Parcourir la source

refactor(tui): use v2 client transport

Dax Raad il y a 1 mois
Parent
commit
8b682c42b6

+ 3 - 25
packages/cli/src/tui.ts

@@ -9,26 +9,22 @@ export function runTui(transport: { url: string; headers: RequestInit["headers"]
   const config = TuiConfig.resolve({}, { terminalSuspend: false })
   let disposeSlots: (() => void) | undefined
   return Effect.gen(function* () {
-    const client = createOpencodeClient({ baseUrl: transport.url, headers: transport.headers, fetch: gracefulFetch })
-    // The long-lived server may have started from a different directory than this CLI.
-    // Probe the client cwd on the server before making it the TUI's default location.
+    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),
       Effect.catch(() =>
-        // A client path may not exist on a remote server, so preserve the server's own default in that case.
         Effect.tryPromise(() => client.v2.location.get(undefined, { throwOnError: true })).pipe(
           Effect.map((response) => response.data.directory),
         ),
       ),
     )
     return yield* run({
-      ...transport,
-      directory,
+      client: createOpencodeClient({ ...options, directory }),
       args: {},
       config,
-      fetch: gracefulFetch,
       pluginHost: {
         async start(input) {
           disposeSlots = await loadBuiltinPlugins(input.api, input.runtime)
@@ -40,21 +36,3 @@ export function runTui(transport: { url: string; headers: RequestInit["headers"]
     })
   }).pipe(Effect.provide(Global.defaultLayer))
 }
-
-const legacyDefaults: Record<string, unknown> = {
-  "/config/providers": { providers: [], default: {} },
-  "/provider": { all: [], default: {}, connected: [] },
-  "/agent": [],
-  "/config": {},
-}
-
-const gracefulFetch = Object.assign(
-  async (input: RequestInfo | URL, init?: RequestInit) => {
-    const response = await fetch(input, init)
-    if (response.status !== 404) return response
-    const fallback = legacyDefaults[new URL(input instanceof Request ? input.url : input).pathname]
-    if (fallback === undefined) return response
-    return Response.json(fallback)
-  },
-  { preconnect: fetch.preconnect },
-)

+ 2 - 3
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 { createOpencodeClient } from "@opencode-ai/sdk/v2"
 
 export const AttachCommand = cmd({
   command: "attach <url>",
@@ -132,7 +133,7 @@ export const AttachCommand = cmd({
     const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
     await Effect.runPromise(
       run({
-        url: args.url,
+        client: createOpencodeClient({ baseUrl: args.url, headers, directory }),
         config,
         pluginHost: createLegacyTuiPluginHost(),
         args: {
@@ -140,8 +141,6 @@ export const AttachCommand = cmd({
           sessionID: args.session,
           fork: args.fork,
         },
-        directory,
-        headers,
       }),
     )
   },

+ 4 - 57
packages/opencode/src/cli/cmd/tui.ts

@@ -8,8 +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 type { GlobalEvent } from "@opencode-ai/sdk/v2"
-import type { EventSource } from "@opencode-ai/tui/context/sdk"
+import { createOpencodeClient } from "@opencode-ai/sdk/v2"
 import { writeHeapSnapshot } from "v8"
 import { validateSession } from "../tui/validate-session"
 import { win32InstallCtrlCGuard } from "@opencode-ai/tui/terminal-win32"
@@ -18,36 +17,6 @@ declare global {
   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() {
   if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH
   const dist = new URL("./cli/tui/worker.js", import.meta.url)
@@ -211,32 +180,13 @@ export const TuiThreadCommand = cmd({
       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
-        ? {
-            url: (await client.call("server", network)).url,
-            fetch: undefined,
-            events: undefined,
-          }
-        : {
-            url: "http://opencode.internal",
-            fetch: createWorkerFetch(client),
-            events: createEventSource(client),
-          }
+      const url = (await client.call("server", network)).url
 
       try {
         await validateSession({
-          url: transport.url,
+          url,
           sessionID: args.session,
           directory: cwd,
-          fetch: transport.fetch,
         })
       } catch (error) {
         UI.error(errorMessage(error))
@@ -254,7 +204,7 @@ export const TuiThreadCommand = cmd({
         const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
         await Effect.runPromise(
           run({
-            url: transport.url,
+            client: createOpencodeClient({ baseUrl: url, directory: cwd }),
             async onSnapshot() {
               const tui = writeHeapSnapshot("tui.heapsnapshot")
               const server = await client.call("snapshot", undefined)
@@ -262,9 +212,6 @@ export const TuiThreadCommand = cmd({
             },
             config,
             pluginHost: createLegacyTuiPluginHost(),
-            directory: cwd,
-            fetch: transport.fetch,
-            events: transport.events,
             args: {
               continue: args.continue,
               sessionID: args.session,

+ 0 - 26
packages/opencode/src/cli/tui/worker.ts

@@ -3,8 +3,6 @@ import { InstanceRuntime } from "@/project/instance-runtime"
 import { Rpc } from "@/util/rpc"
 import { upgrade } from "@/cli/upgrade"
 import { Config } from "@/config/config"
-import { GlobalBus } from "@/bus/global"
-import { ServerAuth } from "@/server/auth"
 import { writeHeapSnapshot } from "node:v8"
 import { Heap } from "@/cli/heap"
 import { AppRuntime } from "@/effect/app-runtime"
@@ -20,33 +18,9 @@ const onUncaughtException = (_error: Error) => {}
 process.on("unhandledRejection", onUnhandledRejection)
 process.on("uncaughtException", onUncaughtException)
 
-// 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
 
 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() {
     const result = writeHeapSnapshot("server.heapsnapshot")
     return result

+ 0 - 82
packages/opencode/test/fixture/tui-sdk.ts

@@ -1,82 +0,0 @@
-import type { GlobalEvent } from "@opencode-ai/sdk/v2"
-import type { EventSource } from "@opencode-ai/tui/context/sdk"
-
-export const worktree = "/tmp/opencode"
-export const directory = `${worktree}/packages/opencode`
-
-export function json(data: unknown, init?: ResponseInit) {
-  return new Response(JSON.stringify(data), {
-    ...init,
-    headers: { "content-type": "application/json", ...(init?.headers ?? {}) },
-  })
-}
-
-export function eventSource(): EventSource {
-  return { subscribe: async () => () => {} }
-}
-
-export function createEventSource() {
-  let fn: ((event: GlobalEvent) => void) | undefined
-
-  return {
-    source: {
-      subscribe: async (handler: (event: GlobalEvent) => void) => {
-        fn = handler
-        return () => {
-          if (fn === handler) fn = undefined
-        }
-      },
-    } satisfies EventSource,
-    emit(event: GlobalEvent) {
-      if (!fn) throw new Error("event source not ready")
-      fn(event)
-    },
-  }
-}
-
-export type FetchHandler = (url: URL) => Response | Promise<Response> | undefined
-
-export function createFetch(override?: FetchHandler) {
-  const session = [] as URL[]
-  const fetch = (async (input: RequestInfo | URL) => {
-    const url = new URL(input instanceof Request ? input.url : String(input))
-    if (url.pathname === "/session") session.push(url)
-
-    const overridden = await override?.(url)
-    if (overridden) return overridden
-
-    switch (url.pathname) {
-      case "/agent":
-      case "/command":
-      case "/experimental/workspace":
-      case "/experimental/workspace/status":
-      case "/formatter":
-      case "/lsp":
-        return json([])
-      case "/config":
-      case "/experimental/resource":
-      case "/mcp":
-      case "/provider/auth":
-      case "/session/status":
-        return json({})
-      case "/config/providers":
-        return json({ providers: {}, default: {} })
-      case "/experimental/console":
-        return json({ consoleManagedProviders: [], switchableOrgCount: 0 })
-      case "/path":
-        return json({ home: "", state: "", config: "", worktree, directory })
-      case "/project/current":
-        return json({ id: "proj_test" })
-      case "/provider":
-        return json({ all: [], default: {}, connected: [] })
-      case "/session":
-        return json([])
-      case "/vcs":
-        return json({ branch: "main" })
-    }
-
-    throw new Error(`unexpected request: ${url.pathname}`)
-  }) as typeof globalThis.fetch
-
-  return { fetch, session }
-}

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

@@ -76,7 +76,7 @@ import {
   useOpencodeKeymap,
 } from "./keymap"
 
-import type { EventSource } from "./context/sdk"
+import type { OpencodeClient } from "@opencode-ai/sdk/v2"
 import { DialogVariant } from "./component/dialog-variant"
 import { createTuiAttention } from "./attention"
 import * as TuiAudio from "./audio"
@@ -134,14 +134,10 @@ const appBindingCommands = [
 ] as const
 
 export type TuiInput = {
-  url: string
+  client: OpencodeClient
   args: Args
   config: TuiConfig.Resolved
   onSnapshot?: () => Promise<string[]>
-  directory?: string
-  fetch?: typeof fetch
-  headers?: RequestInit["headers"]
-  events?: EventSource
   pluginHost: TuiPluginHost
 }
 
@@ -293,13 +289,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
                                   >
                                     <TuiConfigProvider config={input.config}>
                                       <PluginRuntimeProvider value={pluginRuntime}>
-                                        <SDKProvider
-                                          url={input.url}
-                                          directory={input.directory}
-                                          fetch={input.fetch}
-                                          headers={input.headers}
-                                          events={input.events}
-                                        >
+                                        <SDKProvider client={input.client}>
                                           <ProjectProvider>
                                             <SyncProvider>
                                               <DataProvider>
@@ -394,6 +384,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
       event,
       sdk,
       sync,
+      data,
       theme: themeState,
       toast,
       renderer,

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

@@ -75,7 +75,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
     async (projectID, info): Promise<ProjectDirectory[] | undefined> => {
       try {
         await sdk.client.v2.projectCopy.refresh(
-          { projectID, location: { directory: sdk.directory } },
+          { projectID, location: { directory: projectContext.instance.directory() || paths.cwd } },
           { throwOnError: true },
         )
         const directories = await sdk.client.project.directories({ projectID }, { throwOnError: true })
@@ -224,7 +224,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
     const result = await sdk.client.v2.projectCopy
       .remove({
         projectID: props.projectID,
-        location: { directory: sdk.directory },
+        location: { directory: projectContext.instance.directory() || paths.cwd },
         directory: selected.directory,
         force: false,
       })
@@ -246,7 +246,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
         const forced = await sdk.client.v2.projectCopy
           .remove({
             projectID: props.projectID,
-            location: { directory: sdk.directory },
+            location: { directory: projectContext.instance.directory() || paths.cwd },
             directory: selected.directory,
             force: true,
           })

+ 7 - 1
packages/tui/src/component/dialog-session-list.tsx

@@ -13,6 +13,7 @@ import { useLocal } from "../context/local"
 import { createDebouncedSignal } from "../util/signal"
 import { useToast } from "../ui/toast"
 import { useCommandShortcut } from "../keymap"
+import { Spinner } from "./spinner"
 
 export function DialogSessionList() {
   const dialog = useDialog()
@@ -71,7 +72,12 @@ export function DialogSessionList() {
         value: session.id,
         category,
         footer,
-        gutter: slot === undefined ? undefined : () => <text fg={theme.accent}>{slot}</text>,
+        gutter:
+          data.session.status(session.id) === "running"
+            ? () => <Spinner />
+            : slot === undefined
+              ? undefined
+              : () => <text fg={theme.accent}>{slot}</text>,
       }
     }
 

+ 1 - 1
packages/tui/src/component/dialog-workspace-create.tsx

@@ -54,7 +54,7 @@ async function loadWorkspaceAdapters(input: {
   sync: ReturnType<typeof useSync>
   toast: ReturnType<typeof useToast>
 }) {
-  const dir = input.sync.path.directory || input.sdk.directory
+  const dir = input.sync.path.directory || process.cwd()
   try {
     const response = await input.sdk.client.experimental.workspace.adapter.list({ directory: dir })
     if (response.error) throw response.error

+ 42 - 111
packages/tui/src/component/prompt/index.tsx

@@ -40,12 +40,10 @@ import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
 import type { AssistantMessage, FilePart, UserMessage } from "@opencode-ai/sdk/v2"
 import { Locale } from "../../util/locale"
 import { errorMessage } from "../../util/error"
-import { formatDuration } from "../../util/format"
 import { createColors, createFrames } from "../../ui/spinner"
 import { useDialog } from "../../ui/dialog"
 import { DialogIntegration } from "../dialog-integration"
 import { useConnected } from "../use-connected"
-import { DialogAlert } from "../../ui/dialog-alert"
 import { useToast } from "../../ui/toast"
 import { useKV } from "../../context/kv"
 import { createFadeIn } from "../../util/signal"
@@ -155,10 +153,11 @@ export function Prompt(props: PromptProps) {
   const route = useRoute()
   const project = useProject()
   const sync = useSync()
+  const data = useData()
   const tuiConfig = useTuiConfig()
   const dialog = useDialog()
   const toast = useToast()
-  const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" })
+  const status = createMemo(() => data.session.status(props.sessionID ?? ""))
   const history = usePromptHistory()
   const stash = usePromptStash()
   const keymap = useOpencodeKeymap()
@@ -207,7 +206,6 @@ export function Prompt(props: PromptProps) {
   const [auto, setAuto] = createSignal<AutocompleteRef>()
   const workspace = usePromptWorkspace(props.sessionID)
   const move = usePromptMove({ projectID: project.project, sessionID: () => props.sessionID })
-  const data = useData()
   const [cursorVersion, setCursorVersion] = createSignal(0)
   const currentProviderLabel = createMemo(() => local.model.parsed().provider)
   const connected = useConnected()
@@ -385,7 +383,7 @@ export function Prompt(props: PromptProps) {
         name: "session.interrupt",
         category: "Session",
         hidden: true,
-        enabled: status().type !== "idle",
+        enabled: status() === "running",
         run: () => {
           if (auto()?.visible) return
           if (!input.focused) return
@@ -1343,7 +1341,7 @@ export function Prompt(props: PromptProps) {
 
   const spinnerDef = createMemo(() => {
     const agent =
-      status().type !== "idle"
+      status() === "running"
         ? (local.agent.list().find((agent) => agent.id === lastUserMessage()?.agent) ?? local.agent.current())
         : local.agent.current()
     const color = agent ? local.agent.color(agent.id) : theme.border
@@ -1529,77 +1527,12 @@ export function Prompt(props: PromptProps) {
         </box>
         <box width="100%" flexDirection="row" justifyContent="space-between">
           <Switch>
-            <Match when={status().type !== "idle"}>
-              <box
-                flexDirection="row"
-                gap={1}
-                flexGrow={1}
-                justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
-              >
-                <box flexShrink={0} flexDirection="row" gap={1}>
-                  <box marginLeft={1}>
-                    <Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[⋯]</text>}>
-                      <spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
-                    </Show>
-                  </box>
-                  <box flexDirection="row" gap={1} flexShrink={0}>
-                    {(() => {
-                      const retry = createMemo(() => {
-                        const s = status()
-                        if (s.type !== "retry") return
-                        return s
-                      })
-                      const message = createMemo(() => {
-                        const r = retry()
-                        if (!r) return
-                        if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
-                          return "gemini is way too hot right now"
-                        if (r.message.length > 80) return r.message.slice(0, 80) + "..."
-                        return r.message
-                      })
-                      const isTruncated = createMemo(() => {
-                        const r = retry()
-                        if (!r) return false
-                        return r.message.length > 120
-                      })
-                      const [seconds, setSeconds] = createSignal(0)
-                      onMount(() => {
-                        const timer = setInterval(() => {
-                          const next = retry()?.next
-                          if (next) setSeconds(Math.round((next - Date.now()) / 1000))
-                        }, 1000)
-
-                        onCleanup(() => {
-                          clearInterval(timer)
-                        })
-                      })
-                      const handleMessageClick = () => {
-                        const r = retry()
-                        if (!r) return
-                        if (isTruncated()) {
-                          void DialogAlert.show(dialog, "Retry Error", r.message)
-                        }
-                      }
-
-                      const retryText = () => {
-                        const r = retry()
-                        if (!r) return ""
-                        const baseMessage = message()
-                        const truncatedHint = isTruncated() ? " (click to expand)" : ""
-                        const duration = formatDuration(seconds())
-                        const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]`
-                        return baseMessage + truncatedHint + retryInfo
-                      }
-
-                      return (
-                        <Show when={retry()}>
-                          <box onMouseUp={handleMessageClick}>
-                            <text fg={theme.error}>{retryText()}</text>
-                          </box>
-                        </Show>
-                      )
-                    })()}
-                  </box>
+            <Match when={status() === "running"}>
+              <box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start">
+                <box marginLeft={1}>
+                  <Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[⋯]</text>}>
+                    <spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
+                  </Show>
                 </box>
                 <text fg={store.interrupt > 0 ? theme.primary : theme.text}>
                   esc{" "}
@@ -1661,41 +1594,39 @@ export function Prompt(props: PromptProps) {
             </Match>
             <Match when={true}>{props.hint ?? <text />}</Match>
           </Switch>
-          <Show when={status().type !== "retry"}>
-            <box gap={2} flexDirection="row">
-              <Show when={editorContextLabelState() !== "none" ? editorFileLabelDisplay() : undefined}>
-                {(file) => (
-                  <text fg={editorContextLabelState() === "pending" ? theme.secondary : theme.textMuted}>{file()}</text>
-                )}
-              </Show>
-              <Switch>
-                <Match when={store.mode === "normal"}>
-                  <Switch>
-                    <Match when={usage()}>
-                      {(item) => (
-                        <text fg={theme.textMuted} wrapMode="none">
-                          {[item().context, item().cost].filter(Boolean).join(" · ")}
-                        </text>
-                      )}
-                    </Match>
-                    <Match when={true}>
-                      <text fg={theme.text}>
-                        {agentShortcut()} <span style={{ fg: theme.textMuted }}>agents</span>
+          <box gap={2} flexDirection="row">
+            <Show when={editorContextLabelState() !== "none" ? editorFileLabelDisplay() : undefined}>
+              {(file) => (
+                <text fg={editorContextLabelState() === "pending" ? theme.secondary : theme.textMuted}>{file()}</text>
+              )}
+            </Show>
+            <Switch>
+              <Match when={store.mode === "normal"}>
+                <Switch>
+                  <Match when={usage()}>
+                    {(item) => (
+                      <text fg={theme.textMuted} wrapMode="none">
+                        {[item().context, item().cost].filter(Boolean).join(" · ")}
                       </text>
-                    </Match>
-                  </Switch>
-                  <text fg={theme.text}>
-                    {paletteShortcut()} <span style={{ fg: theme.textMuted }}>commands</span>
-                  </text>
-                </Match>
-                <Match when={store.mode === "shell"}>
-                  <text fg={theme.text}>
-                    esc <span style={{ fg: theme.textMuted }}>exit shell mode</span>
-                  </text>
-                </Match>
-              </Switch>
-            </box>
-          </Show>
+                    )}
+                  </Match>
+                  <Match when={true}>
+                    <text fg={theme.text}>
+                      {agentShortcut()} <span style={{ fg: theme.textMuted }}>agents</span>
+                    </text>
+                  </Match>
+                </Switch>
+                <text fg={theme.text}>
+                  {paletteShortcut()} <span style={{ fg: theme.textMuted }}>commands</span>
+                </text>
+              </Match>
+              <Match when={store.mode === "shell"}>
+                <text fg={theme.text}>
+                  esc <span style={{ fg: theme.textMuted }}>exit shell mode</span>
+                </text>
+              </Match>
+            </Switch>
+          </box>
         </box>
       </box>
       <Autocomplete

+ 1 - 1
packages/tui/src/component/prompt/move.tsx

@@ -40,7 +40,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
       const result = await sdk.client.v2.projectCopy.create(
         {
           projectID,
-          location: { directory: sdk.directory },
+          location: { directory: project.instance.directory() || paths.cwd },
           strategy: "git_worktree",
           directory: path.join(paths.worktree, projectID.slice(0, 6)),
           name: generated.data.name,

+ 22 - 1
packages/tui/src/context/data.tsx

@@ -25,6 +25,7 @@ import { createSignal, onCleanup, onMount } from "solid-js"
 import { createGlobalEmitter } from "@solid-primitives/event-bus"
 
 export type DataConnectionStatus = "connecting" | "connected" | "reconnecting"
+export type DataSessionStatus = "idle" | "running"
 
 export type DataEvent = V2Event
 type DataEventMap = { [T in DataEvent["type"]]: Extract<DataEvent, { type: T }> }
@@ -47,6 +48,7 @@ type Data = {
   }
   session: {
     info: Record<string, SessionV2Info>
+    status: Record<string, DataSessionStatus>
     message: Record<string, SessionMessage[]>
     permission: Record<string, PermissionV2Request[]>
     question: Record<string, QuestionV2Request[]>
@@ -75,6 +77,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
       },
       session: {
         info: {},
+        status: {},
         message: {},
         permission: {},
         question: {},
@@ -88,7 +91,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
     const sdk = useSDK()
     const events = createGlobalEmitter<DataEventMap>()
     const [defaultLocation, setDefaultLocation] = createSignal<LocationRef>({
-      directory: sdk.directory ?? process.cwd(),
+      directory: process.cwd(),
     })
     const messageIndex = new Map<string, Map<string, number>>()
 
@@ -179,6 +182,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
           })
           break
         case "session.next.prompted": {
+          setStore("session", "status", event.data.sessionID, "running")
           message.update(event.data.sessionID, (draft, index) => {
             message.append(draft, index, {
               id: event.data.messageID,
@@ -215,6 +219,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
           })
           break
         case "session.next.shell.started":
+          setStore("session", "status", event.data.sessionID, "running")
           message.update(event.data.sessionID, (draft, index) => {
             message.append(draft, index, {
               id: event.data.messageID,
@@ -227,6 +232,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
           })
           break
         case "session.next.shell.ended":
+          setStore("session", "status", event.data.sessionID, "idle")
           message.update(event.data.sessionID, (draft, index) => {
             const match = message.activeShell(draft, event.data.callID)
             if (!match) return
@@ -235,6 +241,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
           })
           break
         case "session.next.step.started":
+          setStore("session", "status", event.data.sessionID, "running")
           message.update(event.data.sessionID, (draft, index) => {
             if (index.has(event.data.assistantMessageID)) return
             const currentAssistant = message.activeAssistant(draft)
@@ -251,6 +258,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
           })
           break
         case "session.next.step.ended":
+          setStore("session", "status", event.data.sessionID, event.data.finish === "tool-calls" ? "running" : "idle")
           message.update(event.data.sessionID, (draft, index) => {
             const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
             if (!currentAssistant) return
@@ -263,6 +271,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
           })
           break
         case "session.next.step.failed":
+          setStore("session", "status", event.data.sessionID, "idle")
           message.update(event.data.sessionID, (draft, index) => {
             const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
             if (!currentAssistant) return
@@ -406,6 +415,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
           break
         case "session.next.retried":
         case "session.next.compaction.started":
+          setStore("session", "status", event.data.sessionID, "running")
+          break
         case "session.next.compaction.delta":
           break
         case "session.next.compaction.ended":
@@ -490,6 +501,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
         get(sessionID: string) {
           return store.session.info[sessionID]
         },
+        status(sessionID: string) {
+          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)
@@ -653,6 +667,13 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
               }),
             ),
           ),
+        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])),
+          ),
+        ),
         result.location.refresh(),
         result.location.agent.refresh(),
         result.location.integration.refresh(),

+ 1 - 1
packages/tui/src/context/project.tsx

@@ -16,7 +16,7 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
       state: "",
       config: "",
       worktree: "",
-      directory: sdk.directory ?? "",
+      directory: process.cwd(),
     } satisfies Path
 
     const [store, setStore] = createStore({

+ 15 - 77
packages/tui/src/context/sdk.tsx

@@ -1,37 +1,12 @@
-import { createOpencodeClient } from "@opencode-ai/sdk/v2"
-import type { GlobalEvent } from "@opencode-ai/sdk/v2"
+import type { GlobalEvent, OpencodeClient } from "@opencode-ai/sdk/v2"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { createSimpleContext } from "./helper"
 import { batch, onCleanup, onMount } from "solid-js"
 
-export type EventSource = {
-  subscribe: (handler: (event: GlobalEvent) => void) => Promise<() => void>
-}
-
 export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
   name: "SDK",
-  init: (props: {
-    url: string
-    directory?: string
-    fetch?: typeof fetch
-    headers?: RequestInit["headers"]
-    events?: EventSource
-  }) => {
+  init: (props: { client: OpencodeClient }) => {
     const abort = new AbortController()
-    let sse: AbortController | undefined
-
-    function createSDK() {
-      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 emitter = {
       emit(_type: "event", event: GlobalEvent) {
@@ -57,21 +32,15 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
       queue = []
       timer = undefined
       last = Date.now()
-      // Batch all event emissions so all store updates result in a single render
       batch(() => {
-        for (const event of events) {
-          emitter.emit("event", event)
-        }
+        for (const event of events) emitter.emit("event", event)
       })
     }
 
     const handleEvent = (event: GlobalEvent) => {
       queue.push(event)
       const elapsed = Date.now() - last
-
       if (timer) return
-      // If we just flushed recently (within 16ms), batch this with future events
-      // Otherwise, process immediately to avoid latency
       if (elapsed < 16) {
         timer = setTimeout(flush, 16)
         return
@@ -79,73 +48,42 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
       flush()
     }
 
-    function startSSE() {
-      sse?.abort()
-      const ctrl = new AbortController()
-      sse = ctrl
-      ;(async () => {
+    onMount(() => {
+      void (async () => {
         let attempt = 0
-        while (true) {
-          if (abort.signal.aborted || ctrl.signal.aborted) break
-
-          const events = await sdk.global.event({
-            signal: ctrl.signal,
+        while (!abort.signal.aborted) {
+          const events = await props.client.global.event({
+            signal: abort.signal,
             sseMaxRetryAttempts: 0,
           })
 
-          if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
-            // Start syncing workspaces, it's important to do this after
-            // we've started listening to events
-            await sdk.sync.start().catch(() => {})
-          }
+          if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) await props.client.sync.start().catch(() => {})
 
           for await (const event of events.stream) {
-            if (ctrl.signal.aborted) break
+            if (abort.signal.aborted) break
             handleEvent(event)
           }
 
           if (timer) clearTimeout(timer)
           if (queue.length > 0) flush()
           attempt += 1
-          if (abort.signal.aborted || ctrl.signal.aborted) break
-
-          // Exponential backoff
-          const backoff = Math.min(retryDelay * 2 ** (attempt - 1), maxRetryDelay)
-          await new Promise((resolve) => setTimeout(resolve, backoff))
+          if (abort.signal.aborted) break
+          await new Promise((resolve) =>
+            setTimeout(resolve, Math.min(retryDelay * 2 ** (attempt - 1), maxRetryDelay)),
+          )
         }
       })().catch(() => {})
-    }
-
-    onMount(async () => {
-      if (props.events) {
-        const unsub = await props.events.subscribe(handleEvent)
-        onCleanup(unsub)
-
-        if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
-          // Start syncing workspaces, it's important to do this after
-          // we've started listening to events
-          await sdk.sync.start().catch(() => {})
-        }
-      } else {
-        startSSE()
-      }
     })
 
     onCleanup(() => {
       abort.abort()
-      sse?.abort()
       if (timer) clearTimeout(timer)
       handlers.clear()
     })
 
     return {
-      get client() {
-        return sdk
-      },
-      directory: props.directory,
+      client: props.client,
       event: emitter,
-      fetch: props.fetch ?? fetch,
-      url: props.url,
     }
   },
 })

+ 39 - 571
packages/tui/src/context/sync.tsx

@@ -1,55 +1,33 @@
 import type {
-  Message,
   Agent,
-  Provider,
-  Session,
-  Part,
-  Config,
-  Todo,
   Command,
-  PermissionRequest,
-  QuestionRequest,
+  Config,
+  ConsoleState,
+  FormatterStatus,
   LspStatus,
-  McpStatus,
   McpResource,
-  FormatterStatus,
-  SessionStatus,
-  ProviderListResponse,
+  McpStatus,
+  Message,
+  Part,
+  PermissionRequest,
+  Provider,
   ProviderAuthMethod,
-  VcsInfo,
+  ProviderListResponse,
+  QuestionRequest,
+  Session,
   SnapshotFileDiff,
-  ConsoleState,
+  Todo,
+  VcsInfo,
 } from "@opencode-ai/sdk/v2"
-import { createStore, produce, reconcile } from "solid-js/store"
-import { useProject } from "./project"
-import { useEvent } from "./event"
-import { useSDK } from "./sdk"
-import { useTuiStartup } from "./runtime"
+import { createStore } from "solid-js/store"
 import { createSimpleContext } from "./helper"
-import { useExit } from "./exit"
-import { useArgs } from "./args"
-import { batch, onMount } from "solid-js"
-import path from "path"
-import { useKV } from "./kv"
+import { useProject } from "./project"
 
 const emptyConsoleState: ConsoleState = {
   consoleManagedProviders: [],
   switchableOrgCount: 0,
 }
 
-function search<T>(items: T[], target: string, key: (item: T) => string) {
-  let left = 0
-  let right = items.length - 1
-  while (left <= right) {
-    const middle = Math.floor((left + right) / 2)
-    const value = key(items[middle])
-    if (value === target) return { found: true, index: middle }
-    if (value < target) left = middle + 1
-    else right = middle - 1
-  }
-  return { found: false, index: left }
-}
-
 export const {
   context: SyncContext,
   use: useSync,
@@ -57,8 +35,7 @@ export const {
 } = createSimpleContext({
   name: "Sync",
   init: () => {
-    const startup = useTuiStartup()
-    const kv = useKV()
+    const project = useProject()
     const [store, setStore] = createStore<{
       status: "loading" | "partial" | "complete"
       provider: Provider[]
@@ -71,39 +48,23 @@ export const {
       provider_auth: Record<string, ProviderAuthMethod[]>
       agent: Agent[]
       command: Command[]
-      permission: {
-        [sessionID: string]: PermissionRequest[]
-      }
-      question: {
-        [sessionID: string]: QuestionRequest[]
-      }
+      permission: Record<string, PermissionRequest[]>
+      question: Record<string, QuestionRequest[]>
       config: Config
       session: Session[]
-      session_status: {
-        [sessionID: string]: SessionStatus
-      }
-      session_diff: {
-        [sessionID: string]: SnapshotFileDiff[]
-      }
-      todo: {
-        [sessionID: string]: Todo[]
-      }
-      message: {
-        [sessionID: string]: Message[]
-      }
-      part: {
-        [messageID: string]: Part[]
-      }
+      session_diff: Record<string, SnapshotFileDiff[]>
+      todo: Record<string, Todo[]>
+      message: Record<string, Message[]>
+      part: Record<string, Part[]>
       lsp: LspStatus[]
-      mcp: {
-        [key: string]: McpStatus
-      }
-      mcp_resource: {
-        [key: string]: McpResource
-      }
+      mcp: Record<string, McpStatus>
+      mcp_resource: Record<string, McpResource>
       formatter: FormatterStatus[]
       vcs: VcsInfo | undefined
     }>({
+      status: "complete",
+      provider: [],
+      provider_default: {},
       provider_next: {
         all: [],
         default: {},
@@ -114,16 +75,12 @@ export const {
         experimentalBackgroundSubagents: false,
       },
       provider_auth: {},
-      config: {},
-      status: "loading",
       agent: [],
+      command: [],
       permission: {},
       question: {},
-      command: [],
-      provider: [],
-      provider_default: {},
+      config: {},
       session: [],
-      session_status: {},
       session_diff: {},
       todo: {},
       message: {},
@@ -135,521 +92,32 @@ export const {
       vcs: undefined,
     })
 
-    const event = useEvent()
-    const project = useProject()
-    const sdk = useSDK()
-
-    const fullSyncedSessions = new Set<string>()
-    const syncingSessions = new Map<string, Promise<void>>()
-    const hydratingSessions = new Map<string, { messages: Set<string>; parts: Set<string> }>()
-    const touchMessage = (sessionID: string, messageID: string) => {
-      hydratingSessions.get(sessionID)?.messages.add(messageID)
-    }
-    const touchPart = (sessionID: string, partID: string) => {
-      hydratingSessions.get(sessionID)?.parts.add(partID)
-    }
-
-    function sessionListQuery(): { scope?: "project"; path?: string } {
-      if (!kv.get("session_directory_filter_enabled", true)) return { scope: "project" }
-      if (!project.data.instance.path.worktree || !project.data.instance.path.directory) return { scope: "project" }
-      return {
-        path: path
-          .relative(path.resolve(project.data.instance.path.worktree), project.data.instance.path.directory)
-          .replaceAll("\\", "/"),
-      }
-    }
-
-    function listSessions() {
-      return sdk.client.session
-        .list({ start: Date.now() - 30 * 24 * 60 * 60 * 1000, ...sessionListQuery() })
-        .then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
-    }
-
-    event.subscribe((event, { workspace }) => {
-      switch (event.type) {
-        case "server.instance.disposed":
-          void bootstrap()
-          break
-        case "permission.replied": {
-          const requests = store.permission[event.properties.sessionID]
-          if (!requests) break
-          const match = search(requests, event.properties.requestID, (r) => r.id)
-          if (!match.found) break
-          setStore(
-            "permission",
-            event.properties.sessionID,
-            produce((draft) => {
-              draft.splice(match.index, 1)
-            }),
-          )
-          break
-        }
-
-        case "permission.asked": {
-          const request = event.properties
-          const requests = store.permission[request.sessionID]
-          if (!requests) {
-            setStore("permission", request.sessionID, [request])
-            break
-          }
-          const match = search(requests, request.id, (r) => r.id)
-          if (match.found) {
-            setStore("permission", request.sessionID, match.index, reconcile(request))
-            break
-          }
-          setStore(
-            "permission",
-            request.sessionID,
-            produce((draft) => {
-              draft.splice(match.index, 0, request)
-            }),
-          )
-          break
-        }
-
-        case "question.replied":
-        case "question.rejected": {
-          const requests = store.question[event.properties.sessionID]
-          if (!requests) break
-          const match = search(requests, event.properties.requestID, (r) => r.id)
-          if (!match.found) break
-          setStore(
-            "question",
-            event.properties.sessionID,
-            produce((draft) => {
-              draft.splice(match.index, 1)
-            }),
-          )
-          break
-        }
-
-        case "question.asked": {
-          const request = event.properties
-          const requests = store.question[request.sessionID]
-          if (!requests) {
-            setStore("question", request.sessionID, [request])
-            break
-          }
-          const match = search(requests, request.id, (r) => r.id)
-          if (match.found) {
-            setStore("question", request.sessionID, match.index, reconcile(request))
-            break
-          }
-          setStore(
-            "question",
-            request.sessionID,
-            produce((draft) => {
-              draft.splice(match.index, 0, request)
-            }),
-          )
-          break
-        }
-
-        case "todo.updated":
-          setStore("todo", event.properties.sessionID, event.properties.todos)
-          break
-
-        case "session.diff":
-          setStore("session_diff", event.properties.sessionID, event.properties.diff)
-          break
-
-        case "session.deleted": {
-          const result = search(store.session, event.properties.info.id, (s) => s.id)
-          if (result.found) {
-            setStore(
-              "session",
-              produce((draft) => {
-                draft.splice(result.index, 1)
-              }),
-            )
-          }
-          break
-        }
-        case "session.updated": {
-          const result = search(store.session, event.properties.info.id, (s) => s.id)
-          if (result.found) {
-            setStore("session", result.index, reconcile(event.properties.info))
-            break
-          }
-          setStore(
-            "session",
-            produce((draft) => {
-              draft.splice(result.index, 0, event.properties.info)
-            }),
-          )
-          break
-        }
-
-        case "session.next.moved": {
-          const result = search(store.session, event.properties.sessionID, (s) => s.id)
-          if (!result.found) break
-          setStore(
-            "session",
-            result.index,
-            produce((session) => {
-              session.directory = event.properties.location.directory
-              session.path = event.properties.subdirectory
-              session.workspaceID = event.properties.location.workspaceID
-              session.time.updated = event.properties.timestamp
-            }),
-          )
-          break
-        }
-
-        case "session.status": {
-          setStore("session_status", event.properties.sessionID, event.properties.status)
-          break
-        }
-
-        case "message.updated": {
-          touchMessage(event.properties.info.sessionID, event.properties.info.id)
-          const messages = store.message[event.properties.info.sessionID]
-          if (!messages) {
-            setStore("message", event.properties.info.sessionID, [event.properties.info])
-            break
-          }
-          const result = search(messages, event.properties.info.id, (m) => m.id)
-          if (result.found) {
-            setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
-            break
-          }
-          setStore(
-            "message",
-            event.properties.info.sessionID,
-            produce((draft) => {
-              draft.splice(result.index, 0, event.properties.info)
-            }),
-          )
-          const updated = store.message[event.properties.info.sessionID]
-          if (updated.length > 100) {
-            const oldest = updated[0]
-            batch(() => {
-              setStore(
-                "message",
-                event.properties.info.sessionID,
-                produce((draft) => {
-                  draft.shift()
-                }),
-              )
-              setStore(
-                "part",
-                produce((draft) => {
-                  delete draft[oldest.id]
-                }),
-              )
-            })
-          }
-          break
-        }
-        case "message.removed": {
-          touchMessage(event.properties.sessionID, event.properties.messageID)
-          const messages = store.message[event.properties.sessionID]
-          const result = search(messages, event.properties.messageID, (m) => m.id)
-          if (result.found) {
-            setStore(
-              "message",
-              event.properties.sessionID,
-              produce((draft) => {
-                draft.splice(result.index, 1)
-              }),
-            )
-          }
-          break
-        }
-        case "message.part.updated": {
-          touchPart(event.properties.part.sessionID, event.properties.part.id)
-          const parts = store.part[event.properties.part.messageID]
-          if (!parts) {
-            setStore("part", event.properties.part.messageID, [event.properties.part])
-            break
-          }
-          const result = search(parts, event.properties.part.id, (p) => p.id)
-          if (result.found) {
-            setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part))
-            break
-          }
-          setStore(
-            "part",
-            event.properties.part.messageID,
-            produce((draft) => {
-              draft.splice(result.index, 0, event.properties.part)
-            }),
-          )
-          break
-        }
-
-        case "message.part.delta": {
-          const parts = store.part[event.properties.messageID]
-          if (!parts) break
-          const result = search(parts, event.properties.partID, (p) => p.id)
-          if (!result.found) break
-          touchPart(event.properties.sessionID, event.properties.partID)
-          setStore(
-            "part",
-            event.properties.messageID,
-            produce((draft) => {
-              const part = draft[result.index]
-              const field = event.properties.field as keyof typeof part
-              const existing = part[field] as string | undefined
-              ;(part[field] as string) = (existing ?? "") + event.properties.delta
-            }),
-          )
-          break
-        }
-
-        case "message.part.removed": {
-          touchPart(event.properties.sessionID, event.properties.partID)
-          const parts = store.part[event.properties.messageID]
-          const result = search(parts, event.properties.partID, (p) => p.id)
-          if (result.found) {
-            setStore(
-              "part",
-              event.properties.messageID,
-              produce((draft) => {
-                draft.splice(result.index, 1)
-              }),
-            )
-          }
-          break
-        }
-
-        case "lsp.updated": {
-          const workspace = project.workspace.current()
-          void sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", x.data ?? []))
-          break
-        }
-
-        case "vcs.branch.updated": {
-          if (workspace === project.workspace.current()) {
-            setStore("vcs", { branch: event.properties.branch })
-          }
-          break
-        }
-      }
-    })
-
-    const exit = useExit()
-    const args = useArgs()
-
-    async function bootstrap(input: { fatal?: boolean } = {}) {
-      const fatal = input.fatal ?? true
-      const workspace = project.workspace.current()
-      const projectPromise = project.sync()
-      const sessionListPromise = projectPromise.then(() => listSessions())
-
-      // blocking - include session.list when continuing a session
-      const providersPromise = sdk.client.config.providers({ workspace }, { throwOnError: true })
-      const providerListPromise = sdk.client.provider.list({ workspace }, { throwOnError: true })
-      const capabilitiesPromise = sdk.client.experimental.capabilities
-        .get({ workspace }, { throwOnError: true })
-        .then((x) => x.data)
-        .catch(() => undefined)
-      const consoleStatePromise = sdk.client.experimental.console
-        .get({ workspace }, { throwOnError: true })
-        .then((x) => x.data)
-        .catch(() => emptyConsoleState)
-      const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
-      const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
-      await Promise.all([
-        providersPromise,
-        providerListPromise,
-        capabilitiesPromise,
-        agentsPromise,
-        configPromise,
-        projectPromise,
-        ...(args.continue ? [sessionListPromise] : []),
-      ])
-        .then(async () => {
-          const providersResponse = providersPromise.then((x) => x.data!)
-          const providerListResponse = providerListPromise.then((x) => x.data!)
-          const capabilitiesResponse = capabilitiesPromise
-          const consoleStateResponse = consoleStatePromise
-          const agentsResponse = agentsPromise.then((x) => x.data ?? [])
-          const configResponse = configPromise.then((x) => x.data!)
-          const sessionListResponse = args.continue ? sessionListPromise : undefined
-
-          return Promise.all([
-            providersResponse,
-            providerListResponse,
-            capabilitiesResponse,
-            consoleStateResponse,
-            agentsResponse,
-            configResponse,
-            ...(sessionListResponse ? [sessionListResponse] : []),
-          ]).then((responses) => {
-            const providers = responses[0]
-            const providerList = responses[1]
-            const capabilities = responses[2]
-            const consoleState = responses[3]
-            const agents = responses[4]
-            const config = responses[5]
-            const sessions = responses[6]
-
-            batch(() => {
-              setStore("provider", reconcile(providers.providers))
-              setStore("provider_default", reconcile(providers.default))
-              setStore("provider_next", reconcile(providerList))
-              setStore("capabilities", "experimentalBackgroundSubagents", capabilities?.backgroundSubagents === true)
-              setStore("console_state", reconcile(consoleState))
-              setStore("agent", reconcile(agents))
-              setStore("config", reconcile(config))
-              if (sessions !== undefined) setStore("session", reconcile(sessions))
-            })
-          })
-        })
-        .then(() => {
-          if (store.status !== "complete") setStore("status", "partial")
-          // non-blocking
-          void Promise.all([
-            ...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]),
-            consoleStatePromise.then((consoleState) => setStore("console_state", reconcile(consoleState))),
-            sdk.client.command.list({ workspace }).then((x) => setStore("command", reconcile(x.data ?? []))),
-            sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", reconcile(x.data ?? []))),
-            sdk.client.mcp.status({ workspace }).then((x) => setStore("mcp", reconcile(x.data ?? {}))),
-            sdk.client.experimental.resource
-              .list({ workspace })
-              .then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))),
-            sdk.client.formatter.status({ workspace }).then((x) => setStore("formatter", reconcile(x.data ?? []))),
-            sdk.client.session.status({ workspace }).then((x) => {
-              setStore("session_status", reconcile(x.data ?? {}))
-            }),
-            sdk.client.provider.auth({ workspace }).then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
-            sdk.client.vcs.get({ workspace }).then((x) => setStore("vcs", reconcile(x.data))),
-            project.workspace.sync(),
-          ]).then(() => {
-            setStore("status", "complete")
-          })
-        })
-        .catch(async (e) => {
-          console.error("tui bootstrap failed", {
-            error: e instanceof Error ? e.message : String(e),
-            name: e instanceof Error ? e.name : undefined,
-            stack: e instanceof Error ? e.stack : undefined,
-          })
-          if (fatal) {
-            exit(e)
-          } else {
-            throw e
-          }
-        })
-    }
-
-    onMount(() => {
-      void bootstrap()
-    })
-
-    const result = {
+    return {
       data: store,
       set: setStore,
       get status() {
         return store.status
       },
       get ready() {
-        if (startup.skipInitialLoading) return true
-        return store.status !== "loading"
+        return true
       },
       get path() {
         return project.instance.path()
       },
       session: {
-        get(sessionID: string) {
-          const match = search(store.session, sessionID, (s) => s.id)
-          if (match.found) return store.session[match.index]
-          return undefined
+        get(_sessionID: string) {
+          return undefined as Session | undefined
         },
         query() {
-          return sessionListQuery()
-        },
-        async refresh() {
-          const list = await listSessions()
-          setStore("session", reconcile(list))
-        },
-        status(sessionID: string) {
-          const session = result.session.get(sessionID)
-          if (!session) return "idle"
-          if (session.time.compacting) return "compacting"
-          const messages = store.message[sessionID] ?? []
-          const last = messages.at(-1)
-          if (!last) return "idle"
-          if (last.role === "user") return "working"
-          return last.time.completed ? "idle" : "working"
+          return {} as { scope?: "project"; path?: string }
         },
-        async sync(sessionID: string) {
-          if (fullSyncedSessions.has(sessionID)) return
-          const syncing = syncingSessions.get(sessionID)
-          if (syncing) return syncing
-          const tracker = { messages: new Set<string>(), parts: new Set<string>() }
-          hydratingSessions.set(sessionID, tracker)
-          const task = (async () => {
-            const [session, messages, todo, diff] = await Promise.all([
-              sdk.client.session.get({ sessionID }, { throwOnError: true }),
-              sdk.client.session.messages({ sessionID, limit: 100 }),
-              sdk.client.session.todo({ sessionID }),
-              sdk.client.session.diff({ sessionID }),
-            ])
-            setStore(
-              produce((draft) => {
-                const match = search(draft.session, sessionID, (s) => s.id)
-                if (match.found) draft.session[match.index] = session.data!
-                if (!match.found) draft.session.splice(match.index, 0, session.data!)
-                draft.todo[sessionID] = todo.data ?? []
-                const currentMessages = draft.message[sessionID] ?? []
-                const infos = (messages.data ?? []).flatMap((message) => {
-                  if (!tracker.messages.has(message.info.id)) return [message.info]
-                  const current = currentMessages.find((item) => item.id === message.info.id)
-                  return current ? [current] : []
-                })
-                infos.push(
-                  ...currentMessages.filter(
-                    (message) => tracker.messages.has(message.id) && !infos.some((item) => item.id === message.id),
-                  ),
-                )
-                const removed = infos.slice(0, -100)
-                const visible = infos.slice(-100)
-                const visibleIDs = new Set(visible.map((message) => message.id))
-                for (const message of messages.data ?? []) {
-                  if (!visibleIDs.has(message.info.id)) {
-                    delete draft.part[message.info.id]
-                    continue
-                  }
-                  const currentParts = draft.part[message.info.id] ?? []
-                  const parts = message.parts.flatMap((part) => {
-                    const current = currentParts.find((item) => item.id === part.id)
-                    if (tracker.parts.has(part.id)) return current ? [current] : []
-                    if (
-                      current &&
-                      (part.type === "text" || part.type === "reasoning") &&
-                      (current.type === "text" || current.type === "reasoning") &&
-                      part.text.length === 0 &&
-                      current.text.length > 0
-                    ) {
-                      return [current]
-                    }
-                    return [part]
-                  })
-                  parts.push(
-                    ...currentParts.filter(
-                      (part) => tracker.parts.has(part.id) && !parts.some((item) => item.id === part.id),
-                    ),
-                  )
-                  draft.part[message.info.id] = parts
-                }
-                for (const message of removed) delete draft.part[message.id]
-                draft.message[sessionID] = visible
-                draft.session_diff[sessionID] = diff.data ?? []
-              }),
-            )
-            fullSyncedSessions.add(sessionID)
-          })().finally(() => {
-            syncingSessions.delete(sessionID)
-            hydratingSessions.delete(sessionID)
-          })
-          syncingSessions.set(sessionID, task)
-          return task
+        async refresh() {},
+        status(_sessionID: string) {
+          return "idle" as const
         },
+        async sync(_sessionID: string) {},
       },
-      bootstrap,
+      async bootstrap(_input: { fatal?: boolean } = {}) {},
     }
-    return result
   },
 })

+ 23 - 8
packages/tui/src/feature-plugins/system/notifications.ts

@@ -56,15 +56,12 @@ const tui: TuiPlugin = async (api) => {
     permissions.delete(event.properties.requestID)
   })
 
-  api.event.on("session.status", (event) => {
-    const sessionID = event.properties.sessionID
-    if (event.properties.status.type === "busy" || event.properties.status.type === "retry") {
-      active.add(sessionID)
-      errored.delete(sessionID)
-      return
-    }
+  const started = (sessionID: string) => {
+    active.add(sessionID)
+    errored.delete(sessionID)
+  }
 
-    if (event.properties.status.type !== "idle") return
+  const ended = (sessionID: string) => {
     if (!active.has(sessionID)) return
     active.delete(sessionID)
 
@@ -75,6 +72,24 @@ const tui: TuiPlugin = async (api) => {
 
     const session = api.state.session.get(sessionID)
     notify(api, sessionID, "Session done", session?.parentID ? "subagent_done" : "done")
+  }
+
+  api.event.on("session.next.prompted", (event) => started(event.properties.sessionID))
+  api.event.on("session.next.shell.started", (event) => started(event.properties.sessionID))
+  api.event.on("session.next.step.started", (event) => started(event.properties.sessionID))
+  api.event.on("session.next.retried", (event) => started(event.properties.sessionID))
+  api.event.on("session.next.compaction.started", (event) => started(event.properties.sessionID))
+  api.event.on("session.next.shell.ended", (event) => ended(event.properties.sessionID))
+  api.event.on("session.next.step.ended", (event) => {
+    if (event.properties.finish === "tool-calls") return
+    ended(event.properties.sessionID)
+  })
+  api.event.on("session.next.step.failed", (event) => {
+    const sessionID = event.properties.sessionID
+    if (!active.has(sessionID)) return
+    errored.add(sessionID)
+    notify(api, sessionID, "Session error", "error")
+    ended(sessionID)
   })
 
   api.event.on("session.error", (event) => {

+ 5 - 3
packages/tui/src/plugin/adapters.tsx

@@ -4,6 +4,7 @@ import type { useEvent } from "../context/event"
 import type { useRoute } from "../context/route"
 import type { useSDK } from "../context/sdk"
 import type { useSync } from "../context/sync"
+import type { useData } from "../context/data"
 import type { useTheme } from "../context/theme"
 import { Dialog as DialogUI, type useDialog } from "../ui/dialog"
 import type { useOpencodeKeymap } from "../keymap"
@@ -31,6 +32,7 @@ type Input = {
   event: ReturnType<typeof useEvent>
   sdk: ReturnType<typeof useSDK>
   sync: ReturnType<typeof useSync>
+  data: ReturnType<typeof useData>
   theme: ReturnType<typeof useTheme>
   toast: ReturnType<typeof useToast>
   renderer: TuiPluginApi["renderer"]
@@ -95,7 +97,7 @@ function mapOptionCb<Value>(cb?: (item: TuiDialogSelectOption<Value>) => void) {
   return (item: SelectOption<Value>) => cb(pickOption(item))
 }
 
-function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
+function stateApi(sync: ReturnType<typeof useSync>, data: ReturnType<typeof useData>): TuiPluginApi["state"] {
   return {
     get ready() {
       return sync.ready
@@ -135,7 +137,7 @@ function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
         return sync.data.message[sessionID] ?? []
       },
       status(sessionID) {
-        return sync.data.session_status[sessionID]
+        return data.session.status(sessionID) === "running" ? { type: "busy" } : { type: "idle" }
       },
       permission(sessionID) {
         return sync.data.permission[sessionID] ?? []
@@ -297,7 +299,7 @@ export function createTuiApiAdapters(input: Input): Omit<TuiPluginApi, "lifecycl
         return input.kv.ready
       },
     },
-    state: stateApi(input.sync),
+    state: stateApi(input.sync, input.data),
     get client() {
       return input.sdk.client
     },

+ 26 - 26
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 { createEventSource, createFetch, directory, json } from "./fixture/tui-sdk"
+import { 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 })
@@ -17,8 +17,8 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
     setTitle(title)
   }
   const listeners = new Set(process.listeners("SIGHUP"))
-  const events = createEventSource()
-  const calls = createFetch()
+  const events = createEventStream()
+  const calls = createFetch(undefined, events)
   let started!: () => void
   const ready = new Promise<void>((resolve) => {
     started = resolve
@@ -29,11 +29,8 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
     const { run } = await import("../src/app")
     const task = Effect.runPromise(
       run({
-        url: "http://test",
-        directory,
+        client: createClient(calls.fetch),
         config: createTuiResolvedConfig({ plugin_enabled: {} }),
-        fetch: calls.fetch,
-        events: events.source,
         args: {},
         pluginHost: {
           async start() {
@@ -63,21 +60,27 @@ test("app.exit prints the session epilogue after scoped cleanup", async () => {
   const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
   const core = await import("@opentui/core")
   mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
-  const events = createEventSource()
-  const calls = createFetch((url) => {
-    if (url.pathname === "/session")
-      return json([
-        {
-          id: "dummy",
-          title: "Demo session",
-          slug: "dummy",
-          projectID: "project",
-          directory,
-          version: "0.0.0-test",
-          time: { created: 0, updated: 0 },
-        },
-      ])
-  })
+  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 originalWrite = process.stdout.write.bind(process.stdout)
   let stdout = ""
   let api: TuiPluginApi | undefined
@@ -95,11 +98,8 @@ test("app.exit prints the session epilogue after scoped cleanup", async () => {
     const { run } = await import("../src/app")
     const task = Effect.runPromise(
       run({
-        url: "http://test",
-        directory,
+        client: createClient(calls.fetch),
         config: createTuiResolvedConfig({ plugin_enabled: {} }),
-        fetch: calls.fetch,
-        events: events.source,
         args: { continue: true },
         pluginHost: {
           async start(input) {

+ 52 - 50
packages/tui/test/cli/cmd/tui/notifications.test.ts

@@ -83,6 +83,48 @@ function permission(id: string, sessionID = "session"): PermissionRequest {
   }
 }
 
+function stepStarted(id: string, sessionID = "session"): Event {
+  return {
+    id,
+    type: "session.next.step.started",
+    properties: {
+      sessionID,
+      assistantMessageID: `msg_${id}`,
+      timestamp: 0,
+      agent: "build",
+      model: { id: "model", providerID: "provider" },
+    },
+  }
+}
+
+function stepEnded(id: string, sessionID = "session", finish = "stop"): Event {
+  return {
+    id,
+    type: "session.next.step.ended",
+    properties: {
+      sessionID,
+      assistantMessageID: `msg_${id}`,
+      timestamp: 0,
+      finish,
+      cost: 0,
+      tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
+    },
+  }
+}
+
+function stepFailed(id: string, sessionID = "session"): Event {
+  return {
+    id,
+    type: "session.next.step.failed",
+    properties: {
+      sessionID,
+      assistantMessageID: `msg_${id}`,
+      timestamp: 0,
+      error: { type: "unknown", message: "boom" },
+    },
+  }
+}
+
 const questionNotification: TuiAttentionNotifyInput = {
   title: "Demo session",
   message: "Question needs input",
@@ -139,21 +181,9 @@ describe("internal notifications TUI plugin", () => {
   test("notifies when an active session becomes idle and suppresses no-op idle", async () => {
     const harness = await setup()
 
-    harness.emit({
-      id: "event-1",
-      type: "session.status",
-      properties: { sessionID: "session", status: { type: "idle" } },
-    })
-    harness.emit({
-      id: "event-2",
-      type: "session.status",
-      properties: { sessionID: "session", status: { type: "busy" } },
-    })
-    harness.emit({
-      id: "event-3",
-      type: "session.status",
-      properties: { sessionID: "session", status: { type: "idle" } },
-    })
+    harness.emit(stepEnded("event-1"))
+    harness.emit(stepStarted("event-2"))
+    harness.emit(stepEnded("event-3"))
 
     expect(harness.notifications).toEqual([
       {
@@ -169,16 +199,8 @@ describe("internal notifications TUI plugin", () => {
     const harness = await setup()
 
     harness.emit({ id: "event-1", type: "question.asked", properties: question("question-1", "subagent") })
-    harness.emit({
-      id: "event-2",
-      type: "session.status",
-      properties: { sessionID: "subagent", status: { type: "busy" } },
-    })
-    harness.emit({
-      id: "event-3",
-      type: "session.status",
-      properties: { sessionID: "subagent", status: { type: "idle" } },
-    })
+    harness.emit(stepStarted("event-2", "subagent"))
+    harness.emit(stepEnded("event-3", "subagent"))
 
     expect(harness.notifications).toEqual([
       {
@@ -199,21 +221,9 @@ describe("internal notifications TUI plugin", () => {
   test("notifies session errors once and suppresses the following idle done notification", async () => {
     const harness = await setup()
 
-    harness.emit({
-      id: "event-1",
-      type: "session.status",
-      properties: { sessionID: "session", status: { type: "busy" } },
-    })
-    harness.emit({
-      id: "event-2",
-      type: "session.error",
-      properties: { sessionID: "session", error: { name: "UnknownError", data: { message: "boom" } } },
-    })
-    harness.emit({
-      id: "event-3",
-      type: "session.status",
-      properties: { sessionID: "session", status: { type: "idle" } },
-    })
+    harness.emit(stepStarted("event-1"))
+    harness.emit(stepFailed("event-2"))
+    harness.emit(stepEnded("event-3"))
 
     expect(harness.notifications).toEqual([
       {
@@ -228,21 +238,13 @@ describe("internal notifications TUI plugin", () => {
   test("special-cases aborts and model response timeouts", async () => {
     const harness = await setup()
 
-    harness.emit({
-      id: "event-1",
-      type: "session.status",
-      properties: { sessionID: "abort", status: { type: "busy" } },
-    })
+    harness.emit(stepStarted("event-1", "abort"))
     harness.emit({
       id: "event-2",
       type: "session.error",
       properties: { sessionID: "abort", error: { name: "MessageAbortedError", data: { message: "Aborted" } } },
     })
-    harness.emit({
-      id: "event-3",
-      type: "session.status",
-      properties: { sessionID: "timeout", status: { type: "busy" } },
-    })
+    harness.emit(stepStarted("event-3", "timeout"))
     harness.emit({
       id: "event-4",
       type: "session.error",

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

@@ -7,9 +7,9 @@ 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 { createEventSource, createFetch, type FetchHandler, directory } from "../../../fixture/tui-sdk"
+import { createClient, createEventStream, createFetch, type FetchHandler } from "../../../fixture/tui-sdk"
 import { TestTuiContexts } from "../../../fixture/tui-environment"
-export { createEventSource, createFetch, directory, eventSource, json, worktree } from "../../../fixture/tui-sdk"
+export { createEventStream, createFetch, directory, json, worktree } from "../../../fixture/tui-sdk"
 
 export async function wait(fn: () => boolean, timeout = 2000) {
   const start = Date.now()
@@ -22,8 +22,8 @@ export async function wait(fn: () => boolean, timeout = 2000) {
 type Ctx = { kv: ReturnType<typeof useKV>; project: ReturnType<typeof useProject>; sync: ReturnType<typeof useSync> }
 
 export async function mount(override?: FetchHandler, state?: string) {
-  const calls = createFetch(override)
-  const events = createEventSource()
+  const events = createEventStream()
+  const calls = createFetch(override, events)
   let sync!: ReturnType<typeof useSync>
   let project!: ReturnType<typeof useProject>
   let kv!: ReturnType<typeof useKV>
@@ -47,7 +47,7 @@ export async function mount(override?: FetchHandler, state?: string) {
     <TestTuiContexts paths={state ? { state } : undefined}>
       <ArgsProvider>
         <KVProvider>
-          <SDKProvider url="http://test" directory={directory} fetch={calls.fetch} events={events.source}>
+          <SDKProvider client={createClient(calls.fetch)}>
             <ProjectProvider>
               <ExitProvider exit={() => {}}>
                 <SyncProvider>

+ 0 - 262
packages/tui/test/cli/cmd/tui/sync-live-hydration.test.tsx

@@ -1,262 +0,0 @@
-/** @jsxImportSource @opentui/solid */
-import { expect, test } from "bun:test"
-import type { GlobalEvent } from "@opencode-ai/sdk/v2"
-import { tmpdir } from "../../../fixture/fixture"
-import { json, mount, wait } from "./sync-fixture"
-
-const sessionID = "ses_hydration_race"
-const messageID = "msg_hydration_race"
-const partID = "prt_hydration_race"
-const session = {
-  id: sessionID,
-  title: "race",
-  time: { created: 0, updated: 0 },
-  version: "1.15.13",
-  directory: "/tmp/opencode/packages/opencode",
-}
-const assistant = {
-  id: messageID,
-  sessionID,
-  role: "assistant" as const,
-  agent: "build",
-  modelID: "model",
-  providerID: "test",
-  mode: "build",
-  parentID: "msg_user",
-  path: { cwd: session.directory, root: session.directory },
-  cost: 0,
-  tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
-  time: { created: 1, completed: 2 },
-}
-
-function global(payload: GlobalEvent["payload"]): GlobalEvent {
-  return { directory: "/tmp/other", project: "proj_test", payload }
-}
-
-test("stale session hydration does not overwrite live message parts", async () => {
-  await using tmp = await tmpdir()
-  await Bun.write(`${tmp.path}/kv.json`, "{}")
-
-  let resolveMessages!: (response: Response) => void
-  const messages = new Promise<Response>((resolve) => {
-    resolveMessages = resolve
-  })
-  let requested = false
-  const { app, emit, sync } = await mount((url) => {
-    if (url.pathname === `/session/${sessionID}`) return json(session)
-    if (url.pathname === `/session/${sessionID}/message`) {
-      requested = true
-      return messages
-    }
-    if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([])
-    return undefined
-  }, tmp.path)
-
-  try {
-    const hydrate = sync.session.sync(sessionID)
-    await wait(() => requested)
-    emit(global({ id: "evt_message", type: "message.updated", properties: { sessionID, info: assistant } }))
-    emit(
-      global({
-        id: "evt_part",
-        type: "message.part.updated",
-        properties: {
-          sessionID,
-          time: 2,
-          part: { id: partID, sessionID, messageID, type: "text", text: "visible live content" },
-        },
-      }),
-    )
-    await wait(() => sync.data.part[messageID]?.[0]?.type === "text")
-
-    resolveMessages(
-      json([
-        {
-          info: assistant,
-          parts: [{ id: partID, sessionID, messageID, type: "text", text: "" }],
-        },
-      ]),
-    )
-    await hydrate
-
-    expect(sync.data.part[messageID][0]).toMatchObject({ text: "visible live content" })
-  } finally {
-    app.renderer.destroy()
-  }
-})
-
-test("orphan live deltas do not suppress hydrated parts", async () => {
-  await using tmp = await tmpdir()
-  await Bun.write(`${tmp.path}/kv.json`, "{}")
-
-  let resolveMessages!: (response: Response) => void
-  const messages = new Promise<Response>((resolve) => {
-    resolveMessages = resolve
-  })
-  let requested = false
-  const { app, emit, sync } = await mount((url) => {
-    if (url.pathname === `/session/${sessionID}`) return json(session)
-    if (url.pathname === `/session/${sessionID}/message`) {
-      requested = true
-      return messages
-    }
-    if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([])
-    return undefined
-  }, tmp.path)
-
-  try {
-    const hydrate = sync.session.sync(sessionID)
-    await wait(() => requested)
-    emit(
-      global({
-        id: "evt_delta",
-        type: "message.part.delta",
-        properties: { sessionID, messageID, partID, field: "text", delta: "ignored until part exists" },
-      }),
-    )
-    resolveMessages(
-      json([{ info: assistant, parts: [{ id: partID, sessionID, messageID, type: "text", text: "hydrated" }] }]),
-    )
-    await hydrate
-
-    expect(sync.data.part[messageID][0]).toMatchObject({ text: "hydrated" })
-  } finally {
-    app.renderer.destroy()
-  }
-})
-
-test("hydration does not clear text streamed before it starts", async () => {
-  await using tmp = await tmpdir()
-  await Bun.write(`${tmp.path}/kv.json`, "{}")
-
-  let resolveMessages!: (response: Response) => void
-  const messages = new Promise<Response>((resolve) => {
-    resolveMessages = resolve
-  })
-  let requested = false
-  const { app, emit, sync } = await mount((url) => {
-    if (url.pathname === `/session/${sessionID}`) return json(session)
-    if (url.pathname === `/session/${sessionID}/message`) {
-      requested = true
-      return messages
-    }
-    if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([])
-    return undefined
-  }, tmp.path)
-
-  try {
-    emit(global({ id: "evt_message", type: "message.updated", properties: { sessionID, info: assistant } }))
-    emit(
-      global({
-        id: "evt_part",
-        type: "message.part.updated",
-        properties: {
-          sessionID,
-          time: 1,
-          part: { id: partID, sessionID, messageID, type: "text", text: "" },
-        },
-      }),
-    )
-    emit(
-      global({
-        id: "evt_delta",
-        type: "message.part.delta",
-        properties: { sessionID, messageID, partID, field: "text", delta: "visible streamed content" },
-      }),
-    )
-    await wait(() => sync.data.part[messageID]?.[0]?.type === "text" && sync.data.part[messageID][0].text !== "")
-    const hydrate = sync.session.sync(sessionID)
-    await wait(() => requested)
-    resolveMessages(json([{ info: assistant, parts: [{ id: partID, sessionID, messageID, type: "text", text: "" }] }]))
-    await hydrate
-
-    expect(sync.data.part[messageID][0]).toMatchObject({ text: "visible streamed content" })
-  } finally {
-    app.renderer.destroy()
-  }
-})
-
-test("live messages merged during hydration retain the 100 message window", async () => {
-  await using tmp = await tmpdir()
-  await Bun.write(`${tmp.path}/kv.json`, "{}")
-
-  let resolveMessages!: (response: Response) => void
-  const messages = new Promise<Response>((resolve) => {
-    resolveMessages = resolve
-  })
-  let requested = false
-  const { app, emit, sync } = await mount((url) => {
-    if (url.pathname === `/session/${sessionID}`) return json(session)
-    if (url.pathname === `/session/${sessionID}/message`) {
-      requested = true
-      return messages
-    }
-    if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([])
-    return undefined
-  }, tmp.path)
-
-  try {
-    const hydrate = sync.session.sync(sessionID)
-    await wait(() => requested)
-    const live = { ...assistant, id: "msg_z_live" }
-    emit(global({ id: "evt_live", type: "message.updated", properties: { sessionID, info: live } }))
-    await wait(() => sync.data.message[sessionID]?.some((message) => message.id === live.id) ?? false)
-    resolveMessages(
-      json(
-        Array.from({ length: 100 }, (_, index) => {
-          const id = `msg_${String(index).padStart(3, "0")}`
-          return {
-            info: { ...assistant, id },
-            parts: [{ id: `prt_${id}`, sessionID, messageID: id, type: "text", text: id }],
-          }
-        }),
-      ),
-    )
-    await hydrate
-
-    expect(sync.data.message[sessionID]).toHaveLength(100)
-    expect(sync.data.message[sessionID].at(-1)?.id).toBe(live.id)
-    expect(sync.data.message[sessionID].some((message) => message.id === "msg_000")).toBe(false)
-    expect(sync.data.part.msg_000).toBeUndefined()
-  } finally {
-    app.renderer.destroy()
-  }
-})
-
-test("a message removed during hydration does not regain stale parts", async () => {
-  await using tmp = await tmpdir()
-  await Bun.write(`${tmp.path}/kv.json`, "{}")
-
-  let resolveMessages!: (response: Response) => void
-  const messages = new Promise<Response>((resolve) => {
-    resolveMessages = resolve
-  })
-  let requested = false
-  const { app, emit, sync } = await mount((url) => {
-    if (url.pathname === `/session/${sessionID}`) return json(session)
-    if (url.pathname === `/session/${sessionID}/message`) {
-      requested = true
-      return messages
-    }
-    if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([])
-    return undefined
-  }, tmp.path)
-
-  try {
-    emit(global({ id: "evt_message", type: "message.updated", properties: { sessionID, info: assistant } }))
-    await wait(() => sync.data.message[sessionID]?.length === 1)
-    const hydrate = sync.session.sync(sessionID)
-    await wait(() => requested)
-    emit(global({ id: "evt_removed", type: "message.removed", properties: { sessionID, messageID } }))
-    await wait(() => sync.data.message[sessionID]?.length === 0)
-    resolveMessages(
-      json([{ info: assistant, parts: [{ id: partID, sessionID, messageID, type: "text", text: "stale" }] }]),
-    )
-    await hydrate
-
-    expect(sync.data.message[sessionID]).toEqual([])
-    expect(sync.data.part[messageID]).toBeUndefined()
-  } finally {
-    app.renderer.destroy()
-  }
-})

+ 0 - 43
packages/tui/test/cli/cmd/tui/sync-undefined-messages.test.tsx

@@ -1,43 +0,0 @@
-/** @jsxImportSource @opentui/solid */
-/**
- * Reproducer for #26560 — TUI crashes with
- *   `TypeError: undefined is not an object (evaluating 'f.data.map')`
- * when entering a session whose messages endpoint returns a non-2xx.
- * The failure path is `sync.tsx#sync.session.sync` reading
- * `messages.data!` while the SDK leaves `data` undefined on error.
- */
-import { describe, expect, test } from "bun:test"
-import { tmpdir } from "../../../fixture/fixture"
-import { directory, json, mount } from "./sync-fixture"
-
-const sessionID = "ses_undef"
-
-describe("tui sync (#26560)", () => {
-  test("entering a session whose messages endpoint errors does not crash sync", async () => {
-    await using tmp = await tmpdir()
-    await Bun.write(`${tmp.path}/kv.json`, "{}")
-
-    const sessionPayload = {
-      id: sessionID,
-      title: "broken",
-      time: { created: 0, updated: 0 },
-      version: "1.14.42",
-      directory,
-      project_id: "proj_test",
-    }
-    const { app, sync } = await mount((url) => {
-      if (url.pathname === `/session/${sessionID}`) return json(sessionPayload)
-      if (url.pathname === `/session/${sessionID}/messages`) return json({}, { status: 500 })
-      if (url.pathname === `/session/${sessionID}/todo`) return json([])
-      if (url.pathname === `/session/${sessionID}/diff`) return json([])
-      if (url.pathname === "/session") return json([sessionPayload])
-      return undefined
-    }, tmp.path)
-
-    try {
-      await expect(sync.session.sync(sessionID)).resolves.toBeUndefined()
-    } finally {
-      app.renderer.destroy()
-    }
-  })
-})

+ 21 - 62
packages/tui/test/cli/cmd/tui/sync.test.tsx

@@ -1,65 +1,24 @@
 /** @jsxImportSource @opentui/solid */
-import { describe, expect, test } from "bun:test"
-import { tmpdir } from "../../../fixture/fixture"
-import { mount, wait } from "./sync-fixture"
-import type { GlobalEvent } from "@opencode-ai/sdk/v2"
-
-function branchEvent(branch: string, workspace?: string): GlobalEvent {
-  return {
-    directory: "/tmp/other",
-    project: "proj_test",
-    workspace,
-    payload: {
-      id: `evt_vcs_${branch}`,
-      type: "vcs.branch.updated",
-      properties: { branch },
-    },
+import { expect, test } from "bun:test"
+import { mount } from "./sync-fixture"
+
+test("legacy sync is an inert compatibility context", async () => {
+  const { app, session, sync } = await mount()
+
+  try {
+    expect(sync.status).toBe("complete")
+    expect(sync.ready).toBe(true)
+    expect(sync.data.session).toEqual([])
+    expect(sync.data.message).toEqual({})
+    expect(sync.data.provider).toEqual([])
+    expect(sync.session.get("ses_test")).toBeUndefined()
+
+    await sync.bootstrap()
+    await sync.session.refresh()
+    await sync.session.sync("ses_test")
+
+    expect(session).toEqual([])
+  } finally {
+    app.renderer.destroy()
   }
-}
-
-describe("tui sync", () => {
-  test("refresh scopes sessions by default and lists project sessions when disabled", async () => {
-    await using tmp = await tmpdir()
-    await Bun.write(`${tmp.path}/kv.json`, "{}")
-    const { app, kv, sync, session } = await mount(undefined, tmp.path)
-
-    try {
-      expect(kv.get("session_directory_filter_enabled", true)).toBe(true)
-      expect(session.at(-1)?.searchParams.get("roots")).toBeNull()
-      expect(session.at(-1)?.searchParams.get("scope")).toBeNull()
-      expect(session.at(-1)?.searchParams.get("path")).toBe("packages/tui")
-
-      kv.set("session_directory_filter_enabled", false)
-      await sync.session.refresh()
-
-      expect(session.at(-1)?.searchParams.get("scope")).toBe("project")
-      expect(session.at(-1)?.searchParams.get("path")).toBeNull()
-      expect(session.at(-1)?.searchParams.get("roots")).toBeNull()
-    } finally {
-      app.renderer.destroy()
-    }
-  })
-
-  test("vcs branch updates only apply for the active workspace", async () => {
-    await using tmp = await tmpdir()
-    await Bun.write(`${tmp.path}/kv.json`, "{}")
-    const { app, emit, project, sync } = await mount(undefined, tmp.path)
-
-    try {
-      expect(sync.data.vcs?.branch).toBe("main")
-
-      project.workspace.set("ws_a")
-      emit(branchEvent("other", "ws_b"))
-      await Bun.sleep(30)
-
-      expect(sync.data.vcs?.branch).toBe("main")
-
-      emit(branchEvent("feature", "ws_a"))
-      await wait(() => sync.data.vcs?.branch === "feature")
-
-      expect(sync.data.vcs?.branch).toBe("feature")
-    } finally {
-      app.renderer.destroy()
-    }
-  })
 })

+ 84 - 24
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 { createEventSource, createFetch, directory, json } from "../../fixture/tui-sdk"
+import { createClient, createEventStream, createFetch, directory, json } from "../../fixture/tui-sdk"
 import { TestTuiContexts } from "../../fixture/tui-environment"
 
 async function wait(fn: () => boolean, timeout = 2000) {
@@ -21,12 +21,12 @@ function global(payload: Event): GlobalEvent {
   return { directory, project: "proj_test", payload }
 }
 
-function emitEvent(events: ReturnType<typeof createEventSource>, payload: Event) {
+function emitEvent(events: ReturnType<typeof createEventStream>, payload: Event) {
   events.emit(global(payload))
 }
 
 test("refreshes resources into reactive getters", async () => {
-  const events = createEventSource()
+  const events = createEventStream()
   const location = {
     directory,
     project: { id: "proj_test", directory },
@@ -73,7 +73,7 @@ test("refreshes resources into reactive getters", async () => {
 
   const app = await testRender(() => (
     <TestTuiContexts>
-      <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
+      <SDKProvider client={createClient(calls.fetch)}>
         <ProjectProvider>
           <DataProvider>
             <Probe />
@@ -85,7 +85,7 @@ test("refreshes resources into reactive getters", async () => {
 
   try {
     await mounted
-    expect(data.location.default()).toEqual({ directory })
+    expect(data.location.default()).toEqual({ directory: process.cwd() })
     expect(data.session.get("ses_test")).toBeUndefined()
     expect(data.location.agent.list(location)).toBeUndefined()
 
@@ -106,12 +106,12 @@ test("refreshes resources into reactive getters", async () => {
 })
 
 test("reconnects the event stream and bootstraps fresh data", async () => {
-  const events = createEventSource()
+  const events = createEventStream()
   const requests = { event: 0, model: 0 }
   const calls = createFetch((url) => {
     if (url.pathname === "/api/event") {
       requests.event++
-      return events.response()
+      return events.v2()
     }
     if (url.pathname !== "/api/model") return
     requests.model++
@@ -143,7 +143,7 @@ test("reconnects the event stream and bootstraps fresh data", async () => {
 
   const app = await testRender(() => (
     <TestTuiContexts>
-      <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
+      <SDKProvider client={createClient(calls.fetch)}>
         <ProjectProvider>
           <DataProvider>
             <Probe />
@@ -173,8 +173,68 @@ 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" } } })
+  }, events)
+  let data!: ReturnType<typeof useData>
+
+  function Probe() {
+    data = useData()
+    return <box />
+  }
+
+  const app = await testRender(() => (
+    <TestTuiContexts>
+      <SDKProvider client={createClient(calls.fetch)}>
+        <ProjectProvider>
+          <DataProvider>
+            <Probe />
+          </DataProvider>
+        </ProjectProvider>
+      </SDKProvider>
+    </TestTuiContexts>
+  ))
+
+  try {
+    await wait(() => data.session.status("session-active") === "running")
+    expect(data.session.status("session-idle")).toBe("idle")
+
+    emitEvent(events, {
+      id: "evt_step_started",
+      type: "session.next.step.started",
+      properties: {
+        sessionID: "session-live",
+        assistantMessageID: "message-live",
+        timestamp: 1,
+        agent: "build",
+        model: { id: "model", providerID: "provider" },
+      },
+    })
+    await wait(() => data.session.status("session-live") === "running")
+
+    emitEvent(events, {
+      id: "evt_step_ended",
+      type: "session.next.step.ended",
+      properties: {
+        sessionID: "session-live",
+        assistantMessageID: "message-live",
+        timestamp: 2,
+        finish: "stop",
+        cost: 0,
+        tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
+      },
+    })
+    await wait(() => data.session.status("session-live") === "idle")
+  } finally {
+    app.renderer.destroy()
+  }
+})
+
 test("refreshes integrations after integration updates", async () => {
-  const events = createEventSource()
+  const events = createEventStream()
   const requests = { integration: 0, model: 0, provider: 0 }
   const calls = createFetch((url) => {
     if (url.pathname === "/api/model") {
@@ -215,7 +275,7 @@ test("refreshes integrations after integration updates", async () => {
 
   const app = await testRender(() => (
     <TestTuiContexts>
-      <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
+      <SDKProvider client={createClient(calls.fetch)}>
         <ProjectProvider>
           <DataProvider>
             <Probe />
@@ -241,7 +301,7 @@ test("refreshes integrations after integration updates", async () => {
 })
 
 test("refreshes effective catalog data after catalog updates", async () => {
-  const events = createEventSource()
+  const events = createEventStream()
   const requests = { model: 0, provider: 0 }
   const calls = createFetch((url) => {
     if (url.pathname === "/api/model") {
@@ -256,7 +316,7 @@ test("refreshes effective catalog data after catalog updates", async () => {
 
   const app = await testRender(() => (
     <TestTuiContexts>
-      <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
+      <SDKProvider client={createClient(calls.fetch)}>
         <ProjectProvider>
           <DataProvider>
             <box />
@@ -277,7 +337,7 @@ test("refreshes effective catalog data after catalog updates", async () => {
 })
 
 test("refreshes references after updates", async () => {
-  const events = createEventSource()
+  const events = createEventStream()
   let requests = 0
   const calls = createFetch((url) => {
     if (url.pathname !== "/api/reference") return
@@ -301,7 +361,7 @@ test("refreshes references after updates", async () => {
 
   const app = await testRender(() => (
     <TestTuiContexts>
-      <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
+      <SDKProvider client={createClient(calls.fetch)}>
         <ProjectProvider>
           <DataProvider>
             <Probe />
@@ -323,7 +383,7 @@ test("refreshes references after updates", async () => {
 })
 
 test("adds and dismisses permission requests from live events", async () => {
-  const events = createEventSource()
+  const events = createEventStream()
   const calls = createFetch(undefined, events)
   let data!: ReturnType<typeof useData>
 
@@ -334,7 +394,7 @@ test("adds and dismisses permission requests from live events", async () => {
 
   const app = await testRender(() => (
     <TestTuiContexts>
-      <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
+      <SDKProvider client={createClient(calls.fetch)}>
         <ProjectProvider>
           <DataProvider>
             <Probe />
@@ -388,7 +448,7 @@ test("adds and dismisses permission requests from live events", async () => {
 })
 
 test("adds and dismisses question requests from live events", async () => {
-  const events = createEventSource()
+  const events = createEventStream()
   const calls = createFetch(undefined, events)
   let data!: ReturnType<typeof useData>
 
@@ -399,7 +459,7 @@ test("adds and dismisses question requests from live events", async () => {
 
   const app = await testRender(() => (
     <TestTuiContexts>
-      <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
+      <SDKProvider client={createClient(calls.fetch)}>
         <ProjectProvider>
           <DataProvider>
             <Probe />
@@ -451,7 +511,7 @@ test("adds and dismisses question requests from live events", async () => {
 })
 
 test("settles pending tools when a live failure arrives", async () => {
-  const events = createEventSource()
+  const events = createEventStream()
   const calls = createFetch(undefined, events)
   let sync!: ReturnType<typeof useData>
   let ready!: () => void
@@ -467,7 +527,7 @@ test("settles pending tools when a live failure arrives", async () => {
 
   const app = await testRender(() => (
     <TestTuiContexts>
-      <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
+      <SDKProvider client={createClient(calls.fetch)}>
         <ProjectProvider>
           <DataProvider>
             <Probe />
@@ -580,7 +640,7 @@ test("settles pending tools when a live failure arrives", async () => {
 })
 
 test("renders admitted prompts only after they become model-visible", async () => {
-  const events = createEventSource()
+  const events = createEventStream()
   const calls = createFetch(undefined, events)
   let sync!: ReturnType<typeof useData>
   let ready!: () => void
@@ -596,7 +656,7 @@ test("renders admitted prompts only after they become model-visible", async () =
 
   const app = await testRender(() => (
     <TestTuiContexts>
-      <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
+      <SDKProvider client={createClient(calls.fetch)}>
         <ProjectProvider>
           <DataProvider>
             <Probe />
@@ -653,7 +713,7 @@ test("renders admitted prompts only after they become model-visible", async () =
 })
 
 test("projects live context updates with their message ID", async () => {
-  const events = createEventSource()
+  const events = createEventStream()
   const calls = createFetch(undefined, events)
   let sync!: ReturnType<typeof useData>
   let ready!: () => void
@@ -669,7 +729,7 @@ test("projects live context updates with their message ID", async () => {
 
   const app = await testRender(() => (
     <TestTuiContexts>
-      <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
+      <SDKProvider client={createClient(calls.fetch)}>
         <ProjectProvider>
           <DataProvider>
             <Probe />

+ 4 - 4
packages/tui/test/cli/tui/use-event.test.tsx

@@ -6,7 +6,7 @@ import { onMount } from "solid-js"
 import { ProjectProvider, useProject } from "../../../src/context/project"
 import { SDKProvider } from "../../../src/context/sdk"
 import { useEvent } from "../../../src/context/event"
-import { createEventSource, createFetch, directory } from "../../fixture/tui-sdk"
+import { createClient, createEventStream, createFetch, directory } from "../../fixture/tui-sdk"
 import { TestTuiContexts } from "../../fixture/tui-environment"
 
 const projectID = "proj_test"
@@ -49,8 +49,8 @@ function update(version: string): Event {
 }
 
 async function mount() {
-  const events = createEventSource()
-  const calls = createFetch()
+  const events = createEventStream()
+  const calls = createFetch(undefined, events)
   const seen: Event[] = []
   const workspaces: Array<string | undefined> = []
   let project!: ReturnType<typeof useProject>
@@ -61,7 +61,7 @@ async function mount() {
 
   const app = await testRender(() => (
     <TestTuiContexts>
-      <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
+      <SDKProvider client={createClient(calls.fetch)}>
         <ProjectProvider>
           <Probe
             onReady={async (ctx) => {

+ 64 - 47
packages/tui/test/fixture/tui-sdk.ts

@@ -1,5 +1,5 @@
+import { createOpencodeClient } from "@opencode-ai/sdk/v2"
 import type { GlobalEvent } from "@opencode-ai/sdk/v2"
-import type { EventSource } from "../../src/context/sdk"
 
 export const worktree = "/tmp/opencode"
 export const directory = `${worktree}/packages/tui`
@@ -11,71 +11,83 @@ export function json(data: unknown, init?: ResponseInit) {
   })
 }
 
-export function eventSource(): EventSource {
-  return { subscribe: async () => () => {} }
-}
+export function createEventStream() {
+  const encoder = new TextEncoder()
+  const global = new Set<ReadableStreamDefaultController<Uint8Array>>()
+  const v2 = new Set<ReadableStreamDefaultController<Uint8Array>>()
+  const pending = {
+    global: [] as Uint8Array[],
+    v2: [] as Uint8Array[],
+  }
+  const response = (
+    controllers: Set<ReadableStreamDefaultController<Uint8Array>>,
+    queued: Uint8Array[],
+    initial?: unknown,
+  ) => {
+    let current: ReadableStreamDefaultController<Uint8Array> | undefined
+    return new Response(
+      new ReadableStream<Uint8Array>({
+        start(controller) {
+          current = controller
+          controllers.add(controller)
+          if (initial) controller.enqueue(encoder.encode(`data: ${JSON.stringify(initial)}\n\n`))
+          for (const chunk of queued.splice(0)) controller.enqueue(chunk)
+        },
+        cancel() {
+          if (current) controllers.delete(current)
+        },
+      }),
+      { headers: { "content-type": "text/event-stream" } },
+    )
+  }
+  const send = (
+    controllers: Set<ReadableStreamDefaultController<Uint8Array>>,
+    queued: Uint8Array[],
+    event: unknown,
+  ) => {
+    const chunk = encoder.encode(`data: ${JSON.stringify(event)}\n\n`)
+    if (controllers.size === 0) {
+      queued.push(chunk)
+      return
+    }
+    for (const controller of controllers) controller.enqueue(chunk)
+  }
 
-export function createEventSource() {
-  let fn: ((event: GlobalEvent) => void) | undefined
-  let stream: ReadableStreamDefaultController<Uint8Array> | undefined
-  const pending: Uint8Array[] = []
   return {
-    source: {
-      subscribe: async (handler: (event: GlobalEvent) => void) => {
-        fn = handler
-        return () => {
-          if (fn === handler) fn = undefined
-        }
-      },
-    } satisfies EventSource,
     emit(event: GlobalEvent) {
-      if (!fn) throw new Error("event source not ready")
-      fn(event)
+      send(global, pending.global, event)
       if (!("properties" in event.payload)) return
-      const chunk = new TextEncoder().encode(
-        `data: ${JSON.stringify({
-          ...event.payload,
-          location: { directory: event.directory, workspaceID: event.workspace },
-          data: event.payload.properties,
-        })}\n\n`,
-      )
-      if (stream) return stream.enqueue(chunk)
-      pending.push(chunk)
+      send(v2, pending.v2, {
+        ...event.payload,
+        location: { directory: event.directory, workspaceID: event.workspace },
+        data: event.payload.properties,
+      })
     },
-    response() {
-      return new Response(
-        new ReadableStream<Uint8Array>({
-          start(controller) {
-            stream = controller
-            controller.enqueue(
-              new TextEncoder().encode(`data: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n`),
-            )
-            for (const chunk of pending.splice(0)) controller.enqueue(chunk)
-          },
-          cancel() {
-            stream = undefined
-          },
-        }),
-        { headers: { "content-type": "text/event-stream" } },
-      )
+    global() {
+      return response(global, pending.global)
+    },
+    v2() {
+      return response(v2, pending.v2, { id: "evt_connected", type: "server.connected", data: {} })
     },
     disconnect() {
-      stream?.close()
-      stream = undefined
+      for (const controller of [...global, ...v2]) controller.close()
+      global.clear()
+      v2.clear()
     },
   }
 }
 
 export type FetchHandler = (url: URL) => Response | Promise<Response> | undefined
 
-export function createFetch(override?: FetchHandler, events?: ReturnType<typeof createEventSource>) {
+export function createFetch(override?: FetchHandler, events?: ReturnType<typeof createEventStream>) {
   const session = [] as URL[]
   const fetch = (async (input: RequestInfo | URL) => {
     const url = new URL(input instanceof Request ? input.url : String(input))
     if (url.pathname === "/session") session.push(url)
     const overridden = await override?.(url)
     if (overridden) return overridden
-    if (url.pathname === "/api/event" && events) return events.response()
+    if (url.pathname === "/global/event" && events) return events.global()
+    if (url.pathname === "/api/event" && events) return events.v2()
 
     if (
       [
@@ -96,6 +108,7 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
     if (url.pathname === "/path") return json({ home: "", state: "", config: "", worktree, directory })
     if (url.pathname === "/api/location") return json({ directory, project: { id: "proj_test", directory: worktree } })
     if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
+    if (url.pathname === "/api/session/active") return json({ data: {} })
     if (
       ["/api/agent", "/api/model", "/api/provider", "/api/integration", "/api/command", "/api/skill"].includes(
         url.pathname,
@@ -115,3 +128,7 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
   }) as typeof globalThis.fetch
   return { fetch, session }
 }
+
+export function createClient(fetch: typeof globalThis.fetch) {
+  return createOpencodeClient({ baseUrl: "http://test", fetch })
+}