James Long 3 kuukautta sitten
vanhempi
sitoutus
38129f3663

+ 1 - 0
.opencode/opencode.jsonc

@@ -1,6 +1,7 @@
 {
   "$schema": "https://opencode.ai/config.json",
   "provider": {},
+  "plugin": ["../packages/opencode/src/control-plane/dev/debug-workspace-plugin.ts"],
   "permission": {
     "edit": {
       "packages/opencode/migration/*": "deny",

+ 13 - 53
packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx

@@ -2,7 +2,7 @@ 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, createResource, createSignal, onMount } from "solid-js"
+import { createMemo, createResource, createSignal, onMount, type JSX } from "solid-js"
 import { Locale } from "@/util/locale"
 import { useProject } from "@tui/context/project"
 import { useKeybind } from "../context/keybind"
@@ -10,15 +10,13 @@ import { useTheme } from "../context/theme"
 import { useSDK } from "../context/sdk"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { DialogSessionRename } from "./dialog-session-rename"
-import { Keybind } from "@/util/keybind"
 import { createDebouncedSignal } from "../util/signal"
 import { useToast } from "../ui/toast"
-import { DialogWorkspaceCreate, openWorkspaceSession, restoreWorkspaceSession } from "./dialog-workspace-create"
+import { DialogWorkspaceCreate, restoreWorkspaceSession } from "./dialog-workspace-create"
 import { Spinner } from "./spinner"
 import { errorMessage } from "@/util/error"
 import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed"
-
-type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
+import { WorkspaceLabel } from "./workspace-label"
 
 export function DialogSessionList() {
   const dialog = useDialog()
@@ -44,23 +42,6 @@ export function DialogSessionList() {
   const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
   const sessions = createMemo(() => searchResults() ?? sync.data.session)
 
-  function createWorkspace() {
-    dialog.replace(() => (
-      <DialogWorkspaceCreate
-        onSelect={(workspaceID) =>
-          openWorkspaceSession({
-            dialog,
-            route,
-            sdk,
-            sync,
-            toast,
-            workspaceID,
-          })
-        }
-      />
-    ))
-  }
-
   function recover(session: NonNullable<ReturnType<typeof sessions>[number]>) {
     const workspace = project.workspace.get(session.workspaceID!)
     const list = () => dialog.replace(() => <DialogSessionList />)
@@ -124,30 +105,18 @@ export function DialogSessionList() {
       .map((x) => {
         const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined
 
-        let workspaceStatus: WorkspaceStatus | null = null
-        if (x.workspaceID) {
-          workspaceStatus = project.workspace.status(x.workspaceID) || "error"
-        }
-
-        let footer = ""
+        let footer: JSX.Element | string = ""
         if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
           if (x.workspaceID) {
-            let desc = "unknown"
-            if (workspace) {
-              desc = `${workspace.type}: ${workspace.name}`
-            }
-
-            footer = (
-              <>
-                {desc}{" "}
-                <span
-                  style={{
-                    fg: workspaceStatus === "connected" ? theme.success : theme.error,
-                  }}
-                >
-                  ●
-                </span>
-              </>
+            footer = workspace ? (
+              <WorkspaceLabel
+                type={workspace.type}
+                name={workspace.name}
+                status={project.workspace.status(x.workspaceID) ?? "error"}
+                icon
+              />
+            ) : (
+              <WorkspaceLabel type="unknown" name={x.workspaceID} status="error" icon />
             )
           }
         } else {
@@ -250,15 +219,6 @@ export function DialogSessionList() {
             dialog.replace(() => <DialogSessionRename session={option.value} />)
           },
         },
-        {
-          keybind: Keybind.parse("ctrl+w")[0],
-          title: "new workspace",
-          side: "right",
-          disabled: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
-          onTrigger: () => {
-            createWorkspace()
-          },
-        },
       ]}
     />
   )

+ 200 - 70
packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx

@@ -1,14 +1,13 @@
-import { createOpencodeClient } from "@opencode-ai/sdk/v2"
+import type { Workspace } from "@opencode-ai/sdk/v2"
 import { useDialog } from "@tui/ui/dialog"
-import { DialogSelect } from "@tui/ui/dialog-select"
-import { useRoute } from "@tui/context/route"
+import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
 import { useSync } from "@tui/context/sync"
 import { useProject } from "@tui/context/project"
 import { createMemo, createSignal, onMount } from "solid-js"
-import { setTimeout as sleep } from "node:timers/promises"
 import { errorMessage } from "@/util/error"
 import { useSDK } from "../context/sdk"
 import { useToast } from "../ui/toast"
+import { WorkspaceLabel } from "./workspace-label"
 
 type Adaptor = {
   type: string
@@ -16,54 +15,24 @@ type Adaptor = {
   description: string
 }
 
-function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID: string) {
-  return createOpencodeClient({
-    baseUrl: sdk.url,
-    fetch: sdk.fetch,
-    directory: sync.path.directory || sdk.directory,
-    experimental_workspaceID: workspaceID,
-  })
-}
-
-export async function openWorkspaceSession(input: {
-  dialog: ReturnType<typeof useDialog>
-  route: ReturnType<typeof useRoute>
-  sdk: ReturnType<typeof useSDK>
-  sync: ReturnType<typeof useSync>
-  toast: ReturnType<typeof useToast>
-  workspaceID: string
-}) {
-  const client = scoped(input.sdk, input.sync, input.workspaceID)
-
-  while (true) {
-    const result = await client.session.create({ workspace: input.workspaceID }).catch(() => undefined)
-    if (!result) {
-      input.toast.show({
-        message: "Failed to create workspace session",
-        variant: "error",
-      })
-      return
+export type WorkspaceSelection =
+  | {
+      type: "none"
     }
-    if (result.response?.status && result.response.status >= 500 && result.response.status < 600) {
-      await sleep(1000)
-      continue
+  | {
+      type: "new"
+      workspaceType: string
+      workspaceName: string
     }
-    if (!result.data) {
-      input.toast.show({
-        message: "Failed to create workspace session",
-        variant: "error",
-      })
-      return
+  | {
+      type: "existing"
+      workspaceID: string
+      workspaceType: string
+      workspaceName: string
     }
 
-    input.route.navigate({
-      type: "session",
-      sessionID: result.data.id,
-    })
-    input.dialog.clear()
-    return
-  }
-}
+type WorkspaceSelectValue = WorkspaceSelection | { type: "existing-list" } | { type: "loading" }
+type ExistingWorkspaceSelectValue = { workspace: Workspace }
 
 export async function restoreWorkspaceSession(input: {
   dialog: ReturnType<typeof useDialog>
@@ -101,13 +70,11 @@ export async function restoreWorkspaceSession(input: {
   input.dialog.clear()
 }
 
-export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> | void }) {
+function DialogWorkspaceTypeSelect(props: { onSelect: (adaptor: Adaptor) => Promise<void> | void }) {
   const dialog = useDialog()
   const sync = useSync()
-  const project = useProject()
   const sdk = useSDK()
   const toast = useToast()
-  const [creating, setCreating] = createSignal<string>()
   const [adaptors, setAdaptors] = createSignal<Adaptor[]>()
 
   onMount(() => {
@@ -132,31 +99,196 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
   })
 
   const options = createMemo(() => {
-    const type = creating()
-    if (type) {
+    const list = adaptors()
+    if (!list) {
       return [
         {
-          title: `Creating ${type} workspace...`,
-          value: "creating" as const,
-          description: "This can take a while for remote environments",
+          title: "Loading workspaces...",
+          value: undefined,
+          description: "Fetching available workspace adaptors",
         },
       ]
     }
+    return list.map((item) => ({
+      title: item.name,
+      value: item,
+      description: item.description,
+    }))
+  })
+
+  return (
+    <DialogSelect
+      title="New Workspace"
+      skipFilter={true}
+      renderFilter={false}
+      options={options()}
+      onSelect={async (option) => {
+        if (!option.value) return
+        void props.onSelect(option.value)
+      }}
+    />
+  )
+}
+
+export function DialogWorkspaceSelect(props: {
+  current?: WorkspaceSelection
+  onSelect: (selection: WorkspaceSelection) => Promise<void> | void
+}) {
+  const dialog = useDialog()
+  const project = useProject()
+  const sync = useSync()
+  const sdk = useSDK()
+  const toast = useToast()
+  const [adaptors, setAdaptors] = createSignal<Adaptor[]>()
+
+  onMount(() => {
+    dialog.setSize("medium")
+    void (async () => {
+      const dir = sync.path.directory || sdk.directory
+      const url = new URL("/experimental/workspace/adaptor", sdk.url)
+      if (dir) url.searchParams.set("directory", dir)
+      const res = await sdk
+        .fetch(url)
+        .then((x) => x.json() as Promise<Adaptor[]>)
+        .catch(() => undefined)
+      if (!res) {
+        toast.show({
+          message: "Failed to load workspace adaptors",
+          variant: "error",
+        })
+        return
+      }
+      setAdaptors(res)
+    })()
+  })
+
+  const options = createMemo<DialogSelectOption<WorkspaceSelectValue>[]>(() => {
     const list = adaptors()
     if (!list) {
       return [
         {
           title: "Loading workspaces...",
-          value: "loading" as const,
+          value: { type: "loading" as const },
           description: "Fetching available workspace adaptors",
+          category: "New workspace",
         },
       ]
     }
-    return list.map((item) => ({
-      title: item.name,
-      value: item.type,
-      description: item.description,
-    }))
+    const workspaces = project.workspace.list()
+    return [
+      ...list.map((adaptor) => ({
+        title: adaptor.name,
+        value: { type: "new" as const, workspaceType: adaptor.type, workspaceName: adaptor.name },
+        description: adaptor.description,
+        category: "New workspace",
+      })),
+      {
+        title: "None",
+        value: { type: "none" as const },
+        description: "Use the local project",
+        category: "Choose workspace",
+      },
+      ...workspaces.slice(0, 3).map((workspace: Workspace) => ({
+        title: workspace.name,
+        description: `(${workspace.type})`,
+        value: {
+          type: "existing" as const,
+          workspaceID: workspace.id,
+          workspaceType: workspace.type,
+          workspaceName: workspace.name,
+        },
+        category: "Choose workspace",
+      })),
+      {
+        title: "View all workspaces",
+        value: { type: "existing-list" as const },
+        description: "Choose from all workspaces",
+        category: "Choose workspace",
+      },
+    ]
+  })
+
+  return (
+    <DialogSelect<WorkspaceSelectValue>
+      title="Warp"
+      skipFilter={true}
+      renderFilter={false}
+      options={options()}
+      current={props.current}
+      onSelect={(option) => {
+        if (!option.value) return
+        if (option.value.type === "none") {
+          void props.onSelect(option.value)
+          return
+        }
+        if (option.value.type === "new") {
+          void props.onSelect(option.value)
+          return
+        }
+        if (option.value.type === "existing") {
+          void props.onSelect(option.value)
+          return
+        }
+
+        dialog.replace(() => <DialogExistingWorkspaceSelect onSelect={props.onSelect} />)
+      }}
+    />
+  )
+}
+
+function DialogExistingWorkspaceSelect(props: { onSelect: (selection: WorkspaceSelection) => Promise<void> | void }) {
+  const project = useProject()
+
+  const options = createMemo<DialogSelectOption<ExistingWorkspaceSelectValue>[]>(() =>
+    project.workspace
+      .list()
+      .filter((workspace) => project.workspace.status(workspace.id) === "connected")
+      .map((workspace: Workspace) => ({
+        title: workspace.name,
+        description: `(${workspace.type})`,
+        value: { workspace },
+      })),
+  )
+
+  return (
+    <DialogSelect<ExistingWorkspaceSelectValue>
+      title="Existing Workspace"
+      options={options()}
+      onSelect={(option) => {
+        void props.onSelect({
+          type: "existing",
+          workspaceID: option.value.workspace.id,
+          workspaceType: option.value.workspace.type,
+          workspaceName: option.value.workspace.name,
+        })
+      }}
+    />
+  )
+}
+
+export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> | void }) {
+  const dialog = useDialog()
+  const project = useProject()
+  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 []
   })
 
   const create = async (type: string) => {
@@ -186,14 +318,12 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
     setCreating(undefined)
   }
 
-  return (
-    <DialogSelect
-      title={creating() ? "Creating Workspace" : "New Workspace"}
-      skipFilter={true}
-      options={options()}
-      onSelect={(option) => {
-        if (option.value === "creating" || option.value === "loading") return
-        void create(option.value)
+  return creating() ? (
+    <DialogSelect title="Creating Workspace" skipFilter={true} renderFilter={false} options={options()} />
+  ) : (
+    <DialogWorkspaceTypeSelect
+      onSelect={(adaptor) => {
+        void create(adaptor.type)
       }}
     />
   )

+ 245 - 106
packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

@@ -7,6 +7,7 @@ import { Filesystem } from "@/util/filesystem"
 import { useLocal } from "@tui/context/local"
 import { tint, useTheme } from "@tui/context/theme"
 import { EmptyBorder, SplitBorder } from "@tui/component/border"
+import { Spinner } from "@tui/component/spinner"
 import { useSDK } from "@tui/context/sdk"
 import { useRoute } from "@tui/context/route"
 import { useProject } from "@tui/context/project"
@@ -40,13 +41,23 @@ import { useKV } from "../../context/kv"
 import { createFadeIn } from "../../util/signal"
 import { useTextareaKeybindings } from "../textarea-keybindings"
 import { DialogSkill } from "../dialog-skill"
-import { DialogWorkspaceCreate, restoreWorkspaceSession } from "../dialog-workspace-create"
+import {
+  DialogWorkspaceCreate,
+  DialogWorkspaceSelect,
+  restoreWorkspaceSession,
+  type WorkspaceSelection,
+} from "../dialog-workspace-create"
 import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
 import { useArgs } from "@tui/context/args"
+import { Flag } from "@opencode-ai/core/flag/flag"
+import { WorkspaceLabel, type WorkspaceStatus } from "../workspace-label"
 
 export type PromptProps = {
   sessionID?: string
   workspaceID?: string
+  workspaceSelection?: WorkspaceSelection
+  onWorkspaceSelectionChange?: (selection: WorkspaceSelection | undefined) => void
+  onWorkspaceCreatingChange?: (creating: boolean) => void
   visible?: boolean
   disabled?: boolean
   onSubmit?: () => void
@@ -149,8 +160,31 @@ export function Prompt(props: PromptProps) {
   })
   let lastSubmittedEditorSelectionKey: string | undefined
   const [auto, setAuto] = createSignal<AutocompleteRef>()
+  const [workspaceSelection, setWorkspaceSelection] = createSignal<WorkspaceSelection>()
+  const [workspaceCreating, setWorkspaceCreating] = createSignal(false)
+  const [workspaceCreatingDots, setWorkspaceCreatingDots] = createSignal(3)
   const currentProviderLabel = createMemo(() => local.model.parsed().provider)
   const hasRightContent = createMemo(() => Boolean(props.right))
+  const selectedWorkspace = () => props.workspaceSelection ?? workspaceSelection()
+
+  function selectWorkspace(selection: WorkspaceSelection | undefined) {
+    setWorkspaceSelection(selection)
+    props.onWorkspaceSelectionChange?.(selection)
+  }
+
+  function setCreatingWorkspace(creating: boolean) {
+    setWorkspaceCreating(creating)
+    props.onWorkspaceCreatingChange?.(creating)
+  }
+
+  createEffect(() => {
+    if (!workspaceCreating()) {
+      setWorkspaceCreatingDots(3)
+      return
+    }
+    const timer = setInterval(() => setWorkspaceCreatingDots((dots) => (dots % 3) + 1), 1000)
+    onCleanup(() => clearInterval(timer))
+  })
 
   function promptModelWarning() {
     toast.show({
@@ -184,8 +218,9 @@ export function Prompt(props: PromptProps) {
   })
 
   createEffect(() => {
-    if (props.disabled) input.cursorColor = theme.backgroundElement
-    if (!props.disabled) input.cursorColor = theme.text
+    if (!input || input.isDestroyed) return
+    if (props.disabled || workspaceCreating()) input.cursorColor = theme.backgroundElement
+    if (!props.disabled && !workspaceCreating()) input.cursorColor = theme.text
   })
 
   const lastUserMessage = createMemo(() => {
@@ -450,6 +485,27 @@ export function Prompt(props: PromptProps) {
           ))
         },
       },
+      {
+        title: "Warp",
+        description: "Change the workspace for the session",
+        value: "workspace.set",
+        category: "Session",
+        enabled: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
+        slash: {
+          name: "warp",
+        },
+        onSelect: (dialog) => {
+          dialog.replace(() => (
+            <DialogWorkspaceSelect
+              current={selectedWorkspace()}
+              onSelect={(selection) => {
+                selectWorkspace(selection)
+                dialog.clear()
+              }}
+            />
+          ))
+        },
+      },
     ]
   })
 
@@ -507,7 +563,7 @@ export function Prompt(props: PromptProps) {
 
   createEffect(() => {
     if (!input || input.isDestroyed) return
-    if (props.visible === false || dialog.stack.length > 0) {
+    if (props.visible === false || dialog.stack.length > 0 || workspaceCreating()) {
       if (input.focused) input.blur()
       return
     }
@@ -527,13 +583,13 @@ export function Prompt(props: PromptProps) {
         : undefined
     input.traits = {
       capture,
-      suspend: !!props.disabled || store.mode === "shell",
+      suspend: !!props.disabled || workspaceCreating() || store.mode === "shell",
       status: store.mode === "shell" ? "SHELL" : undefined,
     }
   })
 
   function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
-    input.extmarks.clear()
+    if (!input.isDestroyed) input.extmarks.clear()
     setStore("extmarkToPartIndex", new Map())
 
     parts.forEach((part, partIndex) => {
@@ -673,7 +729,7 @@ export function Prompt(props: PromptProps) {
       setStore("prompt", "input", input.plainText)
       syncExtmarksWithPromptParts()
     }
-    if (props.disabled) return false
+    if (props.disabled || workspaceCreating()) return false
     if (autocomplete?.visible) return false
     if (!store.prompt.input) return false
     const agent = local.agent.current()
@@ -717,24 +773,6 @@ export function Prompt(props: PromptProps) {
       return false
     }
 
-    let sessionID = props.sessionID
-    if (sessionID == null) {
-      const res = await sdk.client.session.create({ workspace: props.workspaceID })
-
-      if (res.error) {
-        console.log("Creating a session failed:", res.error)
-
-        toast.show({
-          message: "Creating a session failed. Open console for more details.",
-          variant: "error",
-        })
-
-        return true
-      }
-
-      sessionID = res.data.id
-    }
-
     const messageID = MessageID.ascending()
     let inputText = store.prompt.input
 
@@ -759,6 +797,7 @@ export function Prompt(props: PromptProps) {
 
     // Capture mode before it gets reset
     const currentMode = store.mode
+    const submittedPrompt = unwrap(store.prompt)
     const variant = local.model.variant.current()
     const editorSelection = fileContextEnabled() ? editor.selection() : undefined
     const editorSelectionKey = editorSelection ? getEditorSelectionKey(editorSelection) : undefined
@@ -794,6 +833,53 @@ export function Prompt(props: PromptProps) {
           ]
         : []
 
+    let sessionID = props.sessionID
+    if (sessionID == null) {
+      const workspace = selectedWorkspace()
+      if (workspace?.type === "new") {
+        setCreatingWorkspace(true)
+        await new Promise((resolve) => setTimeout(resolve, 10_000))
+      }
+      const workspaceID = await iife(async () => {
+        if (!workspace) return undefined
+        if (workspace.type === "none") return undefined
+        if (workspace.type === "existing") return workspace.workspaceID
+
+        const result = await sdk.client.experimental.workspace
+          .create({ type: workspace.workspaceType, branch: null })
+          .catch(() => undefined)
+        if (result == undefined || result.error || !result.data) return undefined
+
+        await project.workspace.sync()
+        return result.data.id
+      })
+
+      if (workspace?.type === "new" && !workspaceID) {
+        setCreatingWorkspace(false)
+        toast.show({
+          message: "Creating workspace failed",
+          variant: "error",
+        })
+        return true
+      }
+
+      const res = await sdk.client.session.create({ workspace: workspaceID })
+
+      if (res.error) {
+        setCreatingWorkspace(false)
+        console.log("Creating a session failed:", res.error)
+
+        toast.show({
+          message: "Creating a session failed. Open console for more details.",
+          variant: "error",
+        })
+
+        return true
+      }
+
+      sessionID = res.data.id
+    }
+
     if (store.mode === "shell") {
       void sdk.client.session.shell({
         sessionID,
@@ -858,7 +944,7 @@ export function Prompt(props: PromptProps) {
       lastSubmittedEditorSelectionKey = editorSelectionKey
     }
     history.append({
-      ...store.prompt,
+      ...submittedPrompt,
       mode: currentMode,
     })
     input.extmarks.clear()
@@ -877,7 +963,7 @@ export function Prompt(props: PromptProps) {
           sessionID,
         })
       }, 50)
-    input.clear()
+    if (!input.isDestroyed) input.clear()
     return true
   }
   const exit = useExit()
@@ -998,6 +1084,28 @@ export function Prompt(props: PromptProps) {
     return `Ask anything... "${list()[store.placeholder % list().length]}"`
   })
 
+  const workspaceLabel = createMemo<
+    | { type: "new"; workspaceType: string }
+    | { type: "existing"; workspaceType: string; workspaceName: string; status?: WorkspaceStatus }
+    | undefined
+  >(() => {
+    const selected = selectedWorkspace()
+    if (!selected) return
+    if (selected.type === "none") return
+    if (selected.type === "new") {
+      return {
+        type: "new",
+        workspaceType: selected.workspaceType,
+      }
+    }
+    return {
+      type: "existing",
+      workspaceType: selected.workspaceType,
+      workspaceName: selected.workspaceName,
+      status: selected.type === "existing" ? "connected" : undefined,
+    }
+  })
+
   const spinnerDef = createMemo(() => {
     const agent = local.agent.current()
     const color = agent ? local.agent.color(agent.name) : theme.border
@@ -1076,7 +1184,7 @@ export function Prompt(props: PromptProps) {
               }}
               keyBindings={textareaKeybindings()}
               onKeyDown={async (e) => {
-                if (props.disabled) {
+                if (props.disabled || workspaceCreating()) {
                   e.preventDefault()
                   return
                 }
@@ -1159,7 +1267,7 @@ export function Prompt(props: PromptProps) {
                 setTimeout(() => setTimeout(() => submit(), 0), 0)
               }}
               onPaste={async (event: PasteEvent) => {
-                if (props.disabled) {
+                if (props.disabled || workspaceCreating()) {
                   event.preventDefault()
                   return
                 }
@@ -1254,7 +1362,7 @@ export function Prompt(props: PromptProps) {
               }}
               onMouseDown={(r: MouseEvent) => r.target?.focus()}
               focusedBackgroundColor={theme.backgroundElement}
-              cursorColor={theme.text}
+              cursorColor={workspaceCreating() ? theme.backgroundElement : theme.text}
               syntaxStyle={syntax()}
             />
             <box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
@@ -1324,86 +1432,117 @@ export function Prompt(props: PromptProps) {
           />
         </box>
         <box width="100%" flexDirection="row" justifyContent="space-between">
-          <Show when={status().type !== "idle"} fallback={props.hint ?? <text />}>
-            <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} />
+          <Switch>
+            <Match when={workspaceLabel()}>
+              {(workspace) => (
+                <box paddingLeft={3} flexDirection="row" gap={1}>
+                  <Show when={workspaceCreating()}>
+                    <Spinner color={theme.accent} />
                   </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)
+                  <text fg={workspaceCreating() ? theme.accent : theme.text}>
+                    {(() => {
+                      const item = workspace()
+                      if (item.type === "new") {
+                        if (workspaceCreating()) return `Creating ${item.workspaceType}${".".repeat(workspaceCreatingDots())}`
+                        return (
+                          <>
+                            Workspace <span style={{ fg: theme.textMuted }}>(new {item.workspaceType})</span>
+                          </>
+                        )
                       }
-                    }
-
-                    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>
+                      return (
+                        <>
+                          Workspace <span style={{ fg: theme.textMuted }}>{item.workspaceName}</span>
+                        </>
+                      )
+                    })()}
+                  </text>
+                </box>
+              )}
+            </Match>
+            <Match when={true}>
+              <Show when={status().type !== "idle"} fallback={props.hint ?? <text />}>
+                <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>
+                  </box>
+                  <text fg={store.interrupt > 0 ? theme.primary : theme.text}>
+                    esc{" "}
+                    <span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
+                      {store.interrupt > 0 ? "again to interrupt" : "interrupt"}
+                    </span>
+                  </text>
                 </box>
-              </box>
-              <text fg={store.interrupt > 0 ? theme.primary : theme.text}>
-                esc{" "}
-                <span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
-                  {store.interrupt > 0 ? "again to interrupt" : "interrupt"}
-                </span>
-              </text>
-            </box>
-          </Show>
+              </Show>
+            </Match>
+          </Switch>
           <Show when={status().type !== "retry"}>
             <box gap={2} flexDirection="row">
               <Show when={editorFileLabelDisplay()}>{(file) => <text fg={theme.secondary}>{file()}</text>}</Show>

+ 19 - 0
packages/opencode/src/cli/cmd/tui/component/workspace-label.tsx

@@ -0,0 +1,19 @@
+import { useTheme } from "@tui/context/theme"
+
+export type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
+
+export function WorkspaceLabel(props: { type: string; name: string; status?: WorkspaceStatus; icon?: boolean }) {
+  const { theme } = useTheme()
+  const color = () => {
+    if (props.status === "connected") return theme.success
+    if (props.status === "error") return theme.error
+    return theme.textMuted
+  }
+
+  return (
+    <>
+      {props.icon ? <span style={{ fg: color() }}>● </span> : undefined}
+      <span style={{ fg: theme.text }}>{props.name}</span> <span style={{ fg: theme.textMuted }}>({props.type})</span>
+    </>
+  )
+}

+ 21 - 0
packages/opencode/src/cli/cmd/tui/routes/home.tsx

@@ -9,6 +9,7 @@ import { useRouteData } from "@tui/context/route"
 import { usePromptRef } from "../context/prompt"
 import { useLocal } from "../context/local"
 import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
+import type { WorkspaceSelection } from "../component/dialog-workspace-create"
 
 let once = false
 const placeholder = {
@@ -22,10 +23,28 @@ export function Home() {
   const route = useRouteData("home")
   const promptRef = usePromptRef()
   const [ref, setRef] = createSignal<PromptRef | undefined>()
+  const [workspaceSelection, setWorkspaceSelection] = createSignal<WorkspaceSelection>()
   const args = useArgs()
   const local = useLocal()
   let sent = false
 
+  const currentWorkspaceSelection = (): WorkspaceSelection | undefined => {
+    const workspaceID = project.workspace.current()
+    if (!workspaceID) return { type: "none" }
+    const workspace = project.workspace.get(workspaceID)
+    return {
+      type: "existing",
+      workspaceID,
+      workspaceType: workspace?.type ?? "unknown",
+      workspaceName: workspace?.name ?? workspaceID,
+    }
+  }
+
+  createEffect(() => {
+    if (workspaceSelection()) return
+    setWorkspaceSelection(currentWorkspaceSelection())
+  })
+
   const bind = (r: PromptRef | undefined) => {
     setRef(r)
     promptRef.set(r)
@@ -73,6 +92,8 @@ export function Home() {
             <Prompt
               ref={bind}
               workspaceID={project.workspace.current()}
+              workspaceSelection={workspaceSelection()}
+              onWorkspaceSelectionChange={setWorkspaceSelection}
               right={<TuiPluginRuntime.Slot name="home_prompt_right" workspace_id={project.workspace.current()} />}
               placeholders={placeholder}
             />

+ 17 - 12
packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx

@@ -7,6 +7,7 @@ import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/inst
 import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
 
 import { getScrollAcceleration } from "../../util/scroll"
+import { WorkspaceLabel } from "../../component/workspace-label"
 
 export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
   const project = useProject()
@@ -14,17 +15,10 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
   const { theme } = useTheme()
   const tuiConfig = useTuiConfig()
   const session = createMemo(() => sync.session.get(props.sessionID))
-  const workspaceStatus = () => {
+  const workspace = () => {
     const workspaceID = session()?.workspaceID
-    if (!workspaceID) return "error"
-    return project.workspace.status(workspaceID) ?? "error"
-  }
-  const workspaceLabel = () => {
-    const workspaceID = session()?.workspaceID
-    if (!workspaceID) return "unknown"
-    const info = project.workspace.get(workspaceID)
-    if (!info) return "unknown"
-    return `${info.type}: ${info.name}`
+    if (!workspaceID) return
+    return project.workspace.get(workspaceID)
   }
   const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
 
@@ -67,8 +61,19 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
                 </Show>
                 <Show when={session()!.workspaceID}>
                   <text fg={theme.textMuted}>
-                    <span style={{ fg: workspaceStatus() === "connected" ? theme.success : theme.error }}>●</span>{" "}
-                    {workspaceLabel()}
+                    <Show
+                      when={workspace()}
+                      fallback={<WorkspaceLabel type="unknown" name={session()!.workspaceID!} status="error" icon />}
+                    >
+                      {(item) => (
+                        <WorkspaceLabel
+                          type={item().type}
+                          name={item().name}
+                          status={project.workspace.status(item().id) ?? "error"}
+                          icon
+                        />
+                      )}
+                    </Show>
                   </text>
                 </Show>
                 <Show when={session()!.share?.url}>

+ 34 - 26
packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx

@@ -23,6 +23,7 @@ export interface DialogSelectProps<T> {
   onFilter?: (query: string) => void
   onSelect?: (option: DialogSelectOption<T>) => void
   skipFilter?: boolean
+  renderFilter?: boolean
   keybind?: {
     keybind?: Keybind.Info
     title: string
@@ -35,6 +36,7 @@ export interface DialogSelectProps<T> {
 
 export interface DialogSelectOption<T = any> {
   title: string
+  titleView?: JSX.Element
   value: T
   description?: string
   footer?: JSX.Element | string
@@ -81,7 +83,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
   let input: InputRenderable
 
   const filtered = createMemo(() => {
-    if (props.skipFilter) return props.options.filter((x) => x.disabled !== true)
+    if (props.skipFilter || props.renderFilter === false) return props.options.filter((x) => x.disabled !== true)
     const needle = store.filter.toLowerCase()
     const options = pipe(
       props.options,
@@ -250,30 +252,32 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
             esc
           </text>
         </box>
-        <box paddingTop={1}>
-          <input
-            onInput={(e) => {
-              batch(() => {
-                setStore("filter", e)
-                props.onFilter?.(e)
-              })
-            }}
-            focusedBackgroundColor={theme.backgroundPanel}
-            cursorColor={theme.primary}
-            focusedTextColor={theme.textMuted}
-            ref={(r) => {
-              input = r
-              input.traits = { status: "FILTER" }
-              setTimeout(() => {
-                if (!input) return
-                if (input.isDestroyed) return
-                input.focus()
-              }, 1)
-            }}
-            placeholder={props.placeholder ?? "Search"}
-            placeholderColor={theme.textMuted}
-          />
-        </box>
+        <Show when={props.renderFilter !== false}>
+          <box paddingTop={1}>
+            <input
+              onInput={(e) => {
+                batch(() => {
+                  setStore("filter", e)
+                  props.onFilter?.(e)
+                })
+              }}
+              focusedBackgroundColor={theme.backgroundPanel}
+              cursorColor={theme.primary}
+              focusedTextColor={theme.textMuted}
+              ref={(r) => {
+                input = r
+                input.traits = { status: "FILTER" }
+                setTimeout(() => {
+                  if (!input) return
+                  if (input.isDestroyed) return
+                  input.focus()
+                }, 1)
+              }}
+              placeholder={props.placeholder ?? "Search"}
+              placeholderColor={theme.textMuted}
+            />
+          </box>
+        </Show>
       </box>
       <Show
         when={grouped().length > 0}
@@ -347,6 +351,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
                         </Show>
                         <Option
                           title={option.title}
+                          titleView={option.titleView}
                           footer={flatten() ? (option.category ?? option.footer) : option.footer}
                           description={option.description !== category ? option.description : undefined}
                           active={active()}
@@ -403,6 +408,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
 
 function Option(props: {
   title: string
+  titleView?: JSX.Element
   description?: string
   active?: boolean
   current?: boolean
@@ -433,7 +439,9 @@ function Option(props: {
         wrapMode="none"
         paddingLeft={3}
       >
-        {Locale.truncate(props.title, 61)}
+        <Show when={props.titleView} fallback={Locale.truncate(props.title, 61)}>
+          {(titleView) => titleView()}
+        </Show>
         <Show when={props.description}>
           <span style={{ fg: props.active ? fg : theme.textMuted }}> {props.description}</span>
         </Show>

+ 1 - 0
packages/opencode/src/control-plane/workspace.ts

@@ -518,6 +518,7 @@ export const layer = Layer.effect(
         const adaptor = getAdaptor(space.projectID, space.type)
         const target = yield* Effect.promise(() => Promise.resolve(adaptor.target(space)))
 
+        // Update the local copy to point to the new workspace
         yield* Effect.sync(() =>
           SyncEvent.run(Session.Event.Updated, {
             sessionID: input.sessionID,