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..2f8d1f7bbb 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" @@ -54,7 +55,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 +216,29 @@ export function tui(input: { headers={input.headers} events={input.events} > - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + @@ -260,6 +262,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 +286,7 @@ function App(props: { onSnapshot?: () => Promise }) { route, routes, bump: () => setRouteRev((x) => x + 1), + event, sdk, sync, theme: themeState, @@ -491,12 +495,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 +807,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 +820,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 +837,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 +849,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-workspace-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx index 84127b5763..037cebb729 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx @@ -1,5 +1,6 @@ import { useDialog } from "@tui/ui/dialog" import { DialogSelect } from "@tui/ui/dialog-select" +import { useProject } from "@tui/context/project" import { useRoute } from "@tui/context/route" import { useSync } from "@tui/context/sync" import { createEffect, createMemo, createSignal, onMount } from "solid-js" @@ -14,7 +15,7 @@ function scoped(sdk: ReturnType, sync: ReturnType return createOpencodeClient({ baseUrl: sdk.url, fetch: sdk.fetch, - directory: sync.data.path.directory || sdk.directory, + directory: sync.path.directory || sdk.directory, experimental_workspaceID: workspaceID, }) } @@ -149,6 +150,7 @@ function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promi export function DialogWorkspaceList() { const dialog = useDialog() + const project = useProject() const route = useRoute() const sync = useSync() const sdk = useSDK() @@ -168,8 +170,9 @@ export function DialogWorkspaceList() { forceCreate, }) - async function selectWorkspace(workspaceID: string) { - if (workspaceID === "__local__") { + async function selectWorkspace(workspaceID: string | null) { + if (workspaceID == null) { + project.workspace.set(undefined) if (localCount() > 0) { dialog.replace(() => ) return @@ -199,12 +202,7 @@ export function DialogWorkspaceList() { await open(workspaceID) } - const currentWorkspaceID = createMemo(() => { - if (route.data.type === "session") { - return sync.session.get(route.data.sessionID)?.workspaceID ?? "__local__" - } - return "__local__" - }) + const currentWorkspaceID = createMemo(() => project.workspace.current()) const localCount = createMemo( () => sync.data.session.filter((session) => !session.workspaceID && !session.parentID).length, @@ -234,7 +232,7 @@ export function DialogWorkspaceList() { const options = createMemo(() => [ { title: "Local", - value: "__local__", + value: null, category: "Workspace", description: "Use the local machine", footer: `${localCount()} session${localCount() === 1 ? "" : "s"}`, @@ -292,7 +290,7 @@ export function DialogWorkspaceList() { keybind: keybind.all.session_delete?.[0], title: "delete", onTrigger: async (option) => { - if (option.value === "__create__" || option.value === "__local__") return + if (option.value === "__create__" || option.value === null) return if (toDelete() !== option.value) { setToDelete(option.value) return @@ -307,6 +305,7 @@ export function DialogWorkspaceList() { return } if (currentWorkspaceID() === option.value) { + project.workspace.set(undefined) route.navigate({ type: "home", }) 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/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/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index 15e02b8634..a71b95ce4c 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -6,13 +6,10 @@ import { InstanceBootstrap } from "@/project/bootstrap" import { Rpc } from "@/util/rpc" import { upgrade } from "@/cli/upgrade" import { Config } from "@/config/config" -import { Bus } from "@/bus" import { GlobalBus } from "@/bus/global" -import type { Event } from "@opencode-ai/sdk/v2" +import type { GlobalEvent } from "@opencode-ai/sdk/v2" import { Flag } from "@/flag/flag" -import { setTimeout as sleep } from "node:timers/promises" import { writeHeapSnapshot } from "node:v8" -import { WorkspaceID } from "@/control-plane/schema" import { Heap } from "@/cli/heap" await Log.init({ @@ -45,87 +42,6 @@ GlobalBus.on("event", (event) => { let server: Awaited> | undefined -const eventStreams = new Map() - -function startEventStream(directory: string) { - const id = crypto.randomUUID() - - const abort = new AbortController() - const signal = abort.signal - - eventStreams.set(id, abort) - - async function run() { - while (!signal.aborted) { - const shouldReconnect = await Instance.provide({ - directory, - init: InstanceBootstrap, - fn: () => - new Promise((resolve) => { - Rpc.emit("event", { - type: "server.connected", - properties: {}, - } satisfies Event) - - let settled = false - const settle = (value: boolean) => { - if (settled) return - settled = true - signal.removeEventListener("abort", onAbort) - unsub() - resolve(value) - } - - const unsub = Bus.subscribeAll((event) => { - Rpc.emit("event", { - id, - event: event as Event, - }) - if (event.type === Bus.InstanceDisposed.type) { - settle(true) - } - }) - - const onAbort = () => { - settle(false) - } - - signal.addEventListener("abort", onAbort, { once: true }) - }), - }).catch((error) => { - Log.Default.error("event stream subscribe error", { - error: error instanceof Error ? error.message : error, - }) - return false - }) - - if (!shouldReconnect || signal.aborted) { - break - } - - if (!signal.aborted) { - await sleep(250) - } - } - } - - run().catch((error) => { - Log.Default.error("event stream error", { - error: error instanceof Error ? error.message : error, - }) - }) - - return id -} - -function stopEventStream(id: string) { - const abortController = eventStreams.get(id) - if (!abortController) return - - abortController.abort() - eventStreams.delete(id) -} - export const rpc = { async fetch(input: { url: string; method: string; headers: Record; body?: string }) { const headers = { ...input.headers } @@ -167,19 +83,9 @@ export const rpc = { async reload() { await Config.invalidate(true) }, - async subscribe(input: { directory: string | undefined }) { - return startEventStream(input.directory || process.cwd()) - }, - async unsubscribe(input: { id: string }) { - stopEventStream(input.id) - }, async shutdown() { Log.Default.info("worker shutting down") - for (const id of [...eventStreams.keys()]) { - stopEventStream(id) - } - await Instance.disposeAll() if (server) await server.stop(true) }, diff --git a/packages/opencode/src/control-plane/workspace-context.ts b/packages/opencode/src/control-plane/workspace-context.ts new file mode 100644 index 0000000000..37204d17d6 --- /dev/null +++ b/packages/opencode/src/control-plane/workspace-context.ts @@ -0,0 +1,22 @@ +import { Context } from "../util/context" +import type { WorkspaceID } from "../control-plane/schema" + +export interface WorkspaceContext { + workspaceID: string +} + +const context = Context.create("instance") + +export const WorkspaceContext = { + async provide(input: { workspaceID: WorkspaceID; fn: () => R }): Promise { + return context.provide({ workspaceID: input.workspaceID as string }, () => input.fn()) + }, + + get workspaceID() { + try { + return context.use().workspaceID + } catch (err) { + return undefined + } + }, +} diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index bb0fd60020..a030d0b6c8 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -134,12 +134,12 @@ export namespace Workspace { continue } - await parseSSE(res.body, stop, (event) => { - GlobalBus.emit("event", { - directory: space.id, - payload: event, - }) - }) + // await parseSSE(res.body, stop, (event) => { + // GlobalBus.emit("event", { + // directory: space.id, + // payload: event, + // }) + // }) // Wait 250ms and retry if SSE connection fails await sleep(250) diff --git a/packages/opencode/src/effect/instance-ref.ts b/packages/opencode/src/effect/instance-ref.ts index d3939b2640..07a510a4f8 100644 --- a/packages/opencode/src/effect/instance-ref.ts +++ b/packages/opencode/src/effect/instance-ref.ts @@ -4,3 +4,7 @@ import type { InstanceContext } from "@/project/instance" export const InstanceRef = ServiceMap.Reference("~opencode/InstanceRef", { defaultValue: () => undefined, }) + +export const WorkspaceRef = ServiceMap.Reference("~opencode/WorkspaceRef", { + defaultValue: () => undefined, +}) diff --git a/packages/opencode/src/effect/instance-state.ts b/packages/opencode/src/effect/instance-state.ts index a379d3afc0..878648855e 100644 --- a/packages/opencode/src/effect/instance-state.ts +++ b/packages/opencode/src/effect/instance-state.ts @@ -1,8 +1,9 @@ import { Effect, Fiber, ScopedCache, Scope, ServiceMap } from "effect" import { Instance, type InstanceContext } from "@/project/instance" import { Context } from "@/util/context" -import { InstanceRef } from "./instance-ref" +import { InstanceRef, WorkspaceRef } from "./instance-ref" import { registerDisposer } from "./instance-registry" +import { WorkspaceContext } from "@/control-plane/workspace-context" const TypeId = "~opencode/InstanceState" @@ -28,6 +29,10 @@ export namespace InstanceState { return (yield* InstanceRef) ?? Instance.current }) + export const workspaceID = Effect.gen(function* () { + return (yield* WorkspaceRef) ?? WorkspaceContext.workspaceID + }) + export const directory = Effect.map(context, (ctx) => ctx.directory) export const make = ( diff --git a/packages/opencode/src/effect/run-service.ts b/packages/opencode/src/effect/run-service.ts index f609986b58..c16adec627 100644 --- a/packages/opencode/src/effect/run-service.ts +++ b/packages/opencode/src/effect/run-service.ts @@ -2,15 +2,17 @@ import { Effect, Layer, ManagedRuntime } from "effect" import * as ServiceMap from "effect/ServiceMap" import { Instance } from "@/project/instance" import { Context } from "@/util/context" -import { InstanceRef } from "./instance-ref" +import { InstanceRef, WorkspaceRef } from "./instance-ref" import { Observability } from "./oltp" +import { WorkspaceContext } from "@/control-plane/workspace-context" export const memoMap = Layer.makeMemoMapUnsafe() function attach(effect: Effect.Effect): Effect.Effect { try { const ctx = Instance.current - return Effect.provideService(effect, InstanceRef, ctx) + const workspaceID = WorkspaceContext.workspaceID + return effect.pipe(Effect.provideService(InstanceRef, ctx), Effect.provideService(WorkspaceRef, workspaceID)) } catch (err) { if (!(err instanceof Context.NotFound)) throw err } diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index a0d6f2414a..33078183b9 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -5,6 +5,7 @@ import { iife } from "@/util/iife" import { Log } from "@/util/log" import { Context } from "../util/context" import { Project } from "./project" +import { WorkspaceContext } from "@/control-plane/workspace-context" import { State } from "./state" export interface InstanceContext { @@ -20,19 +21,9 @@ const disposal = { all: undefined as Promise | undefined, } -function emit(directory: string) { - GlobalBus.emit("event", { - directory, - payload: { - type: "server.instance.disposed", - properties: { - directory, - }, - }, - }) -} +function emitDisposed(directory: string) {} -function boot(input: { directory: string; init?: () => Promise; project?: Project.Info; worktree?: string }) { +function boot(input: { directory: string; init?: () => Promise; worktree?: string; project?: Project.Info }) { return iife(async () => { const ctx = input.project && input.worktree @@ -93,6 +84,7 @@ export const Instance = { get project() { return context.use().project }, + /** * Check if a path is within the project boundary. * Returns true if path is inside Instance.directory OR Instance.worktree. @@ -131,15 +123,39 @@ export const Instance = { await Promise.all([State.dispose(directory), disposeInstance(directory)]) cache.delete(directory) const next = track(directory, boot({ ...input, directory })) - emit(directory) + + GlobalBus.emit("event", { + directory, + project: input.project?.id, + workspace: WorkspaceContext.workspaceID, + payload: { + type: "server.instance.disposed", + properties: { + directory, + }, + }, + }) + return await next }, async dispose() { const directory = Instance.directory + const project = Instance.project Log.Default.info("disposing instance", { directory }) await Promise.all([State.dispose(directory), disposeInstance(directory)]) cache.delete(directory) - emit(directory) + + GlobalBus.emit("event", { + directory, + project: project.id, + workspace: WorkspaceContext.workspaceID, + payload: { + type: "server.instance.disposed", + properties: { + directory, + }, + }, + }) }, async disposeAll() { if (disposal.all) return disposal.all diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index f587f50b39..8db8df5d55 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -137,6 +137,8 @@ export namespace Project { const emitUpdated = (data: Info) => Effect.sync(() => GlobalBus.emit("event", { + directory: "global", + project: data.id, payload: { type: Event.Updated.type, properties: data }, }), ) diff --git a/packages/opencode/src/server/router.ts b/packages/opencode/src/server/router.ts index 55853d974d..dcc7924c68 100644 --- a/packages/opencode/src/server/router.ts +++ b/packages/opencode/src/server/router.ts @@ -9,6 +9,9 @@ import { Filesystem } from "@/util/filesystem" import { Instance } from "@/project/instance" import { InstanceBootstrap } from "@/project/bootstrap" import { InstanceRoutes } from "./instance" +import { Session } from "@/session" +import { SessionID } from "@/session/schema" +import { WorkspaceContext } from "@/control-plane/workspace-context" type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" } @@ -26,6 +29,16 @@ function local(method: string, path: string) { return false } +async function getSessionWorkspace(url: URL) { + if (url.pathname === "/session/status") return null + + const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1] + if (!id) return null + + const session = await Session.get(SessionID.make(id)).catch(() => undefined) + return session?.workspaceID +} + export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): MiddlewareHandler { const routes = lazy(() => InstanceRoutes(upgrade)) @@ -42,13 +55,12 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware ) const url = new URL(c.req.url) - const workspaceParam = url.searchParams.get("workspace") || c.req.header("x-opencode-workspace") - // TODO: If session is being routed, force it to lookup the - // project/workspace + const sessionWorkspaceID = await getSessionWorkspace(url) + const workspaceID = sessionWorkspaceID || url.searchParams.get("workspace") - // If no workspace is provided we use the "project" workspace - if (!workspaceParam) { + // If no workspace is provided we use the project + if (!workspaceID) { return Instance.provide({ directory, init: InstanceBootstrap, @@ -58,8 +70,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware }) } - const workspaceID = WorkspaceID.make(workspaceParam) - const workspace = await Workspace.get(workspaceID) + const workspace = await Workspace.get(WorkspaceID.make(workspaceID)) if (!workspace) { return new Response(`Workspace not found: ${workspaceID}`, { status: 500, @@ -73,12 +84,16 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware const target = await adaptor.target(workspace) if (target.type === "local") { - return Instance.provide({ - directory: target.directory, - init: InstanceBootstrap, - async fn() { - return routes().fetch(c.req.raw, c.env) - }, + return WorkspaceContext.provide({ + workspaceID: WorkspaceID.make(workspaceID), + fn: () => + Instance.provide({ + directory: target.directory, + init: InstanceBootstrap, + async fn() { + return routes().fetch(c.req.raw, c.env) + }, + }), }) } diff --git a/packages/opencode/src/server/routes/global.ts b/packages/opencode/src/server/routes/global.ts index 16b9e559f2..ec7afcf23d 100644 --- a/packages/opencode/src/server/routes/global.ts +++ b/packages/opencode/src/server/routes/global.ts @@ -105,6 +105,8 @@ export const GlobalRoutes = lazy(() => z .object({ directory: z.string(), + project: z.string().optional(), + workspace: z.string().optional(), payload: BusEvent.payloads(), }) .meta({ diff --git a/packages/opencode/src/tool/lsp.ts b/packages/opencode/src/tool/lsp.ts index 52aef0f9e3..ac0b4c6fcc 100644 --- a/packages/opencode/src/tool/lsp.ts +++ b/packages/opencode/src/tool/lsp.ts @@ -1,12 +1,13 @@ import z from "zod" +import { Effect } from "effect" import { Tool } from "./tool" import path from "path" import { LSP } from "../lsp" import DESCRIPTION from "./lsp.txt" import { Instance } from "../project/instance" import { pathToFileURL } from "url" -import { assertExternalDirectory } from "./external-directory" -import { Filesystem } from "../util/filesystem" +import { assertExternalDirectoryEffect } from "./external-directory" +import { AppFileSystem } from "../filesystem" const operations = [ "goToDefinition", @@ -20,78 +21,71 @@ const operations = [ "outgoingCalls", ] as const -export const LspTool = Tool.define("lsp", { - description: DESCRIPTION, - parameters: z.object({ - operation: z.enum(operations).describe("The LSP operation to perform"), - filePath: z.string().describe("The absolute or relative path to the file"), - line: z.number().int().min(1).describe("The line number (1-based, as shown in editors)"), - character: z.number().int().min(1).describe("The character offset (1-based, as shown in editors)"), - }), - execute: async (args, ctx) => { - const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath) - await assertExternalDirectory(ctx, file) - - await ctx.ask({ - permission: "lsp", - patterns: ["*"], - always: ["*"], - metadata: {}, - }) - const uri = pathToFileURL(file).href - const position = { - file, - line: args.line - 1, - character: args.character - 1, - } - - const relPath = path.relative(Instance.worktree, file) - const title = `${args.operation} ${relPath}:${args.line}:${args.character}` - - const exists = await Filesystem.exists(file) - if (!exists) { - throw new Error(`File not found: ${file}`) - } - - const available = await LSP.hasClients(file) - if (!available) { - throw new Error("No LSP server available for this file type.") - } - - await LSP.touchFile(file, true) - - const result: unknown[] = await (async () => { - switch (args.operation) { - case "goToDefinition": - return LSP.definition(position) - case "findReferences": - return LSP.references(position) - case "hover": - return LSP.hover(position) - case "documentSymbol": - return LSP.documentSymbol(uri) - case "workspaceSymbol": - return LSP.workspaceSymbol("") - case "goToImplementation": - return LSP.implementation(position) - case "prepareCallHierarchy": - return LSP.prepareCallHierarchy(position) - case "incomingCalls": - return LSP.incomingCalls(position) - case "outgoingCalls": - return LSP.outgoingCalls(position) - } - })() - - const output = (() => { - if (result.length === 0) return `No results found for ${args.operation}` - return JSON.stringify(result, null, 2) - })() +export const LspTool = Tool.defineEffect( + "lsp", + Effect.gen(function* () { + const lsp = yield* LSP.Service + const fs = yield* AppFileSystem.Service return { - title, - metadata: { result }, - output, + description: DESCRIPTION, + parameters: z.object({ + operation: z.enum(operations).describe("The LSP operation to perform"), + filePath: z.string().describe("The absolute or relative path to the file"), + line: z.number().int().min(1).describe("The line number (1-based, as shown in editors)"), + character: z.number().int().min(1).describe("The character offset (1-based, as shown in editors)"), + }), + execute: ( + args: { operation: (typeof operations)[number]; filePath: string; line: number; character: number }, + ctx: Tool.Context, + ) => + Effect.gen(function* () { + const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath) + yield* assertExternalDirectoryEffect(ctx, file) + yield* Effect.promise(() => ctx.ask({ permission: "lsp", patterns: ["*"], always: ["*"], metadata: {} })) + + const uri = pathToFileURL(file).href + const position = { file, line: args.line - 1, character: args.character - 1 } + const relPath = path.relative(Instance.worktree, file) + const title = `${args.operation} ${relPath}:${args.line}:${args.character}` + + const exists = yield* fs.existsSafe(file) + if (!exists) throw new Error(`File not found: ${file}`) + + const available = yield* lsp.hasClients(file) + if (!available) throw new Error("No LSP server available for this file type.") + + yield* lsp.touchFile(file, true) + + const result: unknown[] = yield* (() => { + switch (args.operation) { + case "goToDefinition": + return lsp.definition(position) + case "findReferences": + return lsp.references(position) + case "hover": + return lsp.hover(position) + case "documentSymbol": + return lsp.documentSymbol(uri) + case "workspaceSymbol": + return lsp.workspaceSymbol("") + case "goToImplementation": + return lsp.implementation(position) + case "prepareCallHierarchy": + return lsp.prepareCallHierarchy(position) + case "incomingCalls": + return lsp.incomingCalls(position) + case "outgoingCalls": + return lsp.outgoingCalls(position) + } + })() + + return { + title, + metadata: { result }, + output: result.length === 0 ? `No results found for ${args.operation}` : JSON.stringify(result, null, 2), + } + }).pipe(Effect.runPromise), } - }, -}) + }), +) diff --git a/packages/opencode/src/tool/plan.ts b/packages/opencode/src/tool/plan.ts index e91bc3faa2..aa9c698842 100644 --- a/packages/opencode/src/tool/plan.ts +++ b/packages/opencode/src/tool/plan.ts @@ -1,5 +1,6 @@ import z from "zod" import path from "path" +import { Effect } from "effect" import { Tool } from "./tool" import { Question } from "../question" import { Session } from "../session" @@ -9,123 +10,71 @@ import { Instance } from "../project/instance" import { type SessionID, MessageID, PartID } from "../session/schema" import EXIT_DESCRIPTION from "./plan-exit.txt" -async function getLastModel(sessionID: SessionID) { - for await (const item of MessageV2.stream(sessionID)) { +function getLastModel(sessionID: SessionID) { + for (const item of MessageV2.stream(sessionID)) { if (item.info.role === "user" && item.info.model) return item.info.model } - return Provider.defaultModel() + return undefined } -export const PlanExitTool = Tool.define("plan_exit", { - description: EXIT_DESCRIPTION, - parameters: z.object({}), - async execute(_params, ctx) { - const session = await Session.get(ctx.sessionID) - const plan = path.relative(Instance.worktree, Session.plan(session)) - const answers = await Question.ask({ - sessionID: ctx.sessionID, - questions: [ - { - question: `Plan at ${plan} is complete. Would you like to switch to the build agent and start implementing?`, - header: "Build Agent", - custom: false, - options: [ - { label: "Yes", description: "Switch to build agent and start implementing the plan" }, - { label: "No", description: "Stay with plan agent to continue refining the plan" }, - ], - }, - ], - tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined, - }) - - const answer = answers[0]?.[0] - if (answer === "No") throw new Question.RejectedError() - - const model = await getLastModel(ctx.sessionID) - - const userMsg: MessageV2.User = { - id: MessageID.ascending(), - sessionID: ctx.sessionID, - role: "user", - time: { - created: Date.now(), - }, - agent: "build", - model, - } - await Session.updateMessage(userMsg) - await Session.updatePart({ - id: PartID.ascending(), - messageID: userMsg.id, - sessionID: ctx.sessionID, - type: "text", - text: `The plan at ${plan} has been approved, you can now edit files. Execute the plan`, - synthetic: true, - } satisfies MessageV2.TextPart) +export const PlanExitTool = Tool.defineEffect( + "plan_exit", + Effect.gen(function* () { + const session = yield* Session.Service + const question = yield* Question.Service + const provider = yield* Provider.Service return { - title: "Switching to build agent", - output: "User approved switching to build agent. Wait for further instructions.", - metadata: {}, + description: EXIT_DESCRIPTION, + parameters: z.object({}), + execute: (_params: {}, ctx: Tool.Context) => + Effect.gen(function* () { + const info = yield* session.get(ctx.sessionID) + const plan = path.relative(Instance.worktree, Session.plan(info)) + const answers = yield* question.ask({ + sessionID: ctx.sessionID, + questions: [ + { + question: `Plan at ${plan} is complete. Would you like to switch to the build agent and start implementing?`, + header: "Build Agent", + custom: false, + options: [ + { label: "Yes", description: "Switch to build agent and start implementing the plan" }, + { label: "No", description: "Stay with plan agent to continue refining the plan" }, + ], + }, + ], + tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined, + }) + + if (answers[0]?.[0] === "No") yield* new Question.RejectedError() + + const model = getLastModel(ctx.sessionID) ?? (yield* provider.defaultModel()) + + const msg: MessageV2.User = { + id: MessageID.ascending(), + sessionID: ctx.sessionID, + role: "user", + time: { created: Date.now() }, + agent: "build", + model, + } + yield* session.updateMessage(msg) + yield* session.updatePart({ + id: PartID.ascending(), + messageID: msg.id, + sessionID: ctx.sessionID, + type: "text", + text: `The plan at ${plan} has been approved, you can now edit files. Execute the plan`, + synthetic: true, + } satisfies MessageV2.TextPart) + + return { + title: "Switching to build agent", + output: "User approved switching to build agent. Wait for further instructions.", + metadata: {}, + } + }).pipe(Effect.runPromise), } - }, -}) - -/* -export const PlanEnterTool = Tool.define("plan_enter", { - description: ENTER_DESCRIPTION, - parameters: z.object({}), - async execute(_params, ctx) { - const session = await Session.get(ctx.sessionID) - const plan = path.relative(Instance.worktree, Session.plan(session)) - - const answers = await Question.ask({ - sessionID: ctx.sessionID, - questions: [ - { - question: `Would you like to switch to the plan agent and create a plan saved to ${plan}?`, - header: "Plan Mode", - custom: false, - options: [ - { label: "Yes", description: "Switch to plan agent for research and planning" }, - { label: "No", description: "Stay with build agent to continue making changes" }, - ], - }, - ], - tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined, - }) - - const answer = answers[0]?.[0] - - if (answer === "No") throw new Question.RejectedError() - - const model = await getLastModel(ctx.sessionID) - - const userMsg: MessageV2.User = { - id: MessageID.ascending(), - sessionID: ctx.sessionID, - role: "user", - time: { - created: Date.now(), - }, - agent: "plan", - model, - } - await Session.updateMessage(userMsg) - await Session.updatePart({ - id: PartID.ascending(), - messageID: userMsg.id, - sessionID: ctx.sessionID, - type: "text", - text: "User has requested to enter plan mode. Switch to plan mode and begin planning.", - synthetic: true, - } satisfies MessageV2.TextPart) - - return { - title: "Switching to plan agent", - output: `User confirmed to switch to plan mode. A new message has been created to switch you to plan mode. The plan file will be at ${plan}. Begin planning.`, - metadata: {}, - } - }, -}) -*/ + }), +) diff --git a/packages/opencode/src/tool/question.ts b/packages/opencode/src/tool/question.ts index 23c9b35c89..f7adbadcf7 100644 --- a/packages/opencode/src/tool/question.ts +++ b/packages/opencode/src/tool/question.ts @@ -20,27 +20,26 @@ export const QuestionTool = Tool.defineEffect, ctx: Tool.Context) { - const answers = await question - .ask({ + execute: (params: z.infer, ctx: Tool.Context) => + Effect.gen(function* () { + const answers = yield* question.ask({ sessionID: ctx.sessionID, questions: params.questions, tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined, }) - .pipe(Effect.runPromise) - const formatted = params.questions - .map((q, i) => `"${q.question}"="${answers[i]?.length ? answers[i].join(", ") : "Unanswered"}"`) - .join(", ") + const formatted = params.questions + .map((q, i) => `"${q.question}"="${answers[i]?.length ? answers[i].join(", ") : "Unanswered"}"`) + .join(", ") - return { - title: `Asked ${params.questions.length} question${params.questions.length > 1 ? "s" : ""}`, - output: `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.`, - metadata: { - answers, - }, - } - }, + return { + title: `Asked ${params.questions.length} question${params.questions.length > 1 ? "s" : ""}`, + output: `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.`, + metadata: { + answers, + }, + } + }).pipe(Effect.runPromise), } }), ) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 9c0771b8df..6d0a6e0cd0 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -1,4 +1,5 @@ import { PlanExitTool } from "./plan" +import { Session } from "../session" import { QuestionTool } from "./question" import { BashTool } from "./bash" import { EditTool } from "./edit" @@ -16,6 +17,7 @@ import { Config } from "../config/config" import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin" import z from "zod" import { Plugin } from "../plugin" +import { Provider } from "../provider/provider" import { ProviderID, type ModelID } from "../provider/schema" import { WebSearchTool } from "./websearch" import { CodeSearchTool } from "./codesearch" @@ -28,6 +30,7 @@ import { Glob } from "../util/glob" import path from "path" import { pathToFileURL } from "url" import { Effect, Layer, ServiceMap } from "effect" +import { FetchHttpClient, HttpClient } from "effect/unstable/http" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" import { Env } from "../env" @@ -76,10 +79,13 @@ export namespace ToolRegistry { | Todo.Service | Agent.Service | Skill.Service + | Session.Service + | Provider.Service | LSP.Service | FileTime.Service | Instruction.Service | AppFileSystem.Service + | HttpClient.HttpClient > = Layer.effect( Service, Effect.gen(function* () { @@ -92,6 +98,9 @@ export namespace ToolRegistry { const read = yield* ReadTool const question = yield* QuestionTool const todo = yield* TodoWriteTool + const lsptool = yield* LspTool + const plan = yield* PlanExitTool + const webfetch = yield* WebFetchTool const state = yield* InstanceState.make( Effect.fn("ToolRegistry.state")(function* (ctx) { @@ -157,15 +166,15 @@ export namespace ToolRegistry { edit: Tool.init(EditTool), write: Tool.init(WriteTool), task: Tool.init(task), - fetch: Tool.init(WebFetchTool), + fetch: Tool.init(webfetch), todo: Tool.init(todo), search: Tool.init(WebSearchTool), code: Tool.init(CodeSearchTool), skill: Tool.init(SkillTool), patch: Tool.init(ApplyPatchTool), question: Tool.init(question), - lsp: Tool.init(LspTool), - plan: Tool.init(PlanExitTool), + lsp: Tool.init(lsptool), + plan: Tool.init(plan), }) return { @@ -297,10 +306,13 @@ export namespace ToolRegistry { Layer.provide(Todo.defaultLayer), Layer.provide(Skill.defaultLayer), Layer.provide(Agent.defaultLayer), + Layer.provide(Session.defaultLayer), + Layer.provide(Provider.defaultLayer), Layer.provide(LSP.defaultLayer), Layer.provide(FileTime.defaultLayer), Layer.provide(Instruction.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(FetchHttpClient.layer), ), ) diff --git a/packages/opencode/src/tool/webfetch.ts b/packages/opencode/src/tool/webfetch.ts index 559afd6771..1c89d950a3 100644 --- a/packages/opencode/src/tool/webfetch.ts +++ b/packages/opencode/src/tool/webfetch.ts @@ -1,170 +1,163 @@ import z from "zod" +import { Effect } from "effect" +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { Tool } from "./tool" import TurndownService from "turndown" import DESCRIPTION from "./webfetch.txt" -import { abortAfterAny } from "../util/abort" -import { iife } from "@/util/iife" const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds const MAX_TIMEOUT = 120 * 1000 // 2 minutes -export const WebFetchTool = Tool.define("webfetch", { - description: DESCRIPTION, - parameters: z.object({ - url: z.string().describe("The URL to fetch content from"), - format: z - .enum(["text", "markdown", "html"]) - .default("markdown") - .describe("The format to return the content in (text, markdown, or html). Defaults to markdown."), - timeout: z.number().describe("Optional timeout in seconds (max 120)").optional(), - }), - async execute(params, ctx) { - // Validate URL - if (!params.url.startsWith("http://") && !params.url.startsWith("https://")) { - throw new Error("URL must start with http:// or https://") - } - - await ctx.ask({ - permission: "webfetch", - patterns: [params.url], - always: ["*"], - metadata: { - url: params.url, - format: params.format, - timeout: params.timeout, - }, - }) - - const timeout = Math.min((params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000, MAX_TIMEOUT) - - const { signal, clearTimeout } = abortAfterAny(timeout, ctx.abort) - - // Build Accept header based on requested format with q parameters for fallbacks - let acceptHeader = "*/*" - switch (params.format) { - case "markdown": - acceptHeader = "text/markdown;q=1.0, text/x-markdown;q=0.9, text/plain;q=0.8, text/html;q=0.7, */*;q=0.1" - break - case "text": - acceptHeader = "text/plain;q=1.0, text/markdown;q=0.9, text/html;q=0.8, */*;q=0.1" - break - case "html": - acceptHeader = "text/html;q=1.0, application/xhtml+xml;q=0.9, text/plain;q=0.8, text/markdown;q=0.7, */*;q=0.1" - break - default: - acceptHeader = - "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8" - } - const headers = { - "User-Agent": - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36", - Accept: acceptHeader, - "Accept-Language": "en-US,en;q=0.9", - } - - const response = await iife(async () => { - try { - const initial = await fetch(params.url, { signal, headers }) - - // Retry with honest UA if blocked by Cloudflare bot detection (TLS fingerprint mismatch) - return initial.status === 403 && initial.headers.get("cf-mitigated") === "challenge" - ? await fetch(params.url, { signal, headers: { ...headers, "User-Agent": "opencode" } }) - : initial - } finally { - clearTimeout() - } - }) - - if (!response.ok) { - throw new Error(`Request failed with status code: ${response.status}`) - } - - // Check content length - const contentLength = response.headers.get("content-length") - if (contentLength && parseInt(contentLength) > MAX_RESPONSE_SIZE) { - throw new Error("Response too large (exceeds 5MB limit)") - } - - const arrayBuffer = await response.arrayBuffer() - if (arrayBuffer.byteLength > MAX_RESPONSE_SIZE) { - throw new Error("Response too large (exceeds 5MB limit)") - } - - const contentType = response.headers.get("content-type") || "" - const mime = contentType.split(";")[0]?.trim().toLowerCase() || "" - const title = `${params.url} (${contentType})` - - // Check if response is an image - const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet" - - if (isImage) { - const base64Content = Buffer.from(arrayBuffer).toString("base64") - return { - title, - output: "Image fetched successfully", - metadata: {}, - attachments: [ - { - type: "file", - mime, - url: `data:${mime};base64,${base64Content}`, - }, - ], - } - } - - const content = new TextDecoder().decode(arrayBuffer) - - // Handle content based on requested format and actual content type - switch (params.format) { - case "markdown": - if (contentType.includes("text/html")) { - const markdown = convertHTMLToMarkdown(content) - return { - output: markdown, - title, - metadata: {}, - } - } - return { - output: content, - title, - metadata: {}, - } - - case "text": - if (contentType.includes("text/html")) { - const text = await extractTextFromHTML(content) - return { - output: text, - title, - metadata: {}, - } - } - return { - output: content, - title, - metadata: {}, - } - - case "html": - return { - output: content, - title, - metadata: {}, - } - - default: - return { - output: content, - title, - metadata: {}, - } - } - }, +const parameters = z.object({ + url: z.string().describe("The URL to fetch content from"), + format: z + .enum(["text", "markdown", "html"]) + .default("markdown") + .describe("The format to return the content in (text, markdown, or html). Defaults to markdown."), + timeout: z.number().describe("Optional timeout in seconds (max 120)").optional(), }) +export const WebFetchTool = Tool.defineEffect( + "webfetch", + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + const httpOk = HttpClient.filterStatusOk(http) + + return { + description: DESCRIPTION, + parameters, + execute: (params: z.infer, ctx: Tool.Context) => + Effect.gen(function* () { + if (!params.url.startsWith("http://") && !params.url.startsWith("https://")) { + throw new Error("URL must start with http:// or https://") + } + + yield* Effect.promise(() => + ctx.ask({ + permission: "webfetch", + patterns: [params.url], + always: ["*"], + metadata: { + url: params.url, + format: params.format, + timeout: params.timeout, + }, + }), + ) + + const timeout = Math.min((params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000, MAX_TIMEOUT) + + // Build Accept header based on requested format with q parameters for fallbacks + let acceptHeader = "*/*" + switch (params.format) { + case "markdown": + acceptHeader = "text/markdown;q=1.0, text/x-markdown;q=0.9, text/plain;q=0.8, text/html;q=0.7, */*;q=0.1" + break + case "text": + acceptHeader = "text/plain;q=1.0, text/markdown;q=0.9, text/html;q=0.8, */*;q=0.1" + break + case "html": + acceptHeader = + "text/html;q=1.0, application/xhtml+xml;q=0.9, text/plain;q=0.8, text/markdown;q=0.7, */*;q=0.1" + break + default: + acceptHeader = + "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8" + } + const headers = { + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36", + Accept: acceptHeader, + "Accept-Language": "en-US,en;q=0.9", + } + + const request = HttpClientRequest.get(params.url).pipe(HttpClientRequest.setHeaders(headers)) + + // Retry with honest UA if blocked by Cloudflare bot detection (TLS fingerprint mismatch) + const response = yield* httpOk.execute(request).pipe( + Effect.catchIf( + (err) => + err.reason._tag === "StatusCodeError" && + err.reason.response.status === 403 && + err.reason.response.headers["cf-mitigated"] === "challenge", + () => + httpOk.execute( + HttpClientRequest.get(params.url).pipe( + HttpClientRequest.setHeaders({ ...headers, "User-Agent": "opencode" }), + ), + ), + ), + Effect.timeoutOrElse({ duration: timeout, orElse: () => Effect.die(new Error("Request timed out")) }), + ) + + // Check content length + const contentLength = response.headers["content-length"] + if (contentLength && parseInt(contentLength) > MAX_RESPONSE_SIZE) { + throw new Error("Response too large (exceeds 5MB limit)") + } + + const arrayBuffer = yield* response.arrayBuffer + if (arrayBuffer.byteLength > MAX_RESPONSE_SIZE) { + throw new Error("Response too large (exceeds 5MB limit)") + } + + const contentType = response.headers["content-type"] || "" + const mime = contentType.split(";")[0]?.trim().toLowerCase() || "" + const title = `${params.url} (${contentType})` + + // Check if response is an image + const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet" + + if (isImage) { + const base64Content = Buffer.from(arrayBuffer).toString("base64") + return { + title, + output: "Image fetched successfully", + metadata: {}, + attachments: [ + { + type: "file" as const, + mime, + url: `data:${mime};base64,${base64Content}`, + }, + ], + } + } + + const content = new TextDecoder().decode(arrayBuffer) + + // Handle content based on requested format and actual content type + switch (params.format) { + case "markdown": + if (contentType.includes("text/html")) { + const markdown = convertHTMLToMarkdown(content) + return { + output: markdown, + title, + metadata: {}, + } + } + return { output: content, title, metadata: {} } + + case "text": + if (contentType.includes("text/html")) { + const text = yield* Effect.promise(() => extractTextFromHTML(content)) + return { output: text, title, metadata: {} } + } + return { output: content, title, metadata: {} } + + case "html": + return { output: content, title, metadata: {} } + + default: + return { output: content, title, metadata: {} } + } + }).pipe(Effect.runPromise), + } + }), +) + async function extractTextFromHTML(html: string) { let text = "" let skipContent = false diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 54986d65cd..e0e7dab4c1 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -246,6 +246,7 @@ export namespace Worktree { const boot = Effect.fnUntraced(function* (info: Info, startCommand?: string) { const ctx = yield* InstanceState.context + const workspaceID = yield* InstanceState.workspaceID const projectID = ctx.project.id const extra = startCommand?.trim() @@ -255,6 +256,8 @@ export namespace Worktree { log.error("worktree checkout failed", { directory: info.directory, message }) GlobalBus.emit("event", { directory: info.directory, + project: ctx.project.id, + workspace: workspaceID, payload: { type: Event.Failed.type, properties: { message } }, }) return @@ -272,6 +275,8 @@ export namespace Worktree { log.error("worktree bootstrap failed", { directory: info.directory, message }) GlobalBus.emit("event", { directory: info.directory, + project: ctx.project.id, + workspace: workspaceID, payload: { type: Event.Failed.type, properties: { message } }, }) return false @@ -281,6 +286,8 @@ export namespace Worktree { GlobalBus.emit("event", { directory: info.directory, + project: ctx.project.id, + workspace: workspaceID, payload: { type: Event.Ready.type, properties: { name: info.name, branch: info.branch }, diff --git a/packages/opencode/test/cli/tui/sync-provider.test.tsx b/packages/opencode/test/cli/tui/sync-provider.test.tsx new file mode 100644 index 0000000000..ec686b3688 --- /dev/null +++ b/packages/opencode/test/cli/tui/sync-provider.test.tsx @@ -0,0 +1,293 @@ +/** @jsxImportSource @opentui/solid */ +import { afterEach, describe, expect, test } from "bun:test" +import { testRender } from "@opentui/solid" +import { onMount } from "solid-js" +import { ArgsProvider } from "../../../src/cli/cmd/tui/context/args" +import { ExitProvider } from "../../../src/cli/cmd/tui/context/exit" +import { ProjectProvider, useProject } from "../../../src/cli/cmd/tui/context/project" +import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk" +import { SyncProvider, useSync } from "../../../src/cli/cmd/tui/context/sync" + +const sighup = new Set(process.listeners("SIGHUP")) + +afterEach(() => { + for (const fn of process.listeners("SIGHUP")) { + if (!sighup.has(fn)) process.off("SIGHUP", fn) + } +}) + +function json(data: unknown) { + return new Response(JSON.stringify(data), { + headers: { + "content-type": "application/json", + }, + }) +} + +async function wait(fn: () => boolean, timeout = 2000) { + const start = Date.now() + while (!fn()) { + if (Date.now() - start > timeout) throw new Error("timed out waiting for condition") + await Bun.sleep(10) + } +} + +function data(workspace?: string | null) { + const tag = workspace ?? "root" + return { + session: { + id: "ses_1", + title: `session-${tag}`, + workspaceID: workspace ?? undefined, + time: { + updated: 1, + }, + }, + message: { + info: { + id: "msg_1", + sessionID: "ses_1", + role: "assistant", + time: { + created: 1, + completed: 1, + }, + }, + parts: [ + { + id: "part_1", + messageID: "msg_1", + sessionID: "ses_1", + type: "text", + text: `part-${tag}`, + }, + ], + }, + todo: [ + { + id: `todo-${tag}`, + content: `todo-${tag}`, + status: "pending", + priority: "medium", + }, + ], + diff: [ + { + file: `${tag}.ts`, + patch: "", + additions: 0, + deletions: 0, + }, + ], + } +} + +type Hit = { + path: string + workspace?: string +} + +function createFetch(log: Hit[]) { + return Object.assign( + async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init) + const url = new URL(req.url) + const workspace = url.searchParams.get("workspace") ?? req.headers.get("x-opencode-workspace") ?? undefined + log.push({ + path: url.pathname, + workspace, + }) + + if (url.pathname === "/config/providers") { + return json({ providers: [], default: {} }) + } + if (url.pathname === "/provider") { + return json({ all: [], default: {}, connected: [] }) + } + if (url.pathname === "/experimental/console") { + return json({}) + } + if (url.pathname === "/agent") { + return json([]) + } + if (url.pathname === "/config") { + return json({}) + } + if (url.pathname === "/project/current") { + return json({ id: `proj-${workspace ?? "root"}` }) + } + if (url.pathname === "/path") { + return json({ + state: `/tmp/${workspace ?? "root"}/state`, + config: `/tmp/${workspace ?? "root"}/config`, + worktree: "/tmp/worktree", + directory: `/tmp/${workspace ?? "root"}`, + }) + } + if (url.pathname === "/session") { + return json([]) + } + if (url.pathname === "/command") { + return json([]) + } + if (url.pathname === "/lsp") { + return json([]) + } + if (url.pathname === "/mcp") { + return json({}) + } + if (url.pathname === "/experimental/resource") { + return json({}) + } + if (url.pathname === "/formatter") { + return json([]) + } + if (url.pathname === "/session/status") { + return json({}) + } + if (url.pathname === "/provider/auth") { + return json({}) + } + if (url.pathname === "/vcs") { + return json({ branch: "main" }) + } + if (url.pathname === "/experimental/workspace") { + return json([{ id: "ws_a" }, { id: "ws_b" }]) + } + if (url.pathname === "/session/ses_1") { + return json(data(workspace).session) + } + if (url.pathname === "/session/ses_1/message") { + return json([data(workspace).message]) + } + if (url.pathname === "/session/ses_1/todo") { + return json(data(workspace).todo) + } + if (url.pathname === "/session/ses_1/diff") { + return json(data(workspace).diff) + } + + throw new Error(`unexpected request: ${req.method} ${url.pathname}`) + }, + { preconnect: fetch.preconnect.bind(fetch) }, + ) satisfies typeof fetch +} + +async function mount(log: Hit[]) { + let project!: ReturnType + let sync!: ReturnType + let done!: () => void + const ready = new Promise((resolve) => { + done = resolve + }) + + const app = await testRender(() => ( + () => {} }} + > + + + + + { + project = ctx.project + sync = ctx.sync + done() + }} + /> + + + + + + )) + + await ready + return { app, project, sync } +} + +async function waitBoot(log: Hit[], workspace?: string) { + await wait(() => log.some((item) => item.path === "/experimental/workspace")) + if (!workspace) return + await wait(() => log.some((item) => item.path === "/project/current" && item.workspace === workspace)) +} + +function Probe(props: { + onReady: (ctx: { project: ReturnType; sync: ReturnType }) => void +}) { + const project = useProject() + const sync = useSync() + + onMount(() => { + props.onReady({ project, sync }) + }) + + return +} + +describe("SyncProvider", () => { + test("re-runs bootstrap requests when the active workspace changes", async () => { + const log: Hit[] = [] + const { app, project } = await mount(log) + + try { + await waitBoot(log) + log.length = 0 + + project.workspace.set("ws_a") + + await waitBoot(log, "ws_a") + + expect(log.some((item) => item.path === "/path" && item.workspace === "ws_a")).toBe(true) + expect(log.some((item) => item.path === "/config" && item.workspace === "ws_a")).toBe(true) + expect(log.some((item) => item.path === "/session" && item.workspace === "ws_a")).toBe(true) + expect(log.some((item) => item.path === "/command" && item.workspace === "ws_a")).toBe(true) + } finally { + app.renderer.destroy() + } + }) + + test("clears full-sync cache when the active workspace changes", async () => { + const log: Hit[] = [] + const { app, project, sync } = await mount(log) + + try { + await waitBoot(log) + + log.length = 0 + project.workspace.set("ws_a") + await waitBoot(log, "ws_a") + expect(project.workspace.current()).toBe("ws_a") + + log.length = 0 + await sync.session.sync("ses_1") + + expect(log.filter((item) => item.path === "/session/ses_1" && item.workspace === "ws_a")).toHaveLength(1) + expect(sync.data.todo.ses_1[0]?.content).toBe("todo-ws_a") + expect(sync.data.message.ses_1[0]?.id).toBe("msg_1") + expect(sync.data.part.msg_1[0]).toMatchObject({ type: "text", text: "part-ws_a" }) + expect(sync.data.session_diff.ses_1[0]?.file).toBe("ws_a.ts") + + log.length = 0 + project.workspace.set("ws_b") + await waitBoot(log, "ws_b") + expect(project.workspace.current()).toBe("ws_b") + + log.length = 0 + await sync.session.sync("ses_1") + await wait(() => log.some((item) => item.path === "/session/ses_1" && item.workspace === "ws_b")) + + expect(log.filter((item) => item.path === "/session/ses_1" && item.workspace === "ws_b")).toHaveLength(1) + expect(sync.data.todo.ses_1[0]?.content).toBe("todo-ws_b") + expect(sync.data.message.ses_1[0]?.id).toBe("msg_1") + expect(sync.data.part.msg_1[0]).toMatchObject({ type: "text", text: "part-ws_b" }) + expect(sync.data.session_diff.ses_1[0]?.file).toBe("ws_b.ts") + } finally { + app.renderer.destroy() + } + }) +}) diff --git a/packages/opencode/test/cli/tui/use-event.test.tsx b/packages/opencode/test/cli/tui/use-event.test.tsx new file mode 100644 index 0000000000..5b0fcad3c9 --- /dev/null +++ b/packages/opencode/test/cli/tui/use-event.test.tsx @@ -0,0 +1,175 @@ +/** @jsxImportSource @opentui/solid */ +import { describe, expect, test } from "bun:test" +import { testRender } from "@opentui/solid" +import type { Event, GlobalEvent } from "@opencode-ai/sdk/v2" +import { onMount } from "solid-js" +import { ProjectProvider, useProject } from "../../../src/cli/cmd/tui/context/project" +import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk" +import { useEvent } from "../../../src/cli/cmd/tui/context/event" + +async function wait(fn: () => boolean, timeout = 2000) { + const start = Date.now() + while (!fn()) { + if (Date.now() - start > timeout) throw new Error("timed out waiting for condition") + await Bun.sleep(10) + } +} + +function event(payload: Event, input: { directory: string; workspace?: string }): GlobalEvent { + return { + directory: input.directory, + workspace: input.workspace, + payload, + } +} + +function vcs(branch: string): Event { + return { + type: "vcs.branch.updated", + properties: { + branch, + }, + } +} + +function update(version: string): Event { + return { + type: "installation.update-available", + properties: { + version, + }, + } +} + +function createSource() { + let fn: ((event: GlobalEvent) => void) | undefined + + return { + source: { + subscribe: async (handler: (event: GlobalEvent) => void) => { + fn = handler + return () => { + if (fn === handler) fn = undefined + } + }, + }, + emit(evt: GlobalEvent) { + if (!fn) throw new Error("event source not ready") + fn(evt) + }, + } +} + +async function mount() { + const source = createSource() + const seen: Event[] = [] + let project!: ReturnType + let done!: () => void + const ready = new Promise((resolve) => { + done = resolve + }) + + const app = await testRender(() => ( + + + { + project = ctx.project + done() + }} + seen={seen} + /> + + + )) + + await ready + return { app, emit: source.emit, project, seen } +} + +function Probe(props: { seen: Event[]; onReady: (ctx: { project: ReturnType }) => void }) { + const project = useProject() + const event = useEvent() + + onMount(() => { + event.subscribe((evt) => { + props.seen.push(evt) + }) + props.onReady({ project }) + }) + + return +} + +describe("useEvent", () => { + test("delivers matching directory events without an active workspace", async () => { + const { app, emit, seen } = await mount() + + try { + emit(event(vcs("main"), { directory: "/tmp/root" })) + + await wait(() => seen.length === 1) + + expect(seen).toEqual([vcs("main")]) + } finally { + app.renderer.destroy() + } + }) + + test("ignores non-matching directory events without an active workspace", async () => { + const { app, emit, seen } = await mount() + + try { + emit(event(vcs("other"), { directory: "/tmp/other" })) + await Bun.sleep(30) + + expect(seen).toHaveLength(0) + } finally { + app.renderer.destroy() + } + }) + + test("delivers matching workspace events when a workspace is active", async () => { + const { app, emit, project, seen } = await mount() + + try { + project.workspace.set("ws_a") + emit(event(vcs("ws"), { directory: "/tmp/other", workspace: "ws_a" })) + + await wait(() => seen.length === 1) + + expect(seen).toEqual([vcs("ws")]) + } finally { + app.renderer.destroy() + } + }) + + test("ignores non-matching workspace events when a workspace is active", async () => { + const { app, emit, project, seen } = await mount() + + try { + project.workspace.set("ws_a") + emit(event(vcs("ws"), { directory: "/tmp/root", workspace: "ws_b" })) + await Bun.sleep(30) + + expect(seen).toHaveLength(0) + } finally { + app.renderer.destroy() + } + }) + + test("delivers truly global events even when a workspace is active", async () => { + const { app, emit, project, seen } = await mount() + + try { + project.workspace.set("ws_a") + emit(event(update("1.2.3"), { directory: "global" })) + + await wait(() => seen.length === 1) + + expect(seen).toEqual([update("1.2.3")]) + } finally { + app.renderer.destroy() + } + }) +}) diff --git a/packages/opencode/test/memory/abort-leak.test.ts b/packages/opencode/test/memory/abort-leak.test.ts index eebb651a53..2d0180783b 100644 --- a/packages/opencode/test/memory/abort-leak.test.ts +++ b/packages/opencode/test/memory/abort-leak.test.ts @@ -1,5 +1,7 @@ import { describe, test, expect } from "bun:test" import path from "path" +import { Effect } from "effect" +import { FetchHttpClient } from "effect/unstable/http" import { Instance } from "../../src/project/instance" import { WebFetchTool } from "../../src/tool/webfetch" import { SessionID, MessageID } from "../../src/session/schema" @@ -30,7 +32,11 @@ describe("memory: abort controller leak", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const tool = await WebFetchTool.init() + const tool = await WebFetchTool.pipe( + Effect.flatMap((info) => Effect.promise(() => info.init())), + Effect.provide(FetchHttpClient.layer), + Effect.runPromise, + ) // Warm up await tool.execute({ url: "https://example.com", format: "text" }, ctx).catch(() => {}) diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 215f6668cf..ef8512badb 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -1,6 +1,7 @@ import { NodeFileSystem } from "@effect/platform-node" import { expect } from "bun:test" import { Cause, Effect, Exit, Fiber, Layer } from "effect" +import { FetchHttpClient } from "effect/unstable/http" import path from "path" import z from "zod" import { Agent as AgentSvc } from "../../src/agent/agent" @@ -169,6 +170,7 @@ function makeHttp() { const todo = Todo.layer.pipe(Layer.provideMerge(deps)) const registry = ToolRegistry.layer.pipe( Layer.provide(Skill.defaultLayer), + Layer.provide(FetchHttpClient.layer), Layer.provideMerge(todo), Layer.provideMerge(question), Layer.provideMerge(deps), diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index ae67983bf6..b8ee6b2844 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -12,7 +12,8 @@ * tools internally during multi-step processing before emitting events. */ import { expect } from "bun:test" -import { Effect } from "effect" +import { Effect, Layer } from "effect" +import { FetchHttpClient } from "effect/unstable/http" import fs from "fs/promises" import path from "path" import { Session } from "../../src/session" @@ -28,7 +29,6 @@ import { TestLLMServer } from "../lib/llm-server" // Same layer setup as prompt-effect.test.ts import { NodeFileSystem } from "@effect/platform-node" -import { Layer } from "effect" import { Agent as AgentSvc } from "../../src/agent/agent" import { Bus } from "../../src/bus" import { Command } from "../../src/command" @@ -134,6 +134,7 @@ function makeHttp() { const todo = Todo.layer.pipe(Layer.provideMerge(deps)) const registry = ToolRegistry.layer.pipe( Layer.provide(Skill.defaultLayer), + Layer.provide(FetchHttpClient.layer), Layer.provideMerge(todo), Layer.provideMerge(question), Layer.provideMerge(deps), diff --git a/packages/opencode/test/tool/webfetch.test.ts b/packages/opencode/test/tool/webfetch.test.ts index 00e9dcc96c..a26be24ae0 100644 --- a/packages/opencode/test/tool/webfetch.test.ts +++ b/packages/opencode/test/tool/webfetch.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from "bun:test" import path from "path" +import { Effect } from "effect" +import { FetchHttpClient } from "effect/unstable/http" import { Instance } from "../../src/project/instance" import { WebFetchTool } from "../../src/tool/webfetch" import { SessionID, MessageID } from "../../src/session/schema" @@ -22,6 +24,14 @@ async function withFetch(fetch: (req: Request) => Response | Promise, await fn(server.url) } +function initTool() { + return WebFetchTool.pipe( + Effect.flatMap((info) => Effect.promise(() => info.init())), + Effect.provide(FetchHttpClient.layer), + Effect.runPromise, + ) +} + describe("tool.webfetch", () => { test("returns image responses as file attachments", async () => { const bytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]) @@ -31,7 +41,7 @@ describe("tool.webfetch", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const webfetch = await WebFetchTool.init() + const webfetch = await initTool() const result = await webfetch.execute( { url: new URL("/image.png", url).toString(), format: "markdown" }, ctx, @@ -63,7 +73,7 @@ describe("tool.webfetch", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const webfetch = await WebFetchTool.init() + const webfetch = await initTool() const result = await webfetch.execute({ url: new URL("/image.svg", url).toString(), format: "html" }, ctx) expect(result.output).toContain(" { await Instance.provide({ directory: projectRoot, fn: async () => { - const webfetch = await WebFetchTool.init() + const webfetch = await initTool() const result = await webfetch.execute({ url: new URL("/file.txt", url).toString(), format: "text" }, ctx) expect(result.output).toBe("hello from webfetch") expect(result.attachments).toBeUndefined() diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 62c62e138f..823c452f9d 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1011,6 +1011,8 @@ export type Event = export type GlobalEvent = { directory: string + project?: string + workspace?: string payload: Event } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 450de51319..40361c280d 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -9926,6 +9926,12 @@ "directory": { "type": "string" }, + "project": { + "type": "string" + }, + "workspace": { + "type": "string" + }, "payload": { "$ref": "#/components/schemas/Event" }