Jelajahi Sumber

feat(tui): add initial support for workspaces into the tui (#16230)

James Long 1 bulan lalu
induk
melakukan
366b8a8034

+ 17 - 0
packages/opencode/src/cli/cmd/tui/app.tsx

@@ -20,6 +20,7 @@ import { DialogHelp } from "./ui/dialog-help"
 import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command"
 import { DialogAgent } from "@tui/component/dialog-agent"
 import { DialogSessionList } from "@tui/component/dialog-session-list"
+import { DialogWorkspaceList } from "@tui/component/dialog-workspace-list"
 import { KeybindProvider } from "@tui/context/keybind"
 import { ThemeProvider, useTheme } from "@tui/context/theme"
 import { Home } from "@tui/routes/home"
@@ -371,6 +372,22 @@ function App() {
         dialog.replace(() => <DialogSessionList />)
       },
     },
+    ...(Flag.OPENCODE_EXPERIMENTAL_WORKSPACES_TUI
+      ? [
+          {
+            title: "Manage workspaces",
+            value: "workspace.list",
+            category: "Workspace",
+            suggested: true,
+            slash: {
+              name: "workspaces",
+            },
+            onSelect: () => {
+              dialog.replace(() => <DialogWorkspaceList />)
+            },
+          },
+        ]
+      : []),
     {
       title: "New session",
       suggested: route.data.type === "session",

+ 326 - 0
packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx

@@ -0,0 +1,326 @@
+import { useDialog } from "@tui/ui/dialog"
+import { DialogSelect } from "@tui/ui/dialog-select"
+import { useRoute } from "@tui/context/route"
+import { useSync } from "@tui/context/sync"
+import { createEffect, createMemo, createSignal, onMount } from "solid-js"
+import type { Session } from "@opencode-ai/sdk/v2"
+import { useSDK } from "../context/sdk"
+import { useToast } from "../ui/toast"
+import { useKeybind } from "../context/keybind"
+import { DialogSessionList } from "./workspace/dialog-session-list"
+import { createOpencodeClient } from "@opencode-ai/sdk/v2"
+
+async function openWorkspace(input: {
+  dialog: ReturnType<typeof useDialog>
+  route: ReturnType<typeof useRoute>
+  sdk: ReturnType<typeof useSDK>
+  sync: ReturnType<typeof useSync>
+  toast: ReturnType<typeof useToast>
+  workspaceID: string
+  forceCreate?: boolean
+}) {
+  const cacheSession = (session: Session) => {
+    input.sync.set(
+      "session",
+      [...input.sync.data.session.filter((item) => item.id !== session.id), session].toSorted((a, b) =>
+        a.id.localeCompare(b.id),
+      ),
+    )
+  }
+
+  const client = createOpencodeClient({
+    baseUrl: input.sdk.url,
+    fetch: input.sdk.fetch,
+    directory: input.sync.data.path.directory || input.sdk.directory,
+    experimental_workspaceID: input.workspaceID,
+  })
+  const listed = input.forceCreate ? undefined : await client.session.list({ roots: true, limit: 1 })
+  const session = listed?.data?.[0]
+  if (session?.id) {
+    cacheSession(session)
+    input.route.navigate({
+      type: "session",
+      sessionID: session.id,
+    })
+    input.dialog.clear()
+    return
+  }
+  let created: Session | undefined
+  while (!created) {
+    const result = await client.session.create({}).catch(() => undefined)
+    if (!result) {
+      input.toast.show({
+        message: "Failed to open workspace",
+        variant: "error",
+      })
+      return
+    }
+    if (result.response.status >= 500 && result.response.status < 600) {
+      await Bun.sleep(1000)
+      continue
+    }
+    if (!result.data) {
+      input.toast.show({
+        message: "Failed to open workspace",
+        variant: "error",
+      })
+      return
+    }
+    created = result.data
+  }
+  cacheSession(created)
+  input.route.navigate({
+    type: "session",
+    sessionID: created.id,
+  })
+  input.dialog.clear()
+}
+
+function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> }) {
+  const dialog = useDialog()
+  const sync = useSync()
+  const sdk = useSDK()
+  const toast = useToast()
+  const [creating, setCreating] = createSignal<string>()
+
+  onMount(() => {
+    dialog.setSize("medium")
+  })
+
+  const options = createMemo(() => {
+    const type = creating()
+    if (type) {
+      return [
+        {
+          title: `Creating ${type} workspace...`,
+          value: "creating" as const,
+          description: "This can take a while for remote environments",
+        },
+      ]
+    }
+    return [
+      {
+        title: "Worktree",
+        value: "worktree" as const,
+        description: "Create a local git worktree",
+      },
+    ]
+  })
+
+  const createWorkspace = async (type: string) => {
+    if (creating()) return
+    setCreating(type)
+
+    const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch((err) => {
+      console.log(err)
+      return undefined
+    })
+    console.log(JSON.stringify(result, null, 2))
+    const workspace = result?.data
+    if (!workspace) {
+      setCreating(undefined)
+      toast.show({
+        message: "Failed to create workspace",
+        variant: "error",
+      })
+      return
+    }
+    await sync.workspace.sync()
+    await props.onSelect(workspace.id)
+    setCreating(undefined)
+  }
+
+  return (
+    <DialogSelect
+      title={creating() ? "Creating Workspace" : "New Workspace"}
+      skipFilter={true}
+      options={options()}
+      onSelect={(option) => {
+        if (option.value === "creating") return
+        void createWorkspace(option.value)
+      }}
+    />
+  )
+}
+
+export function DialogWorkspaceList() {
+  const dialog = useDialog()
+  const route = useRoute()
+  const sync = useSync()
+  const sdk = useSDK()
+  const toast = useToast()
+  const keybind = useKeybind()
+  const [toDelete, setToDelete] = createSignal<string>()
+  const [counts, setCounts] = createSignal<Record<string, number | null | undefined>>({})
+
+  const open = (workspaceID: string, forceCreate?: boolean) =>
+    openWorkspace({
+      dialog,
+      route,
+      sdk,
+      sync,
+      toast,
+      workspaceID,
+      forceCreate,
+    })
+
+  async function selectWorkspace(workspaceID: string) {
+    if (workspaceID === "__local__") {
+      if (localCount() > 0) {
+        dialog.replace(() => <DialogSessionList localOnly={true} />)
+        return
+      }
+      route.navigate({
+        type: "home",
+      })
+      dialog.clear()
+      return
+    }
+    const count = counts()[workspaceID]
+    if (count && count > 0) {
+      dialog.replace(() => <DialogSessionList workspaceID={workspaceID} />)
+      return
+    }
+
+    if (count === 0) {
+      await open(workspaceID)
+      return
+    }
+    const client = createOpencodeClient({
+      baseUrl: sdk.url,
+      fetch: sdk.fetch,
+      directory: sync.data.path.directory || sdk.directory,
+      experimental_workspaceID: workspaceID,
+    })
+    const listed = await client.session.list({ roots: true, limit: 1 }).catch(() => undefined)
+    if (listed?.data?.length) {
+      dialog.replace(() => <DialogSessionList workspaceID={workspaceID} />)
+      return
+    }
+    await open(workspaceID)
+  }
+
+  const currentWorkspaceID = createMemo(() => {
+    if (route.data.type === "session") {
+      return sync.session.get(route.data.sessionID)?.workspaceID ?? "__local__"
+    }
+    return "__local__"
+  })
+
+  const localCount = createMemo(
+    () => sync.data.session.filter((session) => !session.workspaceID && !session.parentID).length,
+  )
+
+  let run = 0
+  createEffect(() => {
+    const workspaces = sync.data.workspaceList
+    const next = ++run
+    if (!workspaces.length) {
+      setCounts({})
+      return
+    }
+    setCounts(Object.fromEntries(workspaces.map((workspace) => [workspace.id, undefined])))
+    void Promise.all(
+      workspaces.map(async (workspace) => {
+        const client = createOpencodeClient({
+          baseUrl: sdk.url,
+          fetch: sdk.fetch,
+          directory: sync.data.path.directory || sdk.directory,
+          experimental_workspaceID: workspace.id,
+        })
+        const result = await client.session.list({ roots: true }).catch(() => undefined)
+        return [workspace.id, result ? (result.data?.length ?? 0) : null] as const
+      }),
+    ).then((entries) => {
+      if (run !== next) return
+      setCounts(Object.fromEntries(entries))
+    })
+  })
+
+  const options = createMemo(() => [
+    {
+      title: "Local",
+      value: "__local__",
+      category: "Workspace",
+      description: "Use the local machine",
+      footer: `${localCount()} session${localCount() === 1 ? "" : "s"}`,
+    },
+    ...sync.data.workspaceList.map((workspace) => {
+      const count = counts()[workspace.id]
+      return {
+        title:
+          toDelete() === workspace.id
+            ? `Delete ${workspace.id}? Press ${keybind.print("session_delete")} again`
+            : workspace.id,
+        value: workspace.id,
+        category: workspace.type,
+        description: workspace.branch ? `Branch ${workspace.branch}` : undefined,
+        footer:
+          count === undefined
+            ? "Loading sessions..."
+            : count === null
+              ? "Sessions unavailable"
+              : `${count} session${count === 1 ? "" : "s"}`,
+      }
+    }),
+    {
+      title: "+ New workspace",
+      value: "__create__",
+      category: "Actions",
+      description: "Create a new workspace",
+    },
+  ])
+
+  onMount(() => {
+    dialog.setSize("large")
+    void sync.workspace.sync()
+  })
+
+  return (
+    <DialogSelect
+      title="Workspaces"
+      skipFilter={true}
+      options={options()}
+      current={currentWorkspaceID()}
+      onMove={() => {
+        setToDelete(undefined)
+      }}
+      onSelect={(option) => {
+        setToDelete(undefined)
+        if (option.value === "__create__") {
+          dialog.replace(() => <DialogWorkspaceCreate onSelect={(workspaceID) => open(workspaceID, true)} />)
+          return
+        }
+        void selectWorkspace(option.value)
+      }}
+      keybind={[
+        {
+          keybind: keybind.all.session_delete?.[0],
+          title: "delete",
+          onTrigger: async (option) => {
+            if (option.value === "__create__" || option.value === "__local__") return
+            if (toDelete() !== option.value) {
+              setToDelete(option.value)
+              return
+            }
+            const result = await sdk.client.experimental.workspace.remove({ id: option.value }).catch(() => undefined)
+            setToDelete(undefined)
+            if (result?.error) {
+              toast.show({
+                message: "Failed to delete workspace",
+                variant: "error",
+              })
+              return
+            }
+            if (currentWorkspaceID() === option.value) {
+              route.navigate({
+                type: "home",
+              })
+            }
+            await sync.workspace.sync()
+          },
+        },
+      ]}
+    />
+  )
+}

+ 151 - 0
packages/opencode/src/cli/cmd/tui/component/workspace/dialog-session-list.tsx

@@ -0,0 +1,151 @@
+import { useDialog } from "@tui/ui/dialog"
+import { DialogSelect } from "@tui/ui/dialog-select"
+import { useRoute } from "@tui/context/route"
+import { useSync } from "@tui/context/sync"
+import { createMemo, createSignal, createResource, onMount, Show } from "solid-js"
+import { Locale } from "@/util/locale"
+import { useKeybind } from "../../context/keybind"
+import { useTheme } from "../../context/theme"
+import { useSDK } from "../../context/sdk"
+import { DialogSessionRename } from "../dialog-session-rename"
+import { useKV } from "../../context/kv"
+import { createDebouncedSignal } from "../../util/signal"
+import { Spinner } from "../spinner"
+import { useToast } from "../../ui/toast"
+
+export function DialogSessionList(props: { workspaceID?: string; localOnly?: boolean } = {}) {
+  const dialog = useDialog()
+  const route = useRoute()
+  const sync = useSync()
+  const keybind = useKeybind()
+  const { theme } = useTheme()
+  const sdk = useSDK()
+  const kv = useKV()
+  const toast = useToast()
+  const [toDelete, setToDelete] = createSignal<string>()
+  const [search, setSearch] = createDebouncedSignal("", 150)
+
+  const [listed, listedActions] = createResource(
+    () => props.workspaceID,
+    async (workspaceID) => {
+      if (!workspaceID) return undefined
+      const result = await sdk.client.session.list({ roots: true })
+      return result.data ?? []
+    },
+  )
+
+  const [searchResults] = createResource(search, async (query) => {
+    if (!query || props.localOnly) return undefined
+    const result = await sdk.client.session.list({
+      search: query,
+      limit: 30,
+      ...(props.workspaceID ? { roots: true } : {}),
+    })
+    return result.data ?? []
+  })
+
+  const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
+
+  const sessions = createMemo(() => {
+    if (searchResults()) return searchResults()!
+    if (props.workspaceID) return listed() ?? []
+    if (props.localOnly) return sync.data.session.filter((session) => !session.workspaceID)
+    return sync.data.session
+  })
+
+  const options = createMemo(() => {
+    const today = new Date().toDateString()
+    return sessions()
+      .filter((x) => {
+        if (x.parentID !== undefined) return false
+        if (props.workspaceID && listed()) return true
+        if (props.workspaceID) return x.workspaceID === props.workspaceID
+        if (props.localOnly) return !x.workspaceID
+        return true
+      })
+      .toSorted((a, b) => b.time.updated - a.time.updated)
+      .map((x) => {
+        const date = new Date(x.time.updated)
+        let category = date.toDateString()
+        if (category === today) {
+          category = "Today"
+        }
+        const isDeleting = toDelete() === x.id
+        const status = sync.data.session_status?.[x.id]
+        const isWorking = status?.type === "busy"
+        return {
+          title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title,
+          bg: isDeleting ? theme.error : undefined,
+          value: x.id,
+          category,
+          footer: Locale.time(x.time.updated),
+          gutter: isWorking ? <Spinner /> : undefined,
+        }
+      })
+  })
+
+  onMount(() => {
+    dialog.setSize("large")
+  })
+
+  return (
+    <DialogSelect
+      title={props.workspaceID ? `Workspace Sessions` : props.localOnly ? "Local Sessions" : "Sessions"}
+      options={options()}
+      skipFilter={!props.localOnly}
+      current={currentSessionID()}
+      onFilter={setSearch}
+      onMove={() => {
+        setToDelete(undefined)
+      }}
+      onSelect={(option) => {
+        route.navigate({
+          type: "session",
+          sessionID: option.value,
+        })
+        dialog.clear()
+      }}
+      keybind={[
+        {
+          keybind: keybind.all.session_delete?.[0],
+          title: "delete",
+          onTrigger: async (option) => {
+            if (toDelete() === option.value) {
+              const deleted = await sdk.client.session
+                .delete({
+                  sessionID: option.value,
+                })
+                .then(() => true)
+                .catch(() => false)
+              setToDelete(undefined)
+              if (!deleted) {
+                toast.show({
+                  message: "Failed to delete session",
+                  variant: "error",
+                })
+                return
+              }
+              if (props.workspaceID) {
+                listedActions.mutate((sessions) => sessions?.filter((session) => session.id !== option.value))
+                return
+              }
+              sync.set(
+                "session",
+                sync.data.session.filter((session) => session.id !== option.value),
+              )
+              return
+            }
+            setToDelete(option.value)
+          },
+        },
+        {
+          keybind: keybind.all.session_rename?.[0],
+          title: "rename",
+          onTrigger: async (option) => {
+            dialog.replace(() => <DialogSessionRename session={option.value} />)
+          },
+        },
+      ]}
+    />
+  )
+}

+ 55 - 31
packages/opencode/src/cli/cmd/tui/context/sdk.tsx

@@ -5,6 +5,7 @@ import { batch, onCleanup, onMount } from "solid-js"
 
 export type EventSource = {
   on: (handler: (event: Event) => void) => () => void
+  setWorkspace?: (workspaceID?: string) => void
 }
 
 export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
@@ -17,13 +18,21 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
     events?: EventSource
   }) => {
     const abort = new AbortController()
-    const sdk = createOpencodeClient({
-      baseUrl: props.url,
-      signal: abort.signal,
-      directory: props.directory,
-      fetch: props.fetch,
-      headers: props.headers,
-    })
+    let workspaceID: string | undefined
+    let sse: AbortController | undefined
+
+    function createSDK() {
+      return createOpencodeClient({
+        baseUrl: props.url,
+        signal: abort.signal,
+        directory: props.directory,
+        fetch: props.fetch,
+        headers: props.headers,
+        experimental_workspaceID: workspaceID,
+      })
+    }
+
+    let sdk = createSDK()
 
     const emitter = createGlobalEmitter<{
       [key in Event["type"]]: Extract<Event, { type: key }>
@@ -61,41 +70,56 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
       flush()
     }
 
-    onMount(async () => {
-      // If an event source is provided, use it instead of SSE
-      if (props.events) {
-        const unsub = props.events.on(handleEvent)
-        onCleanup(unsub)
-        return
-      }
+    function startSSE() {
+      sse?.abort()
+      const ctrl = new AbortController()
+      sse = ctrl
+      ;(async () => {
+        while (true) {
+          if (abort.signal.aborted || ctrl.signal.aborted) break
+          const events = await sdk.event.subscribe({}, { signal: ctrl.signal })
 
-      // Fall back to SSE
-      while (true) {
-        if (abort.signal.aborted) break
-        const events = await sdk.event.subscribe(
-          {},
-          {
-            signal: abort.signal,
-          },
-        )
+          for await (const event of events.stream) {
+            if (ctrl.signal.aborted) break
+            handleEvent(event)
+          }
 
-        for await (const event of events.stream) {
-          handleEvent(event)
+          if (timer) clearTimeout(timer)
+          if (queue.length > 0) flush()
         }
+      })().catch(() => {})
+    }
 
-        // Flush any remaining events
-        if (timer) clearTimeout(timer)
-        if (queue.length > 0) {
-          flush()
-        }
+    onMount(() => {
+      if (props.events) {
+        const unsub = props.events.on(handleEvent)
+        onCleanup(unsub)
+      } else {
+        startSSE()
       }
     })
 
     onCleanup(() => {
       abort.abort()
+      sse?.abort()
       if (timer) clearTimeout(timer)
     })
 
-    return { client: sdk, event: emitter, url: props.url }
+    return {
+      get client() {
+        return sdk
+      },
+      directory: props.directory,
+      event: emitter,
+      fetch: props.fetch ?? fetch,
+      setWorkspace(next?: string) {
+        if (workspaceID === next) return
+        workspaceID = next
+        sdk = createSDK()
+        props.events?.setWorkspace?.(next)
+        if (!props.events) startSSE()
+      },
+      url: props.url,
+    }
   },
 })

+ 16 - 0
packages/opencode/src/cli/cmd/tui/context/sync.tsx

@@ -28,6 +28,7 @@ import { useArgs } from "./args"
 import { batch, onMount } from "solid-js"
 import { Log } from "@/util/log"
 import type { Path } from "@opencode-ai/sdk"
+import type { Workspace } from "@opencode-ai/sdk/v2"
 
 export const { use: useSync, provider: SyncProvider } = createSimpleContext({
   name: "Sync",
@@ -73,6 +74,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
       formatter: FormatterStatus[]
       vcs: VcsInfo | undefined
       path: Path
+      workspaceList: Workspace[]
     }>({
       provider_next: {
         all: [],
@@ -100,10 +102,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
       formatter: [],
       vcs: undefined,
       path: { state: "", config: "", worktree: "", directory: "" },
+      workspaceList: [],
     })
 
     const sdk = useSDK()
 
+    async function syncWorkspaces() {
+      const result = await sdk.client.experimental.workspace.list().catch(() => undefined)
+      if (!result?.data) return
+      setStore("workspaceList", reconcile(result.data))
+    }
+
     sdk.event.listen((e) => {
       const event = e.details
       switch (event.type) {
@@ -413,6 +422,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
             sdk.client.provider.auth().then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
             sdk.client.vcs.get().then((x) => setStore("vcs", reconcile(x.data))),
             sdk.client.path.get().then((x) => setStore("path", reconcile(x.data!))),
+            syncWorkspaces(),
           ]).then(() => {
             setStore("status", "complete")
           })
@@ -481,6 +491,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
           fullSyncedSessions.add(sessionID)
         },
       },
+      workspace: {
+        get(workspaceID: string) {
+          return store.workspaceList.find((workspace) => workspace.id === workspaceID)
+        },
+        sync: syncWorkspaces,
+      },
       bootstrap,
     }
     return result

+ 41 - 4
packages/opencode/src/cli/cmd/tui/routes/session/header.tsx

@@ -7,6 +7,7 @@ import { SplitBorder } from "@tui/component/border"
 import type { AssistantMessage, Session } from "@opencode-ai/sdk/v2"
 import { useCommandDialog } from "@tui/component/dialog-command"
 import { useKeybind } from "../../context/keybind"
+import { Flag } from "@/flag/flag"
 import { useTerminalDimensions } from "@opentui/solid"
 
 const Title = (props: { session: Accessor<Session> }) => {
@@ -29,6 +30,17 @@ const ContextInfo = (props: { context: Accessor<string | undefined>; cost: Acces
   )
 }
 
+const WorkspaceInfo = (props: { workspace: Accessor<string | undefined> }) => {
+  const { theme } = useTheme()
+  return (
+    <Show when={props.workspace()}>
+      <text fg={theme.textMuted} wrapMode="none" flexShrink={0}>
+        {props.workspace()}
+      </text>
+    </Show>
+  )
+}
+
 export function Header() {
   const route = useRouteData("session")
   const sync = useSync()
@@ -59,6 +71,14 @@ export function Header() {
     return result
   })
 
+  const workspace = createMemo(() => {
+    const id = session()?.workspaceID
+    if (!id) return "Workspace local"
+    const info = sync.workspace.get(id)
+    if (!info) return `Workspace ${id}`
+    return `Workspace ${id} (${info.type})`
+  })
+
   const { theme } = useTheme()
   const keybind = useKeybind()
   const command = useCommandDialog()
@@ -83,9 +103,19 @@ export function Header() {
           <Match when={session()?.parentID}>
             <box flexDirection="column" gap={1}>
               <box flexDirection={narrow() ? "column" : "row"} justifyContent="space-between" gap={narrow() ? 1 : 0}>
-                <text fg={theme.text}>
-                  <b>Subagent session</b>
-                </text>
+                {Flag.OPENCODE_EXPERIMENTAL_WORKSPACES_TUI ? (
+                  <box flexDirection="column">
+                    <text fg={theme.text}>
+                      <b>Subagent session</b>
+                    </text>
+                    <WorkspaceInfo workspace={workspace} />
+                  </box>
+                ) : (
+                  <text fg={theme.text}>
+                    <b>Subagent session</b>
+                  </text>
+                )}
+
                 <ContextInfo context={context} cost={cost} />
               </box>
               <box flexDirection="row" gap={2}>
@@ -124,7 +154,14 @@ export function Header() {
           </Match>
           <Match when={true}>
             <box flexDirection={narrow() ? "column" : "row"} justifyContent="space-between" gap={1}>
-              <Title session={session} />
+              {Flag.OPENCODE_EXPERIMENTAL_WORKSPACES_TUI ? (
+                <box flexDirection="column">
+                  <Title session={session} />
+                  <WorkspaceInfo workspace={workspace} />
+                </box>
+              ) : (
+                <Title session={session} />
+              )}
               <ContextInfo context={context} cost={cost} />
             </box>
           </Match>

+ 6 - 0
packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

@@ -182,6 +182,12 @@ export function Session() {
     return new CustomSpeedScroll(3)
   })
 
+  createEffect(() => {
+    if (session()?.workspaceID) {
+      sdk.setWorkspace(session()?.workspaceID)
+    }
+  })
+
   createEffect(async () => {
     await sync.session
       .sync(route.sessionID)

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

@@ -42,6 +42,9 @@ function createWorkerFetch(client: RpcClient): typeof fetch {
 function createEventSource(client: RpcClient): EventSource {
   return {
     on: (handler) => client.on<Event>("event", handler),
+    setWorkspace: (workspaceID) => {
+      void client.call("setWorkspace", { workspaceID })
+    },
   }
 }
 

+ 7 - 3
packages/opencode/src/cli/cmd/tui/worker.ts

@@ -44,7 +44,7 @@ const eventStream = {
   abort: undefined as AbortController | undefined,
 }
 
-const startEventStream = (directory: string) => {
+const startEventStream = (input: { directory: string; workspaceID?: string }) => {
   if (eventStream.abort) eventStream.abort.abort()
   const abort = new AbortController()
   eventStream.abort = abort
@@ -59,7 +59,8 @@ const startEventStream = (directory: string) => {
 
   const sdk = createOpencodeClient({
     baseUrl: "http://opencode.internal",
-    directory,
+    directory: input.directory,
+    experimental_workspaceID: input.workspaceID,
     fetch: fetchFn,
     signal,
   })
@@ -95,7 +96,7 @@ const startEventStream = (directory: string) => {
   })
 }
 
-startEventStream(process.cwd())
+startEventStream({ directory: process.cwd() })
 
 export const rpc = {
   async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) {
@@ -135,6 +136,9 @@ export const rpc = {
     Config.global.reset()
     await Instance.disposeAll()
   },
+  async setWorkspace(input: { workspaceID?: string }) {
+    startEventStream({ directory: process.cwd(), workspaceID: input.workspaceID })
+  },
   async shutdown() {
     Log.Default.info("worker shutting down")
     if (eventStream.abort) eventStream.abort.abort()

+ 2 - 0
packages/opencode/src/flag/flag.ts

@@ -57,6 +57,8 @@ export namespace Flag {
   export const OPENCODE_EXPERIMENTAL_LSP_TOOL = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL")
   export const OPENCODE_DISABLE_FILETIME_CHECK = truthy("OPENCODE_DISABLE_FILETIME_CHECK")
   export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")
+  export const OPENCODE_EXPERIMENTAL_WORKSPACES_TUI =
+    OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES_TUI")
   export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN")
   export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
   export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]

+ 8 - 1
packages/sdk/js/src/v2/client.ts

@@ -5,7 +5,7 @@ import { type Config } from "./gen/client/types.gen.js"
 import { OpencodeClient } from "./gen/sdk.gen.js"
 export { type Config as OpencodeClientConfig, OpencodeClient }
 
-export function createOpencodeClient(config?: Config & { directory?: string }) {
+export function createOpencodeClient(config?: Config & { directory?: string; experimental_workspaceID?: string }) {
   if (!config?.fetch) {
     const customFetch: any = (req: any) => {
       // @ts-ignore
@@ -27,6 +27,13 @@ export function createOpencodeClient(config?: Config & { directory?: string }) {
     }
   }
 
+  if (config?.experimental_workspaceID) {
+    config.headers = {
+      ...config.headers,
+      "x-opencode-workspace": config.experimental_workspaceID,
+    }
+  }
+
   const client = createClient(config)
   return new OpencodeClient({ client })
 }