diff --git a/packages/opencode/specs/workspace-refactor-pr-description.md b/packages/opencode/specs/workspace-refactor-pr-description.md new file mode 100644 index 0000000000..c72a0f5ec3 --- /dev/null +++ b/packages/opencode/specs/workspace-refactor-pr-description.md @@ -0,0 +1,44 @@ +## Summary + +This refactor moves the TUI toward a single source of truth for project and workspace state, and aligns event handling around a global event stream instead of per-directory subscriptions. + +The main goal is to make workspace switching and session loading behave consistently across the TUI, while simplifying the data flow between the frontend, worker transport, and backend. + +## Why + +The previous shape was splitting related state across multiple places: + +- some workspace/path state lived in sync state +- some UI behavior depended on route state +- event consumers were effectively relying on instance-scoped subscriptions + +That made workspace-aware behavior harder to reason about and more fragile when switching contexts. + +This change centralizes the active project/workspace/path state, makes sync react to that state instead of owning it, and updates the event pipeline so the backend emits richer global events and the TUI filters them based on the current context. The intent is to make the system easier to evolve as workspace support expands. + +## What Changed + +- centralized active project/workspace/path state in the TUI +- made sync derive from that state and re-bootstrap when the active workspace changes +- switched TUI event consumption to a global event stream filtered client-side +- propagated workspace/project metadata through the backend event path and runtime context +- updated the SDK/OpenAPI contract to reflect the richer global event shape +- added targeted TUI tests around workspace-driven sync behavior and event filtering + +## Risk + +This touches several cross-cutting paths, so the main risks are around behavior rather than typing: + +- workspace changes may still expose subtle ordering/race issues if older async bootstrap work finishes after newer state is selected +- event filtering is now more centralized, which is good, but also means mistakes there can hide or misroute UI updates +- session state now depends more heavily on the active workspace context being correct at the right time +- backend/frontend assumptions about global event metadata need to stay aligned, or certain updates may quietly stop appearing in the TUI + +Overall, the biggest risk is regressions during workspace transitions rather than steady-state usage. + +## Validation + +- added focused tests for reactive sync behavior on workspace changes +- added focused tests for `useEvent()` filtering behavior +- ran `bun typecheck` +- ran targeted TUI tests for the new coverage diff --git a/packages/opencode/src/bus/global.ts b/packages/opencode/src/bus/global.ts index 43386dd6b2..e751b59faf 100644 --- a/packages/opencode/src/bus/global.ts +++ b/packages/opencode/src/bus/global.ts @@ -4,6 +4,8 @@ export const GlobalBus = new EventEmitter<{ event: [ { directory?: string + project?: string + workspace?: string payload: any }, ] diff --git a/packages/opencode/src/bus/index.ts b/packages/opencode/src/bus/index.ts index fe26a6672e..6db4eb04fc 100644 --- a/packages/opencode/src/bus/index.ts +++ b/packages/opencode/src/bus/index.ts @@ -1,9 +1,9 @@ import z from "zod" import { Effect, Exit, Layer, PubSub, Scope, ServiceMap, Stream } from "effect" import { Log } from "../util/log" -import { Instance } from "../project/instance" import { BusEvent } from "./bus-event" import { GlobalBus } from "./global" +import { WorkspaceContext } from "@/control-plane/workspace-context" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" @@ -91,8 +91,13 @@ export namespace Bus { yield* PubSub.publish(s.wildcard, payload) const dir = yield* InstanceState.directory + const context = yield* InstanceState.context + const workspace = yield* InstanceState.workspaceID + GlobalBus.emit("event", { directory: dir, + project: context.project.id, + workspace, payload, }) }) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 4161c025c1..23bfdd7173 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -14,7 +14,6 @@ import { batch, Show, on, - onCleanup, } from "solid-js" import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" import { Flag } from "@/flag/flag" @@ -23,6 +22,8 @@ import { DialogProvider, useDialog } from "@tui/ui/dialog" import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider" import { ErrorComponent } from "@tui/component/error-component" import { PluginRouteMissing } from "@tui/component/plugin-route-missing" +import { ProjectProvider } from "@tui/context/project" +import { useEvent } from "@tui/context/event" import { SDKProvider, useSDK } from "@tui/context/sdk" import { StartupLoading } from "@tui/component/startup-loading" import { SyncProvider, useSync } from "@tui/context/sync" @@ -35,7 +36,6 @@ 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 { DialogConsoleOrg } from "@tui/component/dialog-console-org" import { KeybindProvider, useKeybind } from "@tui/context/keybind" import { ThemeProvider, useTheme } from "@tui/context/theme" @@ -54,7 +54,6 @@ import { KVProvider, useKV } from "./context/kv" import { Provider } from "@/provider/provider" import { ArgsProvider, useArgs, type Args } from "./context/args" import open from "open" -import { writeHeapSnapshot } from "v8" import { PromptRefProvider, usePromptRef } from "./context/prompt" import { TuiConfigProvider, useTuiConfig } from "./context/tui-config" import { TuiConfig } from "@/config/tui" @@ -216,27 +215,29 @@ export function tui(input: { headers={input.headers} events={input.events} > - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + @@ -260,6 +261,7 @@ function App(props: { onSnapshot?: () => Promise }) { const kv = useKV() const command = useCommandDialog() const keybind = useKeybind() + const event = useEvent() const sdk = useSDK() const toast = useToast() const themeState = useTheme() @@ -283,6 +285,7 @@ function App(props: { onSnapshot?: () => Promise }) { route, routes, bump: () => setRouteRev((x) => x + 1), + event, sdk, sync, theme: themeState, @@ -461,22 +464,6 @@ function App(props: { onSnapshot?: () => Promise }) { dialog.replace(() => ) }, }, - ...(Flag.OPENCODE_EXPERIMENTAL_WORKSPACES - ? [ - { - title: "Manage workspaces", - value: "workspace.list", - category: "Workspace", - suggested: true, - slash: { - name: "workspaces", - }, - onSelect: () => { - dialog.replace(() => ) - }, - }, - ] - : []), { title: "New session", suggested: route.data.type === "session", @@ -491,12 +478,9 @@ function App(props: { onSnapshot?: () => Promise }) { const current = promptRef.current // Don't require focus - if there's any text, preserve it const currentPrompt = current?.current?.input ? current.current : undefined - const workspaceID = - route.data.type === "session" ? sync.session.get(route.data.sessionID)?.workspaceID : undefined route.navigate({ type: "home", initialPrompt: currentPrompt, - workspaceID, }) dialog.clear() }, @@ -806,11 +790,11 @@ function App(props: { onSnapshot?: () => Promise }) { }, ]) - sdk.event.on(TuiEvent.CommandExecute.type, (evt) => { + event.on(TuiEvent.CommandExecute.type, (evt) => { command.trigger(evt.properties.command) }) - sdk.event.on(TuiEvent.ToastShow.type, (evt) => { + event.on(TuiEvent.ToastShow.type, (evt) => { toast.show({ title: evt.properties.title, message: evt.properties.message, @@ -819,14 +803,14 @@ function App(props: { onSnapshot?: () => Promise }) { }) }) - sdk.event.on(TuiEvent.SessionSelect.type, (evt) => { + event.on(TuiEvent.SessionSelect.type, (evt) => { route.navigate({ type: "session", sessionID: evt.properties.sessionID, }) }) - sdk.event.on("session.deleted", (evt) => { + event.on("session.deleted", (evt) => { if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) { route.navigate({ type: "home" }) toast.show({ @@ -836,7 +820,7 @@ function App(props: { onSnapshot?: () => Promise }) { } }) - sdk.event.on("session.error", (evt) => { + event.on("session.error", (evt) => { const error = evt.properties.error if (error && typeof error === "object" && error.name === "MessageAbortedError") return const message = errorMessage(error) @@ -848,7 +832,7 @@ function App(props: { onSnapshot?: () => Promise }) { }) }) - sdk.event.on("installation.update-available", async (evt) => { + event.on("installation.update-available", async (evt) => { const version = evt.properties.version const skipped = kv.get("skipped_version") diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 775969bfcb..73d37f9e3d 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -1,17 +1,50 @@ -import { useDialog } from "@tui/ui/dialog" -import { DialogSelect } from "@tui/ui/dialog-select" +import { RGBA } from "@opentui/core" import { useRoute } from "@tui/context/route" import { useSync } from "@tui/context/sync" -import { createMemo, createSignal, createResource, onMount, Show } from "solid-js" +import { useDialog } from "@tui/ui/dialog" +import { DialogSelect } from "@tui/ui/dialog-select" +import { createMemo, createResource, createSignal, onMount } 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 { useTheme } from "../context/theme" import { createDebouncedSignal } from "../util/signal" +import { useToast } from "../ui/toast" +import { DialogSessionRename } from "./dialog-session-rename" +import { DialogWorkspaceCreate, openWorkspaceSession } from "./dialog-workspace-create" import { Spinner } from "./spinner" +const color = [ + RGBA.fromHex("#ff7a90"), + RGBA.fromHex("#f8c555"), + RGBA.fromHex("#70d6a3"), + RGBA.fromHex("#57c7ff"), + RGBA.fromHex("#bb9af7"), + RGBA.fromHex("#ff9e64"), +] + +const shape = ["■", "◆", "▲", "▶", "▼", "◀", "●", "◉", "◈", "◊"] +const action = "__workspace_new__" + +function hash(text: string) { + let sum = 0 + for (const char of text) { + sum = (sum * 31 + char.charCodeAt(0)) >>> 0 + } + return sum +} + +function mark(id?: string) { + if (!id) { + return + } + const sum = hash(id) + return { + fg: color[sum % color.length]!, + text: shape[sum % shape.length]!, + } +} + export function DialogSessionList() { const dialog = useDialog() const route = useRoute() @@ -19,44 +52,60 @@ export function DialogSessionList() { 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 [searchResults] = createResource(search, async (query) => { - if (!query) return undefined - const result = await sdk.client.session.list({ search: query, limit: 30 }) + const load = async (search?: string) => { + const result = await sdk.client.session.list({ + roots: true, + ...(search ? { search, limit: 30 } : {}), + }) return result.data ?? [] + } + + const [listed, listedActions] = createResource(async () => load()) + const [found] = createResource(search, async (query) => { + if (!query) return undefined + return load(query) }) - const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) - - const sessions = createMemo(() => searchResults() ?? sync.data.session) + const current = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) + const sessions = createMemo(() => found() ?? listed() ?? sync.data.session) const options = createMemo(() => { const today = new Date().toDateString() - return sessions() - .filter((x) => x.parentID === undefined) - .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, - } - }) + return [ + { + title: "+ New workspace session", + value: action, + category: "Actions", + description: "Create a new workspace, then open a session there", + }, + ...sessions() + .filter((item) => item.parentID === undefined) + .toSorted((a, b) => b.time.updated - a.time.updated) + .map((item) => { + const badge = mark(item.workspaceID) + const date = new Date(item.time.updated) + let category = date.toDateString() + if (category === today) { + category = "Today" + } + const deleting = toDelete() === item.id + const status = sync.data.session_status?.[item.id] + const working = status?.type === "busy" + return { + title: deleting ? `Press ${keybind.print("session_delete")} again to confirm` : item.title, + bg: deleting ? theme.error : undefined, + value: item.id, + category, + footer: Locale.time(item.time.updated), + gutter: working ? : undefined, + margin: badge ? {badge.text} : undefined, + } + }), + ] }) onMount(() => { @@ -68,12 +117,29 @@ export function DialogSessionList() { title="Sessions" options={options()} skipFilter={true} - current={currentSessionID()} + current={current()} onFilter={setSearch} onMove={() => { setToDelete(undefined) }} onSelect={(option) => { + if (option.value === action) { + dialog.replace(() => ( + + openWorkspaceSession({ + dialog, + route, + sdk, + sync, + toast, + workspaceID, + }) + } + /> + )) + return + } route.navigate({ type: "session", sessionID: option.value, @@ -85,10 +151,27 @@ export function DialogSessionList() { keybind: keybind.all.session_delete?.[0], title: "delete", onTrigger: async (option) => { + if (option.value === action) return if (toDelete() === option.value) { - sdk.client.session.delete({ - sessionID: option.value, - }) + const deleted = await sdk.client.session + .delete({ + sessionID: option.value, + }) + .then(() => true) + .catch(() => false) + if (!deleted) { + toast.show({ + message: "Failed to delete session", + variant: "error", + }) + setToDelete(undefined) + return + } + listedActions.mutate((items) => items?.filter((item) => item.id !== option.value)) + sync.set( + "session", + sync.data.session.filter((item) => item.id !== option.value), + ) setToDelete(undefined) return } @@ -99,6 +182,7 @@ export function DialogSessionList() { keybind: keybind.all.session_rename?.[0], title: "rename", onTrigger: async (option) => { + if (option.value === action) return dialog.replace(() => ) }, }, diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx new file mode 100644 index 0000000000..db3b51009e --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx @@ -0,0 +1,119 @@ +import { createOpencodeClient } 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 { useSync } from "@tui/context/sync" +import { createMemo, createSignal, onMount } from "solid-js" +import { setTimeout as sleep } from "node:timers/promises" +import { useSDK } from "../context/sdk" +import { useToast } from "../ui/toast" + +function scoped(sdk: ReturnType, sync: ReturnType, 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 + route: ReturnType + sdk: ReturnType + sync: ReturnType + toast: ReturnType + workspaceID: string +}) { + const client = scoped(input.sdk, input.sync, input.workspaceID) + while (true) { + const result = await client.session.create({ workspaceID: input.workspaceID }).catch(() => undefined) + if (!result) { + input.toast.show({ + message: "Failed to create workspace session", + variant: "error", + }) + return + } + if (result.response.status >= 500 && result.response.status < 600) { + await sleep(1000) + continue + } + if (!result.data) { + input.toast.show({ + message: "Failed to create workspace session", + variant: "error", + }) + return + } + input.route.navigate({ + type: "session", + sessionID: result.data.id, + }) + input.dialog.clear() + return + } +} + +export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise | void }) { + 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 create = async (type: string) => { + if (creating()) return + setCreating(type) + + const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch(() => undefined) + 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 create(option.value) + }} + /> + ) +} 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 deleted file mode 100644 index 84127b5763..0000000000 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx +++ /dev/null @@ -1,320 +0,0 @@ -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 { createOpencodeClient, 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 { setTimeout as sleep } from "node:timers/promises" - -function scoped(sdk: ReturnType, sync: ReturnType, workspaceID?: string) { - return createOpencodeClient({ - baseUrl: sdk.url, - fetch: sdk.fetch, - directory: sync.data.path.directory || sdk.directory, - experimental_workspaceID: workspaceID, - }) -} - -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 = scoped(input.sdk, input.sync, 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({ workspaceID: input.workspaceID }).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 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 = scoped(sdk, sync, 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 = scoped(sdk, sync, 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/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 1c5ede4d72..2118fe98e1 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -250,7 +250,7 @@ export function Autocomplete(props: { const width = props.anchor().width - 4 options.push( ...sortedFiles.map((item): AutocompleteOption => { - const baseDir = (sync.data.path.directory || process.cwd()).replace(/\/+$/, "") + const baseDir = (sync.path.directory || process.cwd()).replace(/\/+$/, "") const fullPath = `${baseDir}/${item}` const urlObj = pathToFileURL(fullPath) let filename = item diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 747c61fd0b..ba6df1d6bb 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -10,6 +10,7 @@ import { EmptyBorder, SplitBorder } from "@tui/component/border" import { useSDK } from "@tui/context/sdk" import { useRoute } from "@tui/context/route" import { useSync } from "@tui/context/sync" +import { useEvent } from "@tui/context/event" import { MessageID, PartID } from "@/session/schema" import { createStore, produce } from "solid-js/store" import { useKeybind } from "@tui/context/keybind" @@ -115,8 +116,9 @@ export function Prompt(props: PromptProps) { const agentStyleId = syntax().getStyleId("extmark.agent")! const pasteStyleId = syntax().getStyleId("extmark.paste")! let promptPartTypeId = 0 + const event = useEvent() - sdk.event.on(TuiEvent.PromptAppend.type, (evt) => { + event.on(TuiEvent.PromptAppend.type, (evt) => { if (!input || input.isDestroyed) return input.insertText(evt.properties.text) setTimeout(() => { 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 deleted file mode 100644 index 326f094a56..0000000000 --- a/packages/opencode/src/cli/cmd/tui/component/workspace/dialog-session-list.tsx +++ /dev/null @@ -1,151 +0,0 @@ -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/directory.ts b/packages/opencode/src/cli/cmd/tui/context/directory.ts index 17e5c180a1..81f2173980 100644 --- a/packages/opencode/src/cli/cmd/tui/context/directory.ts +++ b/packages/opencode/src/cli/cmd/tui/context/directory.ts @@ -1,11 +1,13 @@ import { createMemo } from "solid-js" +import { useProject } from "./project" import { useSync } from "./sync" import { Global } from "@/global" export function useDirectory() { + const project = useProject() const sync = useSync() return createMemo(() => { - const directory = sync.data.path.directory || process.cwd() + const directory = project.instance.path().directory || process.cwd() const result = directory.replace(Global.Path.home, "~") if (sync.data.vcs?.branch) return result + ":" + sync.data.vcs.branch return result diff --git a/packages/opencode/src/cli/cmd/tui/context/event.ts b/packages/opencode/src/cli/cmd/tui/context/event.ts new file mode 100644 index 0000000000..da073f6e92 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/context/event.ts @@ -0,0 +1,41 @@ +import type { Event } from "@opencode-ai/sdk/v2" +import { useProject } from "./project" +import { useSDK } from "./sdk" + +export function useEvent() { + const project = useProject() + const sdk = useSDK() + + function subscribe(handler: (event: Event) => void) { + return sdk.event.on("event", (event) => { + // Special hack for truly global events + if (event.directory === "global") { + handler(event.payload) + } + + if (project.workspace.current()) { + if (event.workspace === project.workspace.current()) { + handler(event.payload) + } + + return + } + + if (event.directory === project.instance.directory()) { + handler(event.payload) + } + }) + } + + function on(type: T, handler: (event: Extract) => void) { + return subscribe((event) => { + if (event.type !== type) return + handler(event as Extract) + }) + } + + return { + subscribe, + on, + } +} diff --git a/packages/opencode/src/cli/cmd/tui/context/project.tsx b/packages/opencode/src/cli/cmd/tui/context/project.tsx new file mode 100644 index 0000000000..522e724013 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/context/project.tsx @@ -0,0 +1,65 @@ +import { batch } from "solid-js" +import type { Path } from "@opencode-ai/sdk" +import { createStore, reconcile } from "solid-js/store" +import { createSimpleContext } from "./helper" +import { useSDK } from "./sdk" + +export const { use: useProject, provider: ProjectProvider } = createSimpleContext({ + name: "Project", + init: () => { + const sdk = useSDK() + const [store, setStore] = createStore({ + project: { + id: undefined as string | undefined, + }, + instance: { + path: { + state: "", + config: "", + worktree: "", + directory: sdk.directory ?? "", + } satisfies Path, + }, + workspace: undefined as string | undefined, + }) + + async function sync() { + const workspace = store.workspace + const [path, project] = await Promise.all([ + sdk.client.path.get({ workspace }), + sdk.client.project.current({ workspace }), + ]) + + batch(() => { + setStore("instance", "path", reconcile(path.data!)) + setStore("project", "id", project.data?.id) + }) + } + + return { + data: store, + project() { + return store.project.id + }, + instance: { + path() { + return store.instance.path + }, + directory() { + return store.instance.path.directory + }, + }, + workspace: { + current() { + return store.workspace + }, + set(next?: string | null) { + const workspace = next ?? undefined + if (store.workspace === workspace) return + setStore("workspace", workspace) + }, + }, + sync, + } + }, +}) diff --git a/packages/opencode/src/cli/cmd/tui/context/route.tsx b/packages/opencode/src/cli/cmd/tui/context/route.tsx index 939c2d5dc8..e9f463a13f 100644 --- a/packages/opencode/src/cli/cmd/tui/context/route.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/route.tsx @@ -5,7 +5,6 @@ import type { PromptInfo } from "../component/prompt/history" export type HomeRoute = { type: "home" initialPrompt?: PromptInfo - workspaceID?: string } export type SessionRoute = { diff --git a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx index 348c3ca1db..ad35aa45c2 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx @@ -1,10 +1,11 @@ -import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2" +import { createOpencodeClient } from "@opencode-ai/sdk/v2" +import type { GlobalEvent, Event } from "@opencode-ai/sdk/v2" import { createSimpleContext } from "./helper" import { createGlobalEmitter } from "@solid-primitives/event-bus" import { batch, onCleanup, onMount } from "solid-js" export type EventSource = { - subscribe: (directory: string | undefined, handler: (event: Event) => void) => Promise<() => void> + subscribe: (handler: (event: GlobalEvent) => void) => Promise<() => void> } export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ @@ -32,10 +33,10 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ let sdk = createSDK() const emitter = createGlobalEmitter<{ - [key in Event["type"]]: Extract + event: GlobalEvent }>() - let queue: Event[] = [] + let queue: GlobalEvent[] = [] let timer: Timer | undefined let last = 0 @@ -48,12 +49,12 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ // Batch all event emissions so all store updates result in a single render batch(() => { for (const event of events) { - emitter.emit(event.type, event) + emitter.emit("event", event) } }) } - const handleEvent = (event: Event) => { + const handleEvent = (event: GlobalEvent) => { queue.push(event) const elapsed = Date.now() - last @@ -74,7 +75,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ ;(async () => { while (true) { if (abort.signal.aborted || ctrl.signal.aborted) break - const events = await sdk.event.subscribe({}, { signal: ctrl.signal }) + const events = await sdk.global.event({ signal: ctrl.signal }) for await (const event of events.stream) { if (ctrl.signal.aborted) break @@ -89,7 +90,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ onMount(async () => { if (props.events) { - const unsub = await props.events.subscribe(props.directory, handleEvent) + const unsub = await props.events.subscribe(handleEvent) onCleanup(unsub) } else { startSSE() diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 11336d5002..bbdc743285 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -17,18 +17,19 @@ import type { ProviderListResponse, ProviderAuthMethod, VcsInfo, + Workspace, } from "@opencode-ai/sdk/v2" import { createStore, produce, reconcile } from "solid-js/store" +import { useProject } from "@tui/context/project" +import { useEvent } from "@tui/context/event" import { useSDK } from "@tui/context/sdk" import { Binary } from "@opencode-ai/util/binary" import { createSimpleContext } from "./helper" import type { Snapshot } from "@/snapshot" import { useExit } from "./exit" import { useArgs } from "./args" -import { batch, onMount } from "solid-js" +import { batch, createEffect, on } from "solid-js" import { Log } from "@/util/log" -import type { Path } from "@opencode-ai/sdk" -import type { Workspace } from "@opencode-ai/sdk/v2" import { ConsoleState, emptyConsoleState, type ConsoleState as ConsoleStateType } from "@/config/console-state" export const { use: useSync, provider: SyncProvider } = createSimpleContext({ @@ -74,9 +75,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ [key: string]: McpResource } formatter: FormatterStatus[] - vcs: VcsInfo | undefined - path: Path workspaceList: Workspace[] + vcs: VcsInfo | undefined }>({ provider_next: { all: [], @@ -103,21 +103,25 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ mcp: {}, mcp_resource: {}, formatter: [], - vcs: undefined, - path: { state: "", config: "", worktree: "", directory: "" }, workspaceList: [], + vcs: undefined, }) + const event = useEvent() + const project = useProject() const sdk = useSDK() async function syncWorkspaces() { + const workspace = project.workspace.current() const result = await sdk.client.experimental.workspace.list().catch(() => undefined) if (!result?.data) return setStore("workspaceList", reconcile(result.data)) + if (!result.data.some((item) => item.id === workspace)) { + project.workspace.set(undefined) + } } - sdk.event.listen((e) => { - const event = e.details + event.subscribe((event) => { switch (event.type) { case "server.instance.disposed": bootstrap() @@ -344,7 +348,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } case "lsp.updated": { - sdk.client.lsp.status().then((x) => setStore("lsp", x.data!)) + const workspace = project.workspace.current() + sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", x.data!)) break } @@ -360,25 +365,28 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ async function bootstrap() { console.log("bootstrapping") + const workspace = project.workspace.current() const start = Date.now() - 30 * 24 * 60 * 60 * 1000 const sessionListPromise = sdk.client.session - .list({ start: start }) + .list({ start: start, workspace }) .then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id))) // blocking - include session.list when continuing a session - const providersPromise = sdk.client.config.providers({}, { throwOnError: true }) - const providerListPromise = sdk.client.provider.list({}, { throwOnError: true }) + const providersPromise = sdk.client.config.providers({ workspace }, { throwOnError: true }) + const providerListPromise = sdk.client.provider.list({ workspace }, { throwOnError: true }) const consoleStatePromise = sdk.client.experimental.console - .get({}, { throwOnError: true }) + .get({ workspace }, { throwOnError: true }) .then((x) => ConsoleState.parse(x.data)) .catch(() => emptyConsoleState) - const agentsPromise = sdk.client.app.agents({}, { throwOnError: true }) - const configPromise = sdk.client.config.get({}, { throwOnError: true }) + const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true }) + const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true }) + const projectPromise = project.sync() const blockingRequests: Promise[] = [ providersPromise, providerListPromise, agentsPromise, configPromise, + projectPromise, ...(args.continue ? [sessionListPromise] : []), ] @@ -423,17 +431,18 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ Promise.all([ ...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]), consoleStatePromise.then((consoleState) => setStore("console_state", reconcile(consoleState))), - sdk.client.command.list().then((x) => setStore("command", reconcile(x.data ?? []))), - sdk.client.lsp.status().then((x) => setStore("lsp", reconcile(x.data!))), - sdk.client.mcp.status().then((x) => setStore("mcp", reconcile(x.data!))), - sdk.client.experimental.resource.list().then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))), - sdk.client.formatter.status().then((x) => setStore("formatter", reconcile(x.data!))), - sdk.client.session.status().then((x) => { + sdk.client.command.list({ workspace }).then((x) => setStore("command", reconcile(x.data ?? []))), + sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", reconcile(x.data!))), + sdk.client.mcp.status({ workspace }).then((x) => setStore("mcp", reconcile(x.data!))), + sdk.client.experimental.resource + .list({ workspace }) + .then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))), + sdk.client.formatter.status({ workspace }).then((x) => setStore("formatter", reconcile(x.data!))), + sdk.client.session.status({ workspace }).then((x) => { setStore("session_status", reconcile(x.data!)) }), - sdk.client.provider.auth().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!))), + sdk.client.provider.auth({ workspace }).then((x) => setStore("provider_auth", reconcile(x.data ?? {}))), + sdk.client.vcs.get({ workspace }).then((x) => setStore("vcs", reconcile(x.data))), syncWorkspaces(), ]).then(() => { setStore("status", "complete") @@ -449,11 +458,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }) } - onMount(() => { - bootstrap() - }) - const fullSyncedSessions = new Set() + createEffect( + on( + () => project.workspace.current(), + () => { + fullSyncedSessions.clear() + void bootstrap() + }, + ), + ) + const result = { data: store, set: setStore, @@ -463,6 +478,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ get ready() { return store.status !== "loading" }, + get path() { + return project.instance.path() + }, session: { get(sessionID: string) { const match = Binary.search(store.session, sessionID, (s) => s.id) @@ -481,11 +499,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }, async sync(sessionID: string) { if (fullSyncedSessions.has(sessionID)) return + const workspace = project.workspace.current() const [session, messages, todo, diff] = await Promise.all([ - sdk.client.session.get({ sessionID }, { throwOnError: true }), - sdk.client.session.messages({ sessionID, limit: 100 }), - sdk.client.session.todo({ sessionID }), - sdk.client.session.diff({ sessionID }), + sdk.client.session.get({ sessionID, workspace }, { throwOnError: true }), + sdk.client.session.messages({ sessionID, limit: 100, workspace }), + sdk.client.session.todo({ sessionID, workspace }), + sdk.client.session.diff({ sessionID, workspace }), ]) setStore( produce((draft) => { @@ -504,8 +523,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }, }, workspace: { + list() { + return store.workspaceList + }, get(workspaceID: string) { - return store.workspaceList.find((workspace) => workspace.id === workspaceID) + return store.workspaceList.find((item) => item.id === workspaceID) }, sync: syncWorkspaces, }, diff --git a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx index 3609f6cc1a..e43a9cc37c 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx +++ b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx @@ -1,6 +1,7 @@ import type { ParsedKey } from "@opentui/core" import type { TuiDialogSelectOption, TuiPluginApi, TuiRouteDefinition, TuiSlotProps } from "@opencode-ai/plugin/tui" import type { useCommandDialog } from "@tui/component/dialog-command" +import type { useEvent } from "@tui/context/event" import type { useKeybind } from "@tui/context/keybind" import type { useRoute } from "@tui/context/route" import type { useSDK } from "@tui/context/sdk" @@ -36,6 +37,7 @@ type Input = { route: ReturnType routes: RouteMap bump: () => void + event: ReturnType sdk: ReturnType sync: ReturnType theme: ReturnType @@ -136,7 +138,7 @@ function stateApi(sync: ReturnType): TuiPluginApi["state"] { return sync.data.provider }, get path() { - return sync.data.path + return sync.path }, get vcs() { if (!sync.data.vcs) return @@ -342,7 +344,7 @@ export function createTuiApi(input: Input): TuiPluginApi { get client() { return input.sdk.client }, - event: input.sdk.event, + event: input.event, renderer: input.renderer, slots: { register() { diff --git a/packages/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx index 79b5c4d7ab..1cce7fb396 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/home.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx @@ -1,6 +1,7 @@ import { Prompt, type PromptRef } from "@tui/component/prompt" import { createEffect, createSignal } from "solid-js" import { Logo } from "../component/logo" +import { useProject } from "../context/project" import { useSync } from "../context/sync" import { Toast } from "../ui/toast" import { useArgs } from "../context/args" @@ -18,6 +19,7 @@ const placeholder = { export function Home() { const sync = useSync() + const project = useProject() const route = useRouteData("home") const promptRef = usePromptRef() const [ref, setRef] = createSignal() @@ -63,11 +65,16 @@ export function Home() { - + } + workspaceID={project.workspace.current()} + right={} placeholders={placeholder} /> 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 32a9d13367..c6bc231fca 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -15,7 +15,9 @@ import { import { Dynamic } from "solid-js/web" import path from "path" import { useRoute, useRouteData } from "@tui/context/route" +import { useProject } from "@tui/context/project" import { useSync } from "@tui/context/sync" +import { useEvent } from "@tui/context/event" import { SplitBorder } from "@tui/component/border" import { Spinner } from "@tui/component/spinner" import { selectedForeground, useTheme } from "@tui/context/theme" @@ -116,6 +118,8 @@ export function Session() { const route = useRouteData("session") const { navigate } = useRoute() const sync = useSync() + const event = useEvent() + const project = useProject() const tuiConfig = useTuiConfig() const kv = useKV() const { theme } = useTheme() @@ -172,10 +176,16 @@ export function Session() { const providers = createMemo(() => Model.index(sync.data.provider)) const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig)) + const toast = useToast() + const sdk = useSDK() createEffect(async () => { - await sync.session - .sync(route.sessionID) + await sdk.client.session + .get({ sessionID: route.sessionID }, { throwOnError: true }) + .then((x) => { + project.workspace.set(x.data?.workspaceID) + }) + .then(() => sync.session.sync(route.sessionID)) .then(() => { if (scroll) scroll.scrollBy(100_000) }) @@ -189,13 +199,10 @@ export function Session() { }) }) - const toast = useToast() - const sdk = useSDK() - // Handle initial prompt from fork let seeded = false let lastSwitch: string | undefined = undefined - sdk.event.on("message.part.updated", (evt) => { + event.on("message.part.updated", (evt) => { const part = evt.properties.part if (part.type !== "tool") return if (part.sessionID !== route.sessionID) return @@ -224,7 +231,7 @@ export function Session() { const dialog = useDialog() const renderer = useRenderer() - sdk.event.on("session.status", (evt) => { + event.on("session.status", (evt) => { if (evt.properties.sessionID !== route.sessionID) return if (evt.properties.status.type !== "retry") return if (evt.properties.status.message !== SessionRetry.GO_UPSELL_MESSAGE) return @@ -1791,7 +1798,7 @@ function Bash(props: ToolProps) { const workdir = props.input.workdir if (!workdir || workdir === ".") return undefined - const base = sync.data.path.directory + const base = sync.path.directory if (!base) return undefined const absolute = path.resolve(base, workdir) diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index df5c416777..0534b147a5 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -10,7 +10,7 @@ import { errorMessage } from "@/util/error" import { withTimeout } from "@/util/timeout" import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network" import { Filesystem } from "@/util/filesystem" -import type { Event } from "@opencode-ai/sdk/v2" +import type { GlobalEvent } from "@opencode-ai/sdk/v2" import type { EventSource } from "./context/sdk" import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" import { TuiConfig } from "@/config/tui" @@ -43,18 +43,10 @@ function createWorkerFetch(client: RpcClient): typeof fetch { function createEventSource(client: RpcClient): EventSource { return { - subscribe: async (directory, handler) => { - const id = await client.call("subscribe", { directory }) - const unsub = client.on<{ id: string; event: Event }>("event", (e) => { - if (e.id === id) { - handler(e.event) - } + subscribe: async (handler) => { + return client.on("global.event", (e) => { + handler(e) }) - - return () => { - unsub() - client.call("unsubscribe", { id }) - } }, } } diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index 46821cccec..c4e855933e 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -42,6 +42,7 @@ export interface DialogSelectOption { disabled?: boolean bg?: RGBA gutter?: JSX.Element + margin?: JSX.Element onSelect?: (ctx: DialogContext) => void } @@ -312,6 +313,7 @@ export function DialogSelect(props: DialogSelectProps) { { setStore("input", "mouse") }} @@ -335,6 +337,11 @@ export function DialogSelect(props: DialogSelectProps) { paddingRight={3} gap={1} > + + + {option.margin} + +