diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx
index 3304d6be6..e939b831d 100644
--- a/packages/opencode/src/cli/cmd/tui/app.tsx
+++ b/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(() => )
},
},
+ ...(Flag.OPENCODE_EXPERIMENTAL_WORKSPACES_TUI
+ ? [
+ {
+ title: "Manage workspaces",
+ value: "workspace.list",
+ category: "Workspace",
+ suggested: true,
+ slash: {
+ name: "workspaces",
+ },
+ onSelect: () => {
+ dialog.replace(() => )
+ },
+ },
+ ]
+ : []),
{
title: "New session",
suggested: route.data.type === "session",
diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx
new file mode 100644
index 000000000..a25b20505
--- /dev/null
+++ b/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
+ route: ReturnType
+ sdk: ReturnType
+ sync: ReturnType
+ toast: ReturnType
+ 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 }) {
+ const dialog = useDialog()
+ const sync = useSync()
+ const sdk = useSDK()
+ const toast = useToast()
+ const [creating, setCreating] = createSignal()
+
+ 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 (
+ {
+ 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()
+ const [counts, setCounts] = createSignal>({})
+
+ 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(() => )
+ return
+ }
+ route.navigate({
+ type: "home",
+ })
+ dialog.clear()
+ return
+ }
+ const count = counts()[workspaceID]
+ if (count && count > 0) {
+ dialog.replace(() => )
+ 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(() => )
+ 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 (
+ {
+ setToDelete(undefined)
+ }}
+ onSelect={(option) => {
+ setToDelete(undefined)
+ if (option.value === "__create__") {
+ dialog.replace(() => 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()
+ },
+ },
+ ]}
+ />
+ )
+}
diff --git a/packages/opencode/src/cli/cmd/tui/component/workspace/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/workspace/dialog-session-list.tsx
new file mode 100644
index 000000000..326f094a5
--- /dev/null
+++ b/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()
+ 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 ? : undefined,
+ }
+ })
+ })
+
+ onMount(() => {
+ dialog.setSize("large")
+ })
+
+ return (
+ {
+ 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(() => )
+ },
+ },
+ ]}
+ />
+ )
+}
diff --git a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx
index 7fa7e05c3..2403a4e93 100644
--- a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx
+++ b/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
@@ -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
+ 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 })
+
+ for await (const event of events.stream) {
+ if (ctrl.signal.aborted) break
+ handleEvent(event)
+ }
+
+ if (timer) clearTimeout(timer)
+ if (queue.length > 0) flush()
+ }
+ })().catch(() => {})
+ }
+
+ onMount(() => {
if (props.events) {
const unsub = props.events.on(handleEvent)
onCleanup(unsub)
- return
- }
-
- // 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) {
- handleEvent(event)
- }
-
- // Flush any remaining events
- if (timer) clearTimeout(timer)
- if (queue.length > 0) {
- flush()
- }
+ } 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,
+ }
},
})
diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx
index 269ed7ae0..3b296a927 100644
--- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx
+++ b/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
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx
index 0c5ea9a85..49b2d6109 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx
+++ b/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 }) => {
@@ -29,6 +30,17 @@ const ContextInfo = (props: { context: Accessor; cost: Acces
)
}
+const WorkspaceInfo = (props: { workspace: Accessor }) => {
+ const { theme } = useTheme()
+ return (
+
+
+ {props.workspace()}
+
+
+ )
+}
+
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() {
-
- Subagent session
-
+ {Flag.OPENCODE_EXPERIMENTAL_WORKSPACES_TUI ? (
+
+
+ Subagent session
+
+
+
+ ) : (
+
+ Subagent session
+
+ )}
+
@@ -124,7 +154,14 @@ export function Header() {
-
+ {Flag.OPENCODE_EXPERIMENTAL_WORKSPACES_TUI ? (
+
+
+
+
+ ) : (
+
+ )}
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
index d3a4ff81e..5358b61ef 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/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)
diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts
index 14a9c8873..6e787c7af 100644
--- a/packages/opencode/src/cli/cmd/tui/thread.ts
+++ b/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", handler),
+ setWorkspace: (workspaceID) => {
+ void client.call("setWorkspace", { workspaceID })
+ },
}
}
diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts
index 4452d6d76..f8dcee78a 100644
--- a/packages/opencode/src/cli/cmd/tui/worker.ts
+++ b/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; 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()
diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts
index 6b2b16c67..7c48e99f4 100644
--- a/packages/opencode/src/flag/flag.ts
+++ b/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"]
diff --git a/packages/sdk/js/src/v2/client.ts b/packages/sdk/js/src/v2/client.ts
index 8685be52d..ad956dd4b 100644
--- a/packages/sdk/js/src/v2/client.ts
+++ b/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 })
}