Compare commits
7 commits
dev
...
jlongster/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c5b65b2b6 | ||
|
|
202b14353d | ||
|
|
e9e8aa5c94 | ||
|
|
2b0fdcc449 | ||
|
|
5d68c61d31 | ||
|
|
5a1be09727 | ||
|
|
165481813a |
36 changed files with 1133 additions and 762 deletions
44
packages/opencode/specs/workspace-refactor-pr-description.md
Normal file
44
packages/opencode/specs/workspace-refactor-pr-description.md
Normal file
|
|
@ -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
|
||||||
|
|
@ -4,6 +4,8 @@ export const GlobalBus = new EventEmitter<{
|
||||||
event: [
|
event: [
|
||||||
{
|
{
|
||||||
directory?: string
|
directory?: string
|
||||||
|
project?: string
|
||||||
|
workspace?: string
|
||||||
payload: any
|
payload: any
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Effect, Exit, Layer, PubSub, Scope, ServiceMap, Stream } from "effect"
|
import { Effect, Exit, Layer, PubSub, Scope, ServiceMap, Stream } from "effect"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
import { Instance } from "../project/instance"
|
|
||||||
import { BusEvent } from "./bus-event"
|
import { BusEvent } from "./bus-event"
|
||||||
import { GlobalBus } from "./global"
|
import { GlobalBus } from "./global"
|
||||||
|
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
|
|
||||||
|
|
@ -91,8 +91,13 @@ export namespace Bus {
|
||||||
yield* PubSub.publish(s.wildcard, payload)
|
yield* PubSub.publish(s.wildcard, payload)
|
||||||
|
|
||||||
const dir = yield* InstanceState.directory
|
const dir = yield* InstanceState.directory
|
||||||
|
const context = yield* InstanceState.context
|
||||||
|
const workspace = yield* InstanceState.workspaceID
|
||||||
|
|
||||||
GlobalBus.emit("event", {
|
GlobalBus.emit("event", {
|
||||||
directory: dir,
|
directory: dir,
|
||||||
|
project: context.project.id,
|
||||||
|
workspace,
|
||||||
payload,
|
payload,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ import {
|
||||||
batch,
|
batch,
|
||||||
Show,
|
Show,
|
||||||
on,
|
on,
|
||||||
onCleanup,
|
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||||
import { Flag } from "@/flag/flag"
|
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 { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
|
||||||
import { ErrorComponent } from "@tui/component/error-component"
|
import { ErrorComponent } from "@tui/component/error-component"
|
||||||
import { PluginRouteMissing } from "@tui/component/plugin-route-missing"
|
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 { SDKProvider, useSDK } from "@tui/context/sdk"
|
||||||
import { StartupLoading } from "@tui/component/startup-loading"
|
import { StartupLoading } from "@tui/component/startup-loading"
|
||||||
import { SyncProvider, useSync } from "@tui/context/sync"
|
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 { CommandProvider, useCommandDialog } from "@tui/component/dialog-command"
|
||||||
import { DialogAgent } from "@tui/component/dialog-agent"
|
import { DialogAgent } from "@tui/component/dialog-agent"
|
||||||
import { DialogSessionList } from "@tui/component/dialog-session-list"
|
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 { DialogConsoleOrg } from "@tui/component/dialog-console-org"
|
||||||
import { KeybindProvider, useKeybind } from "@tui/context/keybind"
|
import { KeybindProvider, useKeybind } from "@tui/context/keybind"
|
||||||
import { ThemeProvider, useTheme } from "@tui/context/theme"
|
import { ThemeProvider, useTheme } from "@tui/context/theme"
|
||||||
|
|
@ -54,7 +54,6 @@ import { KVProvider, useKV } from "./context/kv"
|
||||||
import { Provider } from "@/provider/provider"
|
import { Provider } from "@/provider/provider"
|
||||||
import { ArgsProvider, useArgs, type Args } from "./context/args"
|
import { ArgsProvider, useArgs, type Args } from "./context/args"
|
||||||
import open from "open"
|
import open from "open"
|
||||||
import { writeHeapSnapshot } from "v8"
|
|
||||||
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
||||||
import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
|
import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
|
||||||
import { TuiConfig } from "@/config/tui"
|
import { TuiConfig } from "@/config/tui"
|
||||||
|
|
@ -216,27 +215,29 @@ export function tui(input: {
|
||||||
headers={input.headers}
|
headers={input.headers}
|
||||||
events={input.events}
|
events={input.events}
|
||||||
>
|
>
|
||||||
<SyncProvider>
|
<ProjectProvider>
|
||||||
<ThemeProvider mode={mode}>
|
<SyncProvider>
|
||||||
<LocalProvider>
|
<ThemeProvider mode={mode}>
|
||||||
<KeybindProvider>
|
<LocalProvider>
|
||||||
<PromptStashProvider>
|
<KeybindProvider>
|
||||||
<DialogProvider>
|
<PromptStashProvider>
|
||||||
<CommandProvider>
|
<DialogProvider>
|
||||||
<FrecencyProvider>
|
<CommandProvider>
|
||||||
<PromptHistoryProvider>
|
<FrecencyProvider>
|
||||||
<PromptRefProvider>
|
<PromptHistoryProvider>
|
||||||
<App onSnapshot={input.onSnapshot} />
|
<PromptRefProvider>
|
||||||
</PromptRefProvider>
|
<App onSnapshot={input.onSnapshot} />
|
||||||
</PromptHistoryProvider>
|
</PromptRefProvider>
|
||||||
</FrecencyProvider>
|
</PromptHistoryProvider>
|
||||||
</CommandProvider>
|
</FrecencyProvider>
|
||||||
</DialogProvider>
|
</CommandProvider>
|
||||||
</PromptStashProvider>
|
</DialogProvider>
|
||||||
</KeybindProvider>
|
</PromptStashProvider>
|
||||||
</LocalProvider>
|
</KeybindProvider>
|
||||||
</ThemeProvider>
|
</LocalProvider>
|
||||||
</SyncProvider>
|
</ThemeProvider>
|
||||||
|
</SyncProvider>
|
||||||
|
</ProjectProvider>
|
||||||
</SDKProvider>
|
</SDKProvider>
|
||||||
</TuiConfigProvider>
|
</TuiConfigProvider>
|
||||||
</RouteProvider>
|
</RouteProvider>
|
||||||
|
|
@ -260,6 +261,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||||
const kv = useKV()
|
const kv = useKV()
|
||||||
const command = useCommandDialog()
|
const command = useCommandDialog()
|
||||||
const keybind = useKeybind()
|
const keybind = useKeybind()
|
||||||
|
const event = useEvent()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const themeState = useTheme()
|
const themeState = useTheme()
|
||||||
|
|
@ -283,6 +285,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||||
route,
|
route,
|
||||||
routes,
|
routes,
|
||||||
bump: () => setRouteRev((x) => x + 1),
|
bump: () => setRouteRev((x) => x + 1),
|
||||||
|
event,
|
||||||
sdk,
|
sdk,
|
||||||
sync,
|
sync,
|
||||||
theme: themeState,
|
theme: themeState,
|
||||||
|
|
@ -461,22 +464,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||||
dialog.replace(() => <DialogSessionList />)
|
dialog.replace(() => <DialogSessionList />)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
...(Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
title: "Manage workspaces",
|
|
||||||
value: "workspace.list",
|
|
||||||
category: "Workspace",
|
|
||||||
suggested: true,
|
|
||||||
slash: {
|
|
||||||
name: "workspaces",
|
|
||||||
},
|
|
||||||
onSelect: () => {
|
|
||||||
dialog.replace(() => <DialogWorkspaceList />)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
{
|
{
|
||||||
title: "New session",
|
title: "New session",
|
||||||
suggested: route.data.type === "session",
|
suggested: route.data.type === "session",
|
||||||
|
|
@ -491,12 +478,9 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||||
const current = promptRef.current
|
const current = promptRef.current
|
||||||
// Don't require focus - if there's any text, preserve it
|
// Don't require focus - if there's any text, preserve it
|
||||||
const currentPrompt = current?.current?.input ? current.current : undefined
|
const currentPrompt = current?.current?.input ? current.current : undefined
|
||||||
const workspaceID =
|
|
||||||
route.data.type === "session" ? sync.session.get(route.data.sessionID)?.workspaceID : undefined
|
|
||||||
route.navigate({
|
route.navigate({
|
||||||
type: "home",
|
type: "home",
|
||||||
initialPrompt: currentPrompt,
|
initialPrompt: currentPrompt,
|
||||||
workspaceID,
|
|
||||||
})
|
})
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
|
|
@ -806,11 +790,11 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
sdk.event.on(TuiEvent.CommandExecute.type, (evt) => {
|
event.on(TuiEvent.CommandExecute.type, (evt) => {
|
||||||
command.trigger(evt.properties.command)
|
command.trigger(evt.properties.command)
|
||||||
})
|
})
|
||||||
|
|
||||||
sdk.event.on(TuiEvent.ToastShow.type, (evt) => {
|
event.on(TuiEvent.ToastShow.type, (evt) => {
|
||||||
toast.show({
|
toast.show({
|
||||||
title: evt.properties.title,
|
title: evt.properties.title,
|
||||||
message: evt.properties.message,
|
message: evt.properties.message,
|
||||||
|
|
@ -819,14 +803,14 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
sdk.event.on(TuiEvent.SessionSelect.type, (evt) => {
|
event.on(TuiEvent.SessionSelect.type, (evt) => {
|
||||||
route.navigate({
|
route.navigate({
|
||||||
type: "session",
|
type: "session",
|
||||||
sessionID: evt.properties.sessionID,
|
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) {
|
if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
|
||||||
route.navigate({ type: "home" })
|
route.navigate({ type: "home" })
|
||||||
toast.show({
|
toast.show({
|
||||||
|
|
@ -836,7 +820,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
sdk.event.on("session.error", (evt) => {
|
event.on("session.error", (evt) => {
|
||||||
const error = evt.properties.error
|
const error = evt.properties.error
|
||||||
if (error && typeof error === "object" && error.name === "MessageAbortedError") return
|
if (error && typeof error === "object" && error.name === "MessageAbortedError") return
|
||||||
const message = errorMessage(error)
|
const message = errorMessage(error)
|
||||||
|
|
@ -848,7 +832,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
sdk.event.on("installation.update-available", async (evt) => {
|
event.on("installation.update-available", async (evt) => {
|
||||||
const version = evt.properties.version
|
const version = evt.properties.version
|
||||||
|
|
||||||
const skipped = kv.get("skipped_version")
|
const skipped = kv.get("skipped_version")
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,50 @@
|
||||||
import { useDialog } from "@tui/ui/dialog"
|
import { RGBA } from "@opentui/core"
|
||||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
|
||||||
import { useRoute } from "@tui/context/route"
|
import { useRoute } from "@tui/context/route"
|
||||||
import { useSync } from "@tui/context/sync"
|
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 { Locale } from "@/util/locale"
|
||||||
import { useKeybind } from "../context/keybind"
|
import { useKeybind } from "../context/keybind"
|
||||||
import { useTheme } from "../context/theme"
|
|
||||||
import { useSDK } from "../context/sdk"
|
import { useSDK } from "../context/sdk"
|
||||||
import { DialogSessionRename } from "./dialog-session-rename"
|
import { useTheme } from "../context/theme"
|
||||||
import { useKV } from "../context/kv"
|
|
||||||
import { createDebouncedSignal } from "../util/signal"
|
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"
|
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() {
|
export function DialogSessionList() {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
@ -19,44 +52,60 @@ export function DialogSessionList() {
|
||||||
const keybind = useKeybind()
|
const keybind = useKeybind()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const kv = useKV()
|
const toast = useToast()
|
||||||
|
|
||||||
const [toDelete, setToDelete] = createSignal<string>()
|
const [toDelete, setToDelete] = createSignal<string>()
|
||||||
const [search, setSearch] = createDebouncedSignal("", 150)
|
const [search, setSearch] = createDebouncedSignal("", 150)
|
||||||
|
|
||||||
const [searchResults] = createResource(search, async (query) => {
|
const load = async (search?: string) => {
|
||||||
if (!query) return undefined
|
const result = await sdk.client.session.list({
|
||||||
const result = await sdk.client.session.list({ search: query, limit: 30 })
|
roots: true,
|
||||||
|
...(search ? { search, limit: 30 } : {}),
|
||||||
|
})
|
||||||
return result.data ?? []
|
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 current = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
|
||||||
|
const sessions = createMemo(() => found() ?? listed() ?? sync.data.session)
|
||||||
const sessions = createMemo(() => searchResults() ?? sync.data.session)
|
|
||||||
|
|
||||||
const options = createMemo(() => {
|
const options = createMemo(() => {
|
||||||
const today = new Date().toDateString()
|
const today = new Date().toDateString()
|
||||||
return sessions()
|
return [
|
||||||
.filter((x) => x.parentID === undefined)
|
{
|
||||||
.toSorted((a, b) => b.time.updated - a.time.updated)
|
title: "+ New workspace session",
|
||||||
.map((x) => {
|
value: action,
|
||||||
const date = new Date(x.time.updated)
|
category: "Actions",
|
||||||
let category = date.toDateString()
|
description: "Create a new workspace, then open a session there",
|
||||||
if (category === today) {
|
},
|
||||||
category = "Today"
|
...sessions()
|
||||||
}
|
.filter((item) => item.parentID === undefined)
|
||||||
const isDeleting = toDelete() === x.id
|
.toSorted((a, b) => b.time.updated - a.time.updated)
|
||||||
const status = sync.data.session_status?.[x.id]
|
.map((item) => {
|
||||||
const isWorking = status?.type === "busy"
|
const badge = mark(item.workspaceID)
|
||||||
return {
|
const date = new Date(item.time.updated)
|
||||||
title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title,
|
let category = date.toDateString()
|
||||||
bg: isDeleting ? theme.error : undefined,
|
if (category === today) {
|
||||||
value: x.id,
|
category = "Today"
|
||||||
category,
|
}
|
||||||
footer: Locale.time(x.time.updated),
|
const deleting = toDelete() === item.id
|
||||||
gutter: isWorking ? <Spinner /> : undefined,
|
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 ? <Spinner /> : undefined,
|
||||||
|
margin: badge ? <text fg={badge.fg}>{badge.text}</text> : undefined,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
|
@ -68,12 +117,29 @@ export function DialogSessionList() {
|
||||||
title="Sessions"
|
title="Sessions"
|
||||||
options={options()}
|
options={options()}
|
||||||
skipFilter={true}
|
skipFilter={true}
|
||||||
current={currentSessionID()}
|
current={current()}
|
||||||
onFilter={setSearch}
|
onFilter={setSearch}
|
||||||
onMove={() => {
|
onMove={() => {
|
||||||
setToDelete(undefined)
|
setToDelete(undefined)
|
||||||
}}
|
}}
|
||||||
onSelect={(option) => {
|
onSelect={(option) => {
|
||||||
|
if (option.value === action) {
|
||||||
|
dialog.replace(() => (
|
||||||
|
<DialogWorkspaceCreate
|
||||||
|
onSelect={(workspaceID) =>
|
||||||
|
openWorkspaceSession({
|
||||||
|
dialog,
|
||||||
|
route,
|
||||||
|
sdk,
|
||||||
|
sync,
|
||||||
|
toast,
|
||||||
|
workspaceID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
return
|
||||||
|
}
|
||||||
route.navigate({
|
route.navigate({
|
||||||
type: "session",
|
type: "session",
|
||||||
sessionID: option.value,
|
sessionID: option.value,
|
||||||
|
|
@ -85,10 +151,27 @@ export function DialogSessionList() {
|
||||||
keybind: keybind.all.session_delete?.[0],
|
keybind: keybind.all.session_delete?.[0],
|
||||||
title: "delete",
|
title: "delete",
|
||||||
onTrigger: async (option) => {
|
onTrigger: async (option) => {
|
||||||
|
if (option.value === action) return
|
||||||
if (toDelete() === option.value) {
|
if (toDelete() === option.value) {
|
||||||
sdk.client.session.delete({
|
const deleted = await sdk.client.session
|
||||||
sessionID: option.value,
|
.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)
|
setToDelete(undefined)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -99,6 +182,7 @@ export function DialogSessionList() {
|
||||||
keybind: keybind.all.session_rename?.[0],
|
keybind: keybind.all.session_rename?.[0],
|
||||||
title: "rename",
|
title: "rename",
|
||||||
onTrigger: async (option) => {
|
onTrigger: async (option) => {
|
||||||
|
if (option.value === action) return
|
||||||
dialog.replace(() => <DialogSessionRename session={option.value} />)
|
dialog.replace(() => <DialogSessionRename session={option.value} />)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID: string) {
|
||||||
|
return createOpencodeClient({
|
||||||
|
baseUrl: sdk.url,
|
||||||
|
fetch: sdk.fetch,
|
||||||
|
directory: sync.path.directory || sdk.directory,
|
||||||
|
experimental_workspaceID: workspaceID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openWorkspaceSession(input: {
|
||||||
|
dialog: ReturnType<typeof useDialog>
|
||||||
|
route: ReturnType<typeof useRoute>
|
||||||
|
sdk: ReturnType<typeof useSDK>
|
||||||
|
sync: ReturnType<typeof useSync>
|
||||||
|
toast: ReturnType<typeof useToast>
|
||||||
|
workspaceID: string
|
||||||
|
}) {
|
||||||
|
const client = scoped(input.sdk, input.sync, input.workspaceID)
|
||||||
|
while (true) {
|
||||||
|
const result = await client.session.create({ 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> | void }) {
|
||||||
|
const dialog = useDialog()
|
||||||
|
const sync = useSync()
|
||||||
|
const sdk = useSDK()
|
||||||
|
const toast = useToast()
|
||||||
|
const [creating, setCreating] = createSignal<string>()
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
dialog.setSize("medium")
|
||||||
|
})
|
||||||
|
|
||||||
|
const options = createMemo(() => {
|
||||||
|
const type = creating()
|
||||||
|
if (type) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: `Creating ${type} workspace...`,
|
||||||
|
value: "creating" as const,
|
||||||
|
description: "This can take a while for remote environments",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: "Worktree",
|
||||||
|
value: "worktree" as const,
|
||||||
|
description: "Create a local git worktree",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const 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 (
|
||||||
|
<DialogSelect
|
||||||
|
title={creating() ? "Creating Workspace" : "New Workspace"}
|
||||||
|
skipFilter={true}
|
||||||
|
options={options()}
|
||||||
|
onSelect={(option) => {
|
||||||
|
if (option.value === "creating") return
|
||||||
|
void create(option.value)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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<typeof useSDK>, sync: ReturnType<typeof useSync>, 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<typeof useDialog>
|
|
||||||
route: ReturnType<typeof useRoute>
|
|
||||||
sdk: ReturnType<typeof useSDK>
|
|
||||||
sync: ReturnType<typeof useSync>
|
|
||||||
toast: ReturnType<typeof useToast>
|
|
||||||
workspaceID: string
|
|
||||||
forceCreate?: boolean
|
|
||||||
}) {
|
|
||||||
const cacheSession = (session: Session) => {
|
|
||||||
input.sync.set(
|
|
||||||
"session",
|
|
||||||
[...input.sync.data.session.filter((item) => item.id !== session.id), session].toSorted((a, b) =>
|
|
||||||
a.id.localeCompare(b.id),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = 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<void> }) {
|
|
||||||
const dialog = useDialog()
|
|
||||||
const sync = useSync()
|
|
||||||
const sdk = useSDK()
|
|
||||||
const toast = useToast()
|
|
||||||
const [creating, setCreating] = createSignal<string>()
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
dialog.setSize("medium")
|
|
||||||
})
|
|
||||||
|
|
||||||
const options = createMemo(() => {
|
|
||||||
const type = creating()
|
|
||||||
if (type) {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
title: `Creating ${type} workspace...`,
|
|
||||||
value: "creating" as const,
|
|
||||||
description: "This can take a while for remote environments",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
title: "Worktree",
|
|
||||||
value: "worktree" as const,
|
|
||||||
description: "Create a local git worktree",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
})
|
|
||||||
|
|
||||||
const createWorkspace = async (type: string) => {
|
|
||||||
if (creating()) return
|
|
||||||
setCreating(type)
|
|
||||||
|
|
||||||
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch((err) => {
|
|
||||||
console.log(err)
|
|
||||||
return undefined
|
|
||||||
})
|
|
||||||
console.log(JSON.stringify(result, null, 2))
|
|
||||||
const workspace = result?.data
|
|
||||||
if (!workspace) {
|
|
||||||
setCreating(undefined)
|
|
||||||
toast.show({
|
|
||||||
message: "Failed to create workspace",
|
|
||||||
variant: "error",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await sync.workspace.sync()
|
|
||||||
await props.onSelect(workspace.id)
|
|
||||||
setCreating(undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DialogSelect
|
|
||||||
title={creating() ? "Creating Workspace" : "New Workspace"}
|
|
||||||
skipFilter={true}
|
|
||||||
options={options()}
|
|
||||||
onSelect={(option) => {
|
|
||||||
if (option.value === "creating") return
|
|
||||||
void createWorkspace(option.value)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DialogWorkspaceList() {
|
|
||||||
const dialog = useDialog()
|
|
||||||
const route = useRoute()
|
|
||||||
const sync = useSync()
|
|
||||||
const sdk = useSDK()
|
|
||||||
const toast = useToast()
|
|
||||||
const keybind = useKeybind()
|
|
||||||
const [toDelete, setToDelete] = createSignal<string>()
|
|
||||||
const [counts, setCounts] = createSignal<Record<string, number | null | undefined>>({})
|
|
||||||
|
|
||||||
const open = (workspaceID: string, forceCreate?: boolean) =>
|
|
||||||
openWorkspace({
|
|
||||||
dialog,
|
|
||||||
route,
|
|
||||||
sdk,
|
|
||||||
sync,
|
|
||||||
toast,
|
|
||||||
workspaceID,
|
|
||||||
forceCreate,
|
|
||||||
})
|
|
||||||
|
|
||||||
async function selectWorkspace(workspaceID: string) {
|
|
||||||
if (workspaceID === "__local__") {
|
|
||||||
if (localCount() > 0) {
|
|
||||||
dialog.replace(() => <DialogSessionList localOnly={true} />)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
route.navigate({
|
|
||||||
type: "home",
|
|
||||||
})
|
|
||||||
dialog.clear()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const count = counts()[workspaceID]
|
|
||||||
if (count && count > 0) {
|
|
||||||
dialog.replace(() => <DialogSessionList workspaceID={workspaceID} />)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (count === 0) {
|
|
||||||
await open(workspaceID)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const client = scoped(sdk, sync, workspaceID)
|
|
||||||
const listed = await client.session.list({ roots: true, limit: 1 }).catch(() => undefined)
|
|
||||||
if (listed?.data?.length) {
|
|
||||||
dialog.replace(() => <DialogSessionList workspaceID={workspaceID} />)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await open(workspaceID)
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentWorkspaceID = createMemo(() => {
|
|
||||||
if (route.data.type === "session") {
|
|
||||||
return sync.session.get(route.data.sessionID)?.workspaceID ?? "__local__"
|
|
||||||
}
|
|
||||||
return "__local__"
|
|
||||||
})
|
|
||||||
|
|
||||||
const localCount = createMemo(
|
|
||||||
() => sync.data.session.filter((session) => !session.workspaceID && !session.parentID).length,
|
|
||||||
)
|
|
||||||
|
|
||||||
let run = 0
|
|
||||||
createEffect(() => {
|
|
||||||
const workspaces = sync.data.workspaceList
|
|
||||||
const next = ++run
|
|
||||||
if (!workspaces.length) {
|
|
||||||
setCounts({})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setCounts(Object.fromEntries(workspaces.map((workspace) => [workspace.id, undefined])))
|
|
||||||
void Promise.all(
|
|
||||||
workspaces.map(async (workspace) => {
|
|
||||||
const client = 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 (
|
|
||||||
<DialogSelect
|
|
||||||
title="Workspaces"
|
|
||||||
skipFilter={true}
|
|
||||||
options={options()}
|
|
||||||
current={currentWorkspaceID()}
|
|
||||||
onMove={() => {
|
|
||||||
setToDelete(undefined)
|
|
||||||
}}
|
|
||||||
onSelect={(option) => {
|
|
||||||
setToDelete(undefined)
|
|
||||||
if (option.value === "__create__") {
|
|
||||||
dialog.replace(() => <DialogWorkspaceCreate onSelect={(workspaceID) => open(workspaceID, true)} />)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
void selectWorkspace(option.value)
|
|
||||||
}}
|
|
||||||
keybind={[
|
|
||||||
{
|
|
||||||
keybind: keybind.all.session_delete?.[0],
|
|
||||||
title: "delete",
|
|
||||||
onTrigger: async (option) => {
|
|
||||||
if (option.value === "__create__" || option.value === "__local__") return
|
|
||||||
if (toDelete() !== option.value) {
|
|
||||||
setToDelete(option.value)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const result = await sdk.client.experimental.workspace.remove({ id: option.value }).catch(() => undefined)
|
|
||||||
setToDelete(undefined)
|
|
||||||
if (result?.error) {
|
|
||||||
toast.show({
|
|
||||||
message: "Failed to delete workspace",
|
|
||||||
variant: "error",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (currentWorkspaceID() === option.value) {
|
|
||||||
route.navigate({
|
|
||||||
type: "home",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
await sync.workspace.sync()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -250,7 +250,7 @@ export function Autocomplete(props: {
|
||||||
const width = props.anchor().width - 4
|
const width = props.anchor().width - 4
|
||||||
options.push(
|
options.push(
|
||||||
...sortedFiles.map((item): AutocompleteOption => {
|
...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 fullPath = `${baseDir}/${item}`
|
||||||
const urlObj = pathToFileURL(fullPath)
|
const urlObj = pathToFileURL(fullPath)
|
||||||
let filename = item
|
let filename = item
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { EmptyBorder, SplitBorder } from "@tui/component/border"
|
||||||
import { useSDK } from "@tui/context/sdk"
|
import { useSDK } from "@tui/context/sdk"
|
||||||
import { useRoute } from "@tui/context/route"
|
import { useRoute } from "@tui/context/route"
|
||||||
import { useSync } from "@tui/context/sync"
|
import { useSync } from "@tui/context/sync"
|
||||||
|
import { useEvent } from "@tui/context/event"
|
||||||
import { MessageID, PartID } from "@/session/schema"
|
import { MessageID, PartID } from "@/session/schema"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { useKeybind } from "@tui/context/keybind"
|
import { useKeybind } from "@tui/context/keybind"
|
||||||
|
|
@ -115,8 +116,9 @@ export function Prompt(props: PromptProps) {
|
||||||
const agentStyleId = syntax().getStyleId("extmark.agent")!
|
const agentStyleId = syntax().getStyleId("extmark.agent")!
|
||||||
const pasteStyleId = syntax().getStyleId("extmark.paste")!
|
const pasteStyleId = syntax().getStyleId("extmark.paste")!
|
||||||
let promptPartTypeId = 0
|
let promptPartTypeId = 0
|
||||||
|
const event = useEvent()
|
||||||
|
|
||||||
sdk.event.on(TuiEvent.PromptAppend.type, (evt) => {
|
event.on(TuiEvent.PromptAppend.type, (evt) => {
|
||||||
if (!input || input.isDestroyed) return
|
if (!input || input.isDestroyed) return
|
||||||
input.insertText(evt.properties.text)
|
input.insertText(evt.properties.text)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|
|
||||||
|
|
@ -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<string>()
|
|
||||||
const [search, setSearch] = createDebouncedSignal("", 150)
|
|
||||||
|
|
||||||
const [listed, listedActions] = createResource(
|
|
||||||
() => props.workspaceID,
|
|
||||||
async (workspaceID) => {
|
|
||||||
if (!workspaceID) return undefined
|
|
||||||
const result = await sdk.client.session.list({ roots: true })
|
|
||||||
return result.data ?? []
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const [searchResults] = createResource(search, async (query) => {
|
|
||||||
if (!query || props.localOnly) return undefined
|
|
||||||
const result = await sdk.client.session.list({
|
|
||||||
search: query,
|
|
||||||
limit: 30,
|
|
||||||
...(props.workspaceID ? { roots: true } : {}),
|
|
||||||
})
|
|
||||||
return result.data ?? []
|
|
||||||
})
|
|
||||||
|
|
||||||
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
|
|
||||||
|
|
||||||
const sessions = createMemo(() => {
|
|
||||||
if (searchResults()) return searchResults()!
|
|
||||||
if (props.workspaceID) return listed() ?? []
|
|
||||||
if (props.localOnly) return sync.data.session.filter((session) => !session.workspaceID)
|
|
||||||
return sync.data.session
|
|
||||||
})
|
|
||||||
|
|
||||||
const options = createMemo(() => {
|
|
||||||
const today = new Date().toDateString()
|
|
||||||
return sessions()
|
|
||||||
.filter((x) => {
|
|
||||||
if (x.parentID !== undefined) return false
|
|
||||||
if (props.workspaceID && listed()) return true
|
|
||||||
if (props.workspaceID) return x.workspaceID === props.workspaceID
|
|
||||||
if (props.localOnly) return !x.workspaceID
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
.toSorted((a, b) => b.time.updated - a.time.updated)
|
|
||||||
.map((x) => {
|
|
||||||
const date = new Date(x.time.updated)
|
|
||||||
let category = date.toDateString()
|
|
||||||
if (category === today) {
|
|
||||||
category = "Today"
|
|
||||||
}
|
|
||||||
const isDeleting = toDelete() === x.id
|
|
||||||
const status = sync.data.session_status?.[x.id]
|
|
||||||
const isWorking = status?.type === "busy"
|
|
||||||
return {
|
|
||||||
title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title,
|
|
||||||
bg: isDeleting ? theme.error : undefined,
|
|
||||||
value: x.id,
|
|
||||||
category,
|
|
||||||
footer: Locale.time(x.time.updated),
|
|
||||||
gutter: isWorking ? <Spinner /> : undefined,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
dialog.setSize("large")
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DialogSelect
|
|
||||||
title={props.workspaceID ? `Workspace Sessions` : props.localOnly ? "Local Sessions" : "Sessions"}
|
|
||||||
options={options()}
|
|
||||||
skipFilter={!props.localOnly}
|
|
||||||
current={currentSessionID()}
|
|
||||||
onFilter={setSearch}
|
|
||||||
onMove={() => {
|
|
||||||
setToDelete(undefined)
|
|
||||||
}}
|
|
||||||
onSelect={(option) => {
|
|
||||||
route.navigate({
|
|
||||||
type: "session",
|
|
||||||
sessionID: option.value,
|
|
||||||
})
|
|
||||||
dialog.clear()
|
|
||||||
}}
|
|
||||||
keybind={[
|
|
||||||
{
|
|
||||||
keybind: keybind.all.session_delete?.[0],
|
|
||||||
title: "delete",
|
|
||||||
onTrigger: async (option) => {
|
|
||||||
if (toDelete() === option.value) {
|
|
||||||
const deleted = await sdk.client.session
|
|
||||||
.delete({
|
|
||||||
sessionID: option.value,
|
|
||||||
})
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false)
|
|
||||||
setToDelete(undefined)
|
|
||||||
if (!deleted) {
|
|
||||||
toast.show({
|
|
||||||
message: "Failed to delete session",
|
|
||||||
variant: "error",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (props.workspaceID) {
|
|
||||||
listedActions.mutate((sessions) => sessions?.filter((session) => session.id !== option.value))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sync.set(
|
|
||||||
"session",
|
|
||||||
sync.data.session.filter((session) => session.id !== option.value),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setToDelete(option.value)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
keybind: keybind.all.session_rename?.[0],
|
|
||||||
title: "rename",
|
|
||||||
onTrigger: async (option) => {
|
|
||||||
dialog.replace(() => <DialogSessionRename session={option.value} />)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
import { createMemo } from "solid-js"
|
import { createMemo } from "solid-js"
|
||||||
|
import { useProject } from "./project"
|
||||||
import { useSync } from "./sync"
|
import { useSync } from "./sync"
|
||||||
import { Global } from "@/global"
|
import { Global } from "@/global"
|
||||||
|
|
||||||
export function useDirectory() {
|
export function useDirectory() {
|
||||||
|
const project = useProject()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
return createMemo(() => {
|
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, "~")
|
const result = directory.replace(Global.Path.home, "~")
|
||||||
if (sync.data.vcs?.branch) return result + ":" + sync.data.vcs.branch
|
if (sync.data.vcs?.branch) return result + ":" + sync.data.vcs.branch
|
||||||
return result
|
return result
|
||||||
|
|
|
||||||
41
packages/opencode/src/cli/cmd/tui/context/event.ts
Normal file
41
packages/opencode/src/cli/cmd/tui/context/event.ts
Normal file
|
|
@ -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<T extends Event["type"]>(type: T, handler: (event: Extract<Event, { type: T }>) => void) {
|
||||||
|
return subscribe((event) => {
|
||||||
|
if (event.type !== type) return
|
||||||
|
handler(event as Extract<Event, { type: T }>)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe,
|
||||||
|
on,
|
||||||
|
}
|
||||||
|
}
|
||||||
65
packages/opencode/src/cli/cmd/tui/context/project.tsx
Normal file
65
packages/opencode/src/cli/cmd/tui/context/project.tsx
Normal file
|
|
@ -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,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
@ -5,7 +5,6 @@ import type { PromptInfo } from "../component/prompt/history"
|
||||||
export type HomeRoute = {
|
export type HomeRoute = {
|
||||||
type: "home"
|
type: "home"
|
||||||
initialPrompt?: PromptInfo
|
initialPrompt?: PromptInfo
|
||||||
workspaceID?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionRoute = {
|
export type SessionRoute = {
|
||||||
|
|
|
||||||
|
|
@ -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 { createSimpleContext } from "./helper"
|
||||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||||
import { batch, onCleanup, onMount } from "solid-js"
|
import { batch, onCleanup, onMount } from "solid-js"
|
||||||
|
|
||||||
export type EventSource = {
|
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({
|
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||||
|
|
@ -32,10 +33,10 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||||
let sdk = createSDK()
|
let sdk = createSDK()
|
||||||
|
|
||||||
const emitter = createGlobalEmitter<{
|
const emitter = createGlobalEmitter<{
|
||||||
[key in Event["type"]]: Extract<Event, { type: key }>
|
event: GlobalEvent
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
let queue: Event[] = []
|
let queue: GlobalEvent[] = []
|
||||||
let timer: Timer | undefined
|
let timer: Timer | undefined
|
||||||
let last = 0
|
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 all event emissions so all store updates result in a single render
|
||||||
batch(() => {
|
batch(() => {
|
||||||
for (const event of events) {
|
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)
|
queue.push(event)
|
||||||
const elapsed = Date.now() - last
|
const elapsed = Date.now() - last
|
||||||
|
|
||||||
|
|
@ -74,7 +75,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||||
;(async () => {
|
;(async () => {
|
||||||
while (true) {
|
while (true) {
|
||||||
if (abort.signal.aborted || ctrl.signal.aborted) break
|
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) {
|
for await (const event of events.stream) {
|
||||||
if (ctrl.signal.aborted) break
|
if (ctrl.signal.aborted) break
|
||||||
|
|
@ -89,7 +90,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if (props.events) {
|
if (props.events) {
|
||||||
const unsub = await props.events.subscribe(props.directory, handleEvent)
|
const unsub = await props.events.subscribe(handleEvent)
|
||||||
onCleanup(unsub)
|
onCleanup(unsub)
|
||||||
} else {
|
} else {
|
||||||
startSSE()
|
startSSE()
|
||||||
|
|
|
||||||
|
|
@ -17,18 +17,19 @@ import type {
|
||||||
ProviderListResponse,
|
ProviderListResponse,
|
||||||
ProviderAuthMethod,
|
ProviderAuthMethod,
|
||||||
VcsInfo,
|
VcsInfo,
|
||||||
|
Workspace,
|
||||||
} from "@opencode-ai/sdk/v2"
|
} from "@opencode-ai/sdk/v2"
|
||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
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 { useSDK } from "@tui/context/sdk"
|
||||||
import { Binary } from "@opencode-ai/util/binary"
|
import { Binary } from "@opencode-ai/util/binary"
|
||||||
import { createSimpleContext } from "./helper"
|
import { createSimpleContext } from "./helper"
|
||||||
import type { Snapshot } from "@/snapshot"
|
import type { Snapshot } from "@/snapshot"
|
||||||
import { useExit } from "./exit"
|
import { useExit } from "./exit"
|
||||||
import { useArgs } from "./args"
|
import { useArgs } from "./args"
|
||||||
import { batch, onMount } from "solid-js"
|
import { batch, createEffect, on } from "solid-js"
|
||||||
import { Log } from "@/util/log"
|
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"
|
import { ConsoleState, emptyConsoleState, type ConsoleState as ConsoleStateType } from "@/config/console-state"
|
||||||
|
|
||||||
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||||
|
|
@ -74,9 +75,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||||
[key: string]: McpResource
|
[key: string]: McpResource
|
||||||
}
|
}
|
||||||
formatter: FormatterStatus[]
|
formatter: FormatterStatus[]
|
||||||
vcs: VcsInfo | undefined
|
|
||||||
path: Path
|
|
||||||
workspaceList: Workspace[]
|
workspaceList: Workspace[]
|
||||||
|
vcs: VcsInfo | undefined
|
||||||
}>({
|
}>({
|
||||||
provider_next: {
|
provider_next: {
|
||||||
all: [],
|
all: [],
|
||||||
|
|
@ -103,21 +103,25 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||||
mcp: {},
|
mcp: {},
|
||||||
mcp_resource: {},
|
mcp_resource: {},
|
||||||
formatter: [],
|
formatter: [],
|
||||||
vcs: undefined,
|
|
||||||
path: { state: "", config: "", worktree: "", directory: "" },
|
|
||||||
workspaceList: [],
|
workspaceList: [],
|
||||||
|
vcs: undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const event = useEvent()
|
||||||
|
const project = useProject()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
|
|
||||||
async function syncWorkspaces() {
|
async function syncWorkspaces() {
|
||||||
|
const workspace = project.workspace.current()
|
||||||
const result = await sdk.client.experimental.workspace.list().catch(() => undefined)
|
const result = await sdk.client.experimental.workspace.list().catch(() => undefined)
|
||||||
if (!result?.data) return
|
if (!result?.data) return
|
||||||
setStore("workspaceList", reconcile(result.data))
|
setStore("workspaceList", reconcile(result.data))
|
||||||
|
if (!result.data.some((item) => item.id === workspace)) {
|
||||||
|
project.workspace.set(undefined)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sdk.event.listen((e) => {
|
event.subscribe((event) => {
|
||||||
const event = e.details
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "server.instance.disposed":
|
case "server.instance.disposed":
|
||||||
bootstrap()
|
bootstrap()
|
||||||
|
|
@ -344,7 +348,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||||
}
|
}
|
||||||
|
|
||||||
case "lsp.updated": {
|
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
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -360,25 +365,28 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
console.log("bootstrapping")
|
console.log("bootstrapping")
|
||||||
|
const workspace = project.workspace.current()
|
||||||
const start = Date.now() - 30 * 24 * 60 * 60 * 1000
|
const start = Date.now() - 30 * 24 * 60 * 60 * 1000
|
||||||
const sessionListPromise = sdk.client.session
|
const sessionListPromise = sdk.client.session
|
||||||
.list({ start: start })
|
.list({ start: start, workspace })
|
||||||
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
|
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
|
||||||
|
|
||||||
// blocking - include session.list when continuing a session
|
// blocking - include session.list when continuing a session
|
||||||
const providersPromise = sdk.client.config.providers({}, { throwOnError: true })
|
const providersPromise = sdk.client.config.providers({ workspace }, { throwOnError: true })
|
||||||
const providerListPromise = sdk.client.provider.list({}, { throwOnError: true })
|
const providerListPromise = sdk.client.provider.list({ workspace }, { throwOnError: true })
|
||||||
const consoleStatePromise = sdk.client.experimental.console
|
const consoleStatePromise = sdk.client.experimental.console
|
||||||
.get({}, { throwOnError: true })
|
.get({ workspace }, { throwOnError: true })
|
||||||
.then((x) => ConsoleState.parse(x.data))
|
.then((x) => ConsoleState.parse(x.data))
|
||||||
.catch(() => emptyConsoleState)
|
.catch(() => emptyConsoleState)
|
||||||
const agentsPromise = sdk.client.app.agents({}, { throwOnError: true })
|
const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
|
||||||
const configPromise = sdk.client.config.get({}, { throwOnError: true })
|
const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
|
||||||
|
const projectPromise = project.sync()
|
||||||
const blockingRequests: Promise<unknown>[] = [
|
const blockingRequests: Promise<unknown>[] = [
|
||||||
providersPromise,
|
providersPromise,
|
||||||
providerListPromise,
|
providerListPromise,
|
||||||
agentsPromise,
|
agentsPromise,
|
||||||
configPromise,
|
configPromise,
|
||||||
|
projectPromise,
|
||||||
...(args.continue ? [sessionListPromise] : []),
|
...(args.continue ? [sessionListPromise] : []),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -423,17 +431,18 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||||
Promise.all([
|
Promise.all([
|
||||||
...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]),
|
...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]),
|
||||||
consoleStatePromise.then((consoleState) => setStore("console_state", reconcile(consoleState))),
|
consoleStatePromise.then((consoleState) => setStore("console_state", reconcile(consoleState))),
|
||||||
sdk.client.command.list().then((x) => setStore("command", reconcile(x.data ?? []))),
|
sdk.client.command.list({ workspace }).then((x) => setStore("command", reconcile(x.data ?? []))),
|
||||||
sdk.client.lsp.status().then((x) => setStore("lsp", reconcile(x.data!))),
|
sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", reconcile(x.data!))),
|
||||||
sdk.client.mcp.status().then((x) => setStore("mcp", reconcile(x.data!))),
|
sdk.client.mcp.status({ workspace }).then((x) => setStore("mcp", reconcile(x.data!))),
|
||||||
sdk.client.experimental.resource.list().then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))),
|
sdk.client.experimental.resource
|
||||||
sdk.client.formatter.status().then((x) => setStore("formatter", reconcile(x.data!))),
|
.list({ workspace })
|
||||||
sdk.client.session.status().then((x) => {
|
.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!))
|
setStore("session_status", reconcile(x.data!))
|
||||||
}),
|
}),
|
||||||
sdk.client.provider.auth().then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
|
sdk.client.provider.auth({ workspace }).then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
|
||||||
sdk.client.vcs.get().then((x) => setStore("vcs", reconcile(x.data))),
|
sdk.client.vcs.get({ workspace }).then((x) => setStore("vcs", reconcile(x.data))),
|
||||||
sdk.client.path.get().then((x) => setStore("path", reconcile(x.data!))),
|
|
||||||
syncWorkspaces(),
|
syncWorkspaces(),
|
||||||
]).then(() => {
|
]).then(() => {
|
||||||
setStore("status", "complete")
|
setStore("status", "complete")
|
||||||
|
|
@ -449,11 +458,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
bootstrap()
|
|
||||||
})
|
|
||||||
|
|
||||||
const fullSyncedSessions = new Set<string>()
|
const fullSyncedSessions = new Set<string>()
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => project.workspace.current(),
|
||||||
|
() => {
|
||||||
|
fullSyncedSessions.clear()
|
||||||
|
void bootstrap()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
data: store,
|
data: store,
|
||||||
set: setStore,
|
set: setStore,
|
||||||
|
|
@ -463,6 +478,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||||
get ready() {
|
get ready() {
|
||||||
return store.status !== "loading"
|
return store.status !== "loading"
|
||||||
},
|
},
|
||||||
|
get path() {
|
||||||
|
return project.instance.path()
|
||||||
|
},
|
||||||
session: {
|
session: {
|
||||||
get(sessionID: string) {
|
get(sessionID: string) {
|
||||||
const match = Binary.search(store.session, sessionID, (s) => s.id)
|
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) {
|
async sync(sessionID: string) {
|
||||||
if (fullSyncedSessions.has(sessionID)) return
|
if (fullSyncedSessions.has(sessionID)) return
|
||||||
|
const workspace = project.workspace.current()
|
||||||
const [session, messages, todo, diff] = await Promise.all([
|
const [session, messages, todo, diff] = await Promise.all([
|
||||||
sdk.client.session.get({ sessionID }, { throwOnError: true }),
|
sdk.client.session.get({ sessionID, workspace }, { throwOnError: true }),
|
||||||
sdk.client.session.messages({ sessionID, limit: 100 }),
|
sdk.client.session.messages({ sessionID, limit: 100, workspace }),
|
||||||
sdk.client.session.todo({ sessionID }),
|
sdk.client.session.todo({ sessionID, workspace }),
|
||||||
sdk.client.session.diff({ sessionID }),
|
sdk.client.session.diff({ sessionID, workspace }),
|
||||||
])
|
])
|
||||||
setStore(
|
setStore(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
|
|
@ -504,8 +523,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
workspace: {
|
workspace: {
|
||||||
|
list() {
|
||||||
|
return store.workspaceList
|
||||||
|
},
|
||||||
get(workspaceID: string) {
|
get(workspaceID: string) {
|
||||||
return store.workspaceList.find((workspace) => workspace.id === workspaceID)
|
return store.workspaceList.find((item) => item.id === workspaceID)
|
||||||
},
|
},
|
||||||
sync: syncWorkspaces,
|
sync: syncWorkspaces,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { ParsedKey } from "@opentui/core"
|
import type { ParsedKey } from "@opentui/core"
|
||||||
import type { TuiDialogSelectOption, TuiPluginApi, TuiRouteDefinition, TuiSlotProps } from "@opencode-ai/plugin/tui"
|
import type { TuiDialogSelectOption, TuiPluginApi, TuiRouteDefinition, TuiSlotProps } from "@opencode-ai/plugin/tui"
|
||||||
import type { useCommandDialog } from "@tui/component/dialog-command"
|
import type { useCommandDialog } from "@tui/component/dialog-command"
|
||||||
|
import type { useEvent } from "@tui/context/event"
|
||||||
import type { useKeybind } from "@tui/context/keybind"
|
import type { useKeybind } from "@tui/context/keybind"
|
||||||
import type { useRoute } from "@tui/context/route"
|
import type { useRoute } from "@tui/context/route"
|
||||||
import type { useSDK } from "@tui/context/sdk"
|
import type { useSDK } from "@tui/context/sdk"
|
||||||
|
|
@ -36,6 +37,7 @@ type Input = {
|
||||||
route: ReturnType<typeof useRoute>
|
route: ReturnType<typeof useRoute>
|
||||||
routes: RouteMap
|
routes: RouteMap
|
||||||
bump: () => void
|
bump: () => void
|
||||||
|
event: ReturnType<typeof useEvent>
|
||||||
sdk: ReturnType<typeof useSDK>
|
sdk: ReturnType<typeof useSDK>
|
||||||
sync: ReturnType<typeof useSync>
|
sync: ReturnType<typeof useSync>
|
||||||
theme: ReturnType<typeof useTheme>
|
theme: ReturnType<typeof useTheme>
|
||||||
|
|
@ -136,7 +138,7 @@ function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
|
||||||
return sync.data.provider
|
return sync.data.provider
|
||||||
},
|
},
|
||||||
get path() {
|
get path() {
|
||||||
return sync.data.path
|
return sync.path
|
||||||
},
|
},
|
||||||
get vcs() {
|
get vcs() {
|
||||||
if (!sync.data.vcs) return
|
if (!sync.data.vcs) return
|
||||||
|
|
@ -342,7 +344,7 @@ export function createTuiApi(input: Input): TuiPluginApi {
|
||||||
get client() {
|
get client() {
|
||||||
return input.sdk.client
|
return input.sdk.client
|
||||||
},
|
},
|
||||||
event: input.sdk.event,
|
event: input.event,
|
||||||
renderer: input.renderer,
|
renderer: input.renderer,
|
||||||
slots: {
|
slots: {
|
||||||
register() {
|
register() {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Prompt, type PromptRef } from "@tui/component/prompt"
|
import { Prompt, type PromptRef } from "@tui/component/prompt"
|
||||||
import { createEffect, createSignal } from "solid-js"
|
import { createEffect, createSignal } from "solid-js"
|
||||||
import { Logo } from "../component/logo"
|
import { Logo } from "../component/logo"
|
||||||
|
import { useProject } from "../context/project"
|
||||||
import { useSync } from "../context/sync"
|
import { useSync } from "../context/sync"
|
||||||
import { Toast } from "../ui/toast"
|
import { Toast } from "../ui/toast"
|
||||||
import { useArgs } from "../context/args"
|
import { useArgs } from "../context/args"
|
||||||
|
|
@ -18,6 +19,7 @@ const placeholder = {
|
||||||
|
|
||||||
export function Home() {
|
export function Home() {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
|
const project = useProject()
|
||||||
const route = useRouteData("home")
|
const route = useRouteData("home")
|
||||||
const promptRef = usePromptRef()
|
const promptRef = usePromptRef()
|
||||||
const [ref, setRef] = createSignal<PromptRef | undefined>()
|
const [ref, setRef] = createSignal<PromptRef | undefined>()
|
||||||
|
|
@ -63,11 +65,16 @@ export function Home() {
|
||||||
</box>
|
</box>
|
||||||
<box height={1} minHeight={0} flexShrink={1} />
|
<box height={1} minHeight={0} flexShrink={1} />
|
||||||
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
|
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
|
||||||
<TuiPluginRuntime.Slot name="home_prompt" mode="replace" workspace_id={route.workspaceID} ref={bind}>
|
<TuiPluginRuntime.Slot
|
||||||
|
name="home_prompt"
|
||||||
|
mode="replace"
|
||||||
|
workspace_id={project.workspace.current()}
|
||||||
|
ref={bind}
|
||||||
|
>
|
||||||
<Prompt
|
<Prompt
|
||||||
ref={bind}
|
ref={bind}
|
||||||
workspaceID={route.workspaceID}
|
workspaceID={project.workspace.current()}
|
||||||
right={<TuiPluginRuntime.Slot name="home_prompt_right" workspace_id={route.workspaceID} />}
|
right={<TuiPluginRuntime.Slot name="home_prompt_right" workspace_id={project.workspace.current()} />}
|
||||||
placeholders={placeholder}
|
placeholders={placeholder}
|
||||||
/>
|
/>
|
||||||
</TuiPluginRuntime.Slot>
|
</TuiPluginRuntime.Slot>
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,9 @@ import {
|
||||||
import { Dynamic } from "solid-js/web"
|
import { Dynamic } from "solid-js/web"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { useRoute, useRouteData } from "@tui/context/route"
|
import { useRoute, useRouteData } from "@tui/context/route"
|
||||||
|
import { useProject } from "@tui/context/project"
|
||||||
import { useSync } from "@tui/context/sync"
|
import { useSync } from "@tui/context/sync"
|
||||||
|
import { useEvent } from "@tui/context/event"
|
||||||
import { SplitBorder } from "@tui/component/border"
|
import { SplitBorder } from "@tui/component/border"
|
||||||
import { Spinner } from "@tui/component/spinner"
|
import { Spinner } from "@tui/component/spinner"
|
||||||
import { selectedForeground, useTheme } from "@tui/context/theme"
|
import { selectedForeground, useTheme } from "@tui/context/theme"
|
||||||
|
|
@ -116,6 +118,8 @@ export function Session() {
|
||||||
const route = useRouteData("session")
|
const route = useRouteData("session")
|
||||||
const { navigate } = useRoute()
|
const { navigate } = useRoute()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
|
const event = useEvent()
|
||||||
|
const project = useProject()
|
||||||
const tuiConfig = useTuiConfig()
|
const tuiConfig = useTuiConfig()
|
||||||
const kv = useKV()
|
const kv = useKV()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
|
|
@ -172,10 +176,16 @@ export function Session() {
|
||||||
const providers = createMemo(() => Model.index(sync.data.provider))
|
const providers = createMemo(() => Model.index(sync.data.provider))
|
||||||
|
|
||||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
|
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
|
||||||
|
const toast = useToast()
|
||||||
|
const sdk = useSDK()
|
||||||
|
|
||||||
createEffect(async () => {
|
createEffect(async () => {
|
||||||
await sync.session
|
await sdk.client.session
|
||||||
.sync(route.sessionID)
|
.get({ sessionID: route.sessionID }, { throwOnError: true })
|
||||||
|
.then((x) => {
|
||||||
|
project.workspace.set(x.data?.workspaceID)
|
||||||
|
})
|
||||||
|
.then(() => sync.session.sync(route.sessionID))
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (scroll) scroll.scrollBy(100_000)
|
if (scroll) scroll.scrollBy(100_000)
|
||||||
})
|
})
|
||||||
|
|
@ -189,13 +199,10 @@ export function Session() {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const toast = useToast()
|
|
||||||
const sdk = useSDK()
|
|
||||||
|
|
||||||
// Handle initial prompt from fork
|
// Handle initial prompt from fork
|
||||||
let seeded = false
|
let seeded = false
|
||||||
let lastSwitch: string | undefined = undefined
|
let lastSwitch: string | undefined = undefined
|
||||||
sdk.event.on("message.part.updated", (evt) => {
|
event.on("message.part.updated", (evt) => {
|
||||||
const part = evt.properties.part
|
const part = evt.properties.part
|
||||||
if (part.type !== "tool") return
|
if (part.type !== "tool") return
|
||||||
if (part.sessionID !== route.sessionID) return
|
if (part.sessionID !== route.sessionID) return
|
||||||
|
|
@ -224,7 +231,7 @@ export function Session() {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
|
|
||||||
sdk.event.on("session.status", (evt) => {
|
event.on("session.status", (evt) => {
|
||||||
if (evt.properties.sessionID !== route.sessionID) return
|
if (evt.properties.sessionID !== route.sessionID) return
|
||||||
if (evt.properties.status.type !== "retry") return
|
if (evt.properties.status.type !== "retry") return
|
||||||
if (evt.properties.status.message !== SessionRetry.GO_UPSELL_MESSAGE) return
|
if (evt.properties.status.message !== SessionRetry.GO_UPSELL_MESSAGE) return
|
||||||
|
|
@ -1791,7 +1798,7 @@ function Bash(props: ToolProps<typeof BashTool>) {
|
||||||
const workdir = props.input.workdir
|
const workdir = props.input.workdir
|
||||||
if (!workdir || workdir === ".") return undefined
|
if (!workdir || workdir === ".") return undefined
|
||||||
|
|
||||||
const base = sync.data.path.directory
|
const base = sync.path.directory
|
||||||
if (!base) return undefined
|
if (!base) return undefined
|
||||||
|
|
||||||
const absolute = path.resolve(base, workdir)
|
const absolute = path.resolve(base, workdir)
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { errorMessage } from "@/util/error"
|
||||||
import { withTimeout } from "@/util/timeout"
|
import { withTimeout } from "@/util/timeout"
|
||||||
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
|
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
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 type { EventSource } from "./context/sdk"
|
||||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||||
import { TuiConfig } from "@/config/tui"
|
import { TuiConfig } from "@/config/tui"
|
||||||
|
|
@ -43,18 +43,10 @@ function createWorkerFetch(client: RpcClient): typeof fetch {
|
||||||
|
|
||||||
function createEventSource(client: RpcClient): EventSource {
|
function createEventSource(client: RpcClient): EventSource {
|
||||||
return {
|
return {
|
||||||
subscribe: async (directory, handler) => {
|
subscribe: async (handler) => {
|
||||||
const id = await client.call("subscribe", { directory })
|
return client.on<GlobalEvent>("global.event", (e) => {
|
||||||
const unsub = client.on<{ id: string; event: Event }>("event", (e) => {
|
handler(e)
|
||||||
if (e.id === id) {
|
|
||||||
handler(e.event)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => {
|
|
||||||
unsub()
|
|
||||||
client.call("unsubscribe", { id })
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ export interface DialogSelectOption<T = any> {
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
bg?: RGBA
|
bg?: RGBA
|
||||||
gutter?: JSX.Element
|
gutter?: JSX.Element
|
||||||
|
margin?: JSX.Element
|
||||||
onSelect?: (ctx: DialogContext) => void
|
onSelect?: (ctx: DialogContext) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -312,6 +313,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||||
<box
|
<box
|
||||||
id={JSON.stringify(option.value)}
|
id={JSON.stringify(option.value)}
|
||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
|
position="relative"
|
||||||
onMouseMove={() => {
|
onMouseMove={() => {
|
||||||
setStore("input", "mouse")
|
setStore("input", "mouse")
|
||||||
}}
|
}}
|
||||||
|
|
@ -335,6 +337,11 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||||
paddingRight={3}
|
paddingRight={3}
|
||||||
gap={1}
|
gap={1}
|
||||||
>
|
>
|
||||||
|
<Show when={!current() && option.margin}>
|
||||||
|
<box position="absolute" left={1} flexShrink={0}>
|
||||||
|
{option.margin}
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
<Option
|
<Option
|
||||||
title={option.title}
|
title={option.title}
|
||||||
footer={flatten() ? (option.category ?? option.footer) : option.footer}
|
footer={flatten() ? (option.category ?? option.footer) : option.footer}
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,10 @@ import { InstanceBootstrap } from "@/project/bootstrap"
|
||||||
import { Rpc } from "@/util/rpc"
|
import { Rpc } from "@/util/rpc"
|
||||||
import { upgrade } from "@/cli/upgrade"
|
import { upgrade } from "@/cli/upgrade"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { Bus } from "@/bus"
|
|
||||||
import { GlobalBus } from "@/bus/global"
|
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 { Flag } from "@/flag/flag"
|
||||||
import { setTimeout as sleep } from "node:timers/promises"
|
|
||||||
import { writeHeapSnapshot } from "node:v8"
|
import { writeHeapSnapshot } from "node:v8"
|
||||||
import { WorkspaceID } from "@/control-plane/schema"
|
|
||||||
import { Heap } from "@/cli/heap"
|
import { Heap } from "@/cli/heap"
|
||||||
|
|
||||||
await Log.init({
|
await Log.init({
|
||||||
|
|
@ -45,87 +42,6 @@ GlobalBus.on("event", (event) => {
|
||||||
|
|
||||||
let server: Awaited<ReturnType<typeof Server.listen>> | undefined
|
let server: Awaited<ReturnType<typeof Server.listen>> | undefined
|
||||||
|
|
||||||
const eventStreams = new Map<string, AbortController>()
|
|
||||||
|
|
||||||
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<boolean>((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 = {
|
export const rpc = {
|
||||||
async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) {
|
async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) {
|
||||||
const headers = { ...input.headers }
|
const headers = { ...input.headers }
|
||||||
|
|
@ -167,19 +83,9 @@ export const rpc = {
|
||||||
async reload() {
|
async reload() {
|
||||||
await Config.invalidate(true)
|
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() {
|
async shutdown() {
|
||||||
Log.Default.info("worker shutting down")
|
Log.Default.info("worker shutting down")
|
||||||
|
|
||||||
for (const id of [...eventStreams.keys()]) {
|
|
||||||
stopEventStream(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
await Instance.disposeAll()
|
await Instance.disposeAll()
|
||||||
if (server) await server.stop(true)
|
if (server) await server.stop(true)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
22
packages/opencode/src/control-plane/workspace-context.ts
Normal file
22
packages/opencode/src/control-plane/workspace-context.ts
Normal file
|
|
@ -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<WorkspaceContext>("instance")
|
||||||
|
|
||||||
|
export const WorkspaceContext = {
|
||||||
|
async provide<R>(input: { workspaceID: WorkspaceID; fn: () => R }): Promise<R> {
|
||||||
|
return context.provide({ workspaceID: input.workspaceID as string }, () => input.fn())
|
||||||
|
},
|
||||||
|
|
||||||
|
get workspaceID() {
|
||||||
|
try {
|
||||||
|
return context.use().workspaceID
|
||||||
|
} catch (err) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -134,12 +134,12 @@ export namespace Workspace {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
await parseSSE(res.body, stop, (event) => {
|
// await parseSSE(res.body, stop, (event) => {
|
||||||
GlobalBus.emit("event", {
|
// GlobalBus.emit("event", {
|
||||||
directory: space.id,
|
// directory: space.id,
|
||||||
payload: event,
|
// payload: event,
|
||||||
})
|
// })
|
||||||
})
|
// })
|
||||||
|
|
||||||
// Wait 250ms and retry if SSE connection fails
|
// Wait 250ms and retry if SSE connection fails
|
||||||
await sleep(250)
|
await sleep(250)
|
||||||
|
|
|
||||||
|
|
@ -4,3 +4,7 @@ import type { InstanceContext } from "@/project/instance"
|
||||||
export const InstanceRef = ServiceMap.Reference<InstanceContext | undefined>("~opencode/InstanceRef", {
|
export const InstanceRef = ServiceMap.Reference<InstanceContext | undefined>("~opencode/InstanceRef", {
|
||||||
defaultValue: () => undefined,
|
defaultValue: () => undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const WorkspaceRef = ServiceMap.Reference<string | undefined>("~opencode/WorkspaceRef", {
|
||||||
|
defaultValue: () => undefined,
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { Effect, Fiber, ScopedCache, Scope, ServiceMap } from "effect"
|
import { Effect, Fiber, ScopedCache, Scope, ServiceMap } from "effect"
|
||||||
import { Instance, type InstanceContext } from "@/project/instance"
|
import { Instance, type InstanceContext } from "@/project/instance"
|
||||||
import { Context } from "@/util/context"
|
import { Context } from "@/util/context"
|
||||||
import { InstanceRef } from "./instance-ref"
|
import { InstanceRef, WorkspaceRef } from "./instance-ref"
|
||||||
import { registerDisposer } from "./instance-registry"
|
import { registerDisposer } from "./instance-registry"
|
||||||
|
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||||
|
|
||||||
const TypeId = "~opencode/InstanceState"
|
const TypeId = "~opencode/InstanceState"
|
||||||
|
|
||||||
|
|
@ -28,6 +29,10 @@ export namespace InstanceState {
|
||||||
return (yield* InstanceRef) ?? Instance.current
|
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 directory = Effect.map(context, (ctx) => ctx.directory)
|
||||||
|
|
||||||
export const make = <A, E = never, R = never>(
|
export const make = <A, E = never, R = never>(
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,17 @@ import { Effect, Layer, ManagedRuntime } from "effect"
|
||||||
import * as ServiceMap from "effect/ServiceMap"
|
import * as ServiceMap from "effect/ServiceMap"
|
||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/instance"
|
||||||
import { Context } from "@/util/context"
|
import { Context } from "@/util/context"
|
||||||
import { InstanceRef } from "./instance-ref"
|
import { InstanceRef, WorkspaceRef } from "./instance-ref"
|
||||||
import { Observability } from "./oltp"
|
import { Observability } from "./oltp"
|
||||||
|
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||||
|
|
||||||
export const memoMap = Layer.makeMemoMapUnsafe()
|
export const memoMap = Layer.makeMemoMapUnsafe()
|
||||||
|
|
||||||
function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> {
|
function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> {
|
||||||
try {
|
try {
|
||||||
const ctx = Instance.current
|
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) {
|
} catch (err) {
|
||||||
if (!(err instanceof Context.NotFound)) throw err
|
if (!(err instanceof Context.NotFound)) throw err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { iife } from "@/util/iife"
|
||||||
import { Log } from "@/util/log"
|
import { Log } from "@/util/log"
|
||||||
import { Context } from "../util/context"
|
import { Context } from "../util/context"
|
||||||
import { Project } from "./project"
|
import { Project } from "./project"
|
||||||
|
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||||
import { State } from "./state"
|
import { State } from "./state"
|
||||||
|
|
||||||
export interface InstanceContext {
|
export interface InstanceContext {
|
||||||
|
|
@ -20,19 +21,9 @@ const disposal = {
|
||||||
all: undefined as Promise<void> | undefined,
|
all: undefined as Promise<void> | undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
function emit(directory: string) {
|
function emitDisposed(directory: string) {}
|
||||||
GlobalBus.emit("event", {
|
|
||||||
directory,
|
|
||||||
payload: {
|
|
||||||
type: "server.instance.disposed",
|
|
||||||
properties: {
|
|
||||||
directory,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function boot(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
|
function boot(input: { directory: string; init?: () => Promise<any>; worktree?: string; project?: Project.Info }) {
|
||||||
return iife(async () => {
|
return iife(async () => {
|
||||||
const ctx =
|
const ctx =
|
||||||
input.project && input.worktree
|
input.project && input.worktree
|
||||||
|
|
@ -93,6 +84,7 @@ export const Instance = {
|
||||||
get project() {
|
get project() {
|
||||||
return context.use().project
|
return context.use().project
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a path is within the project boundary.
|
* Check if a path is within the project boundary.
|
||||||
* Returns true if path is inside Instance.directory OR Instance.worktree.
|
* 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)])
|
await Promise.all([State.dispose(directory), disposeInstance(directory)])
|
||||||
cache.delete(directory)
|
cache.delete(directory)
|
||||||
const next = track(directory, boot({ ...input, 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
|
return await next
|
||||||
},
|
},
|
||||||
async dispose() {
|
async dispose() {
|
||||||
const directory = Instance.directory
|
const directory = Instance.directory
|
||||||
|
const project = Instance.project
|
||||||
Log.Default.info("disposing instance", { directory })
|
Log.Default.info("disposing instance", { directory })
|
||||||
await Promise.all([State.dispose(directory), disposeInstance(directory)])
|
await Promise.all([State.dispose(directory), disposeInstance(directory)])
|
||||||
cache.delete(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() {
|
async disposeAll() {
|
||||||
if (disposal.all) return disposal.all
|
if (disposal.all) return disposal.all
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,8 @@ export namespace Project {
|
||||||
const emitUpdated = (data: Info) =>
|
const emitUpdated = (data: Info) =>
|
||||||
Effect.sync(() =>
|
Effect.sync(() =>
|
||||||
GlobalBus.emit("event", {
|
GlobalBus.emit("event", {
|
||||||
|
directory: "global",
|
||||||
|
project: data.id,
|
||||||
payload: { type: Event.Updated.type, properties: data },
|
payload: { type: Event.Updated.type, properties: data },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@ import { Filesystem } from "@/util/filesystem"
|
||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/instance"
|
||||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||||
import { InstanceRoutes } from "./instance"
|
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" }
|
type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" }
|
||||||
|
|
||||||
|
|
@ -26,6 +29,16 @@ function local(method: string, path: string) {
|
||||||
return false
|
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 {
|
export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): MiddlewareHandler {
|
||||||
const routes = lazy(() => InstanceRoutes(upgrade))
|
const routes = lazy(() => InstanceRoutes(upgrade))
|
||||||
|
|
||||||
|
|
@ -42,13 +55,12 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
||||||
)
|
)
|
||||||
|
|
||||||
const url = new URL(c.req.url)
|
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
|
const sessionWorkspaceID = await getSessionWorkspace(url)
|
||||||
// project/workspace
|
const workspaceID = sessionWorkspaceID || url.searchParams.get("workspace")
|
||||||
|
|
||||||
// If no workspace is provided we use the "project" workspace
|
// If no workspace is provided we use the project
|
||||||
if (!workspaceParam) {
|
if (!workspaceID) {
|
||||||
return Instance.provide({
|
return Instance.provide({
|
||||||
directory,
|
directory,
|
||||||
init: InstanceBootstrap,
|
init: InstanceBootstrap,
|
||||||
|
|
@ -58,8 +70,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspaceID = WorkspaceID.make(workspaceParam)
|
const workspace = await Workspace.get(WorkspaceID.make(workspaceID))
|
||||||
const workspace = await Workspace.get(workspaceID)
|
|
||||||
if (!workspace) {
|
if (!workspace) {
|
||||||
return new Response(`Workspace not found: ${workspaceID}`, {
|
return new Response(`Workspace not found: ${workspaceID}`, {
|
||||||
status: 500,
|
status: 500,
|
||||||
|
|
@ -73,12 +84,16 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
||||||
const target = await adaptor.target(workspace)
|
const target = await adaptor.target(workspace)
|
||||||
|
|
||||||
if (target.type === "local") {
|
if (target.type === "local") {
|
||||||
return Instance.provide({
|
return WorkspaceContext.provide({
|
||||||
directory: target.directory,
|
workspaceID: WorkspaceID.make(workspaceID),
|
||||||
init: InstanceBootstrap,
|
fn: () =>
|
||||||
async fn() {
|
Instance.provide({
|
||||||
return routes().fetch(c.req.raw, c.env)
|
directory: target.directory,
|
||||||
},
|
init: InstanceBootstrap,
|
||||||
|
async fn() {
|
||||||
|
return routes().fetch(c.req.raw, c.env)
|
||||||
|
},
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,8 @@ export const GlobalRoutes = lazy(() =>
|
||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
directory: z.string(),
|
directory: z.string(),
|
||||||
|
project: z.string().optional(),
|
||||||
|
workspace: z.string().optional(),
|
||||||
payload: BusEvent.payloads(),
|
payload: BusEvent.payloads(),
|
||||||
})
|
})
|
||||||
.meta({
|
.meta({
|
||||||
|
|
|
||||||
|
|
@ -245,6 +245,7 @@ export namespace Worktree {
|
||||||
|
|
||||||
const boot = Effect.fnUntraced(function* (info: Info, startCommand?: string) {
|
const boot = Effect.fnUntraced(function* (info: Info, startCommand?: string) {
|
||||||
const ctx = yield* InstanceState.context
|
const ctx = yield* InstanceState.context
|
||||||
|
const workspaceID = yield* InstanceState.workspaceID
|
||||||
const projectID = ctx.project.id
|
const projectID = ctx.project.id
|
||||||
const extra = startCommand?.trim()
|
const extra = startCommand?.trim()
|
||||||
|
|
||||||
|
|
@ -254,6 +255,8 @@ export namespace Worktree {
|
||||||
log.error("worktree checkout failed", { directory: info.directory, message })
|
log.error("worktree checkout failed", { directory: info.directory, message })
|
||||||
GlobalBus.emit("event", {
|
GlobalBus.emit("event", {
|
||||||
directory: info.directory,
|
directory: info.directory,
|
||||||
|
project: ctx.project.id,
|
||||||
|
workspace: workspaceID,
|
||||||
payload: { type: Event.Failed.type, properties: { message } },
|
payload: { type: Event.Failed.type, properties: { message } },
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|
@ -271,6 +274,8 @@ export namespace Worktree {
|
||||||
log.error("worktree bootstrap failed", { directory: info.directory, message })
|
log.error("worktree bootstrap failed", { directory: info.directory, message })
|
||||||
GlobalBus.emit("event", {
|
GlobalBus.emit("event", {
|
||||||
directory: info.directory,
|
directory: info.directory,
|
||||||
|
project: ctx.project.id,
|
||||||
|
workspace: workspaceID,
|
||||||
payload: { type: Event.Failed.type, properties: { message } },
|
payload: { type: Event.Failed.type, properties: { message } },
|
||||||
})
|
})
|
||||||
return false
|
return false
|
||||||
|
|
@ -280,6 +285,8 @@ export namespace Worktree {
|
||||||
|
|
||||||
GlobalBus.emit("event", {
|
GlobalBus.emit("event", {
|
||||||
directory: info.directory,
|
directory: info.directory,
|
||||||
|
project: ctx.project.id,
|
||||||
|
workspace: workspaceID,
|
||||||
payload: {
|
payload: {
|
||||||
type: Event.Ready.type,
|
type: Event.Ready.type,
|
||||||
properties: { name: info.name, branch: info.branch },
|
properties: { name: info.name, branch: info.branch },
|
||||||
|
|
|
||||||
293
packages/opencode/test/cli/tui/sync-provider.test.tsx
Normal file
293
packages/opencode/test/cli/tui/sync-provider.test.tsx
Normal file
|
|
@ -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<typeof useProject>
|
||||||
|
let sync!: ReturnType<typeof useSync>
|
||||||
|
let done!: () => void
|
||||||
|
const ready = new Promise<void>((resolve) => {
|
||||||
|
done = resolve
|
||||||
|
})
|
||||||
|
|
||||||
|
const app = await testRender(() => (
|
||||||
|
<SDKProvider
|
||||||
|
url="http://test"
|
||||||
|
directory="/tmp/root"
|
||||||
|
fetch={createFetch(log)}
|
||||||
|
events={{ subscribe: async () => () => {} }}
|
||||||
|
>
|
||||||
|
<ArgsProvider continue={false}>
|
||||||
|
<ExitProvider>
|
||||||
|
<ProjectProvider>
|
||||||
|
<SyncProvider>
|
||||||
|
<Probe
|
||||||
|
onReady={(ctx) => {
|
||||||
|
project = ctx.project
|
||||||
|
sync = ctx.sync
|
||||||
|
done()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SyncProvider>
|
||||||
|
</ProjectProvider>
|
||||||
|
</ExitProvider>
|
||||||
|
</ArgsProvider>
|
||||||
|
</SDKProvider>
|
||||||
|
))
|
||||||
|
|
||||||
|
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<typeof useProject>; sync: ReturnType<typeof useSync> }) => void
|
||||||
|
}) {
|
||||||
|
const project = useProject()
|
||||||
|
const sync = useSync()
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
props.onReady({ project, sync })
|
||||||
|
})
|
||||||
|
|
||||||
|
return <box />
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
175
packages/opencode/test/cli/tui/use-event.test.tsx
Normal file
175
packages/opencode/test/cli/tui/use-event.test.tsx
Normal file
|
|
@ -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<typeof useProject>
|
||||||
|
let done!: () => void
|
||||||
|
const ready = new Promise<void>((resolve) => {
|
||||||
|
done = resolve
|
||||||
|
})
|
||||||
|
|
||||||
|
const app = await testRender(() => (
|
||||||
|
<SDKProvider url="http://test" directory="/tmp/root" events={source.source}>
|
||||||
|
<ProjectProvider>
|
||||||
|
<Probe
|
||||||
|
onReady={(ctx) => {
|
||||||
|
project = ctx.project
|
||||||
|
done()
|
||||||
|
}}
|
||||||
|
seen={seen}
|
||||||
|
/>
|
||||||
|
</ProjectProvider>
|
||||||
|
</SDKProvider>
|
||||||
|
))
|
||||||
|
|
||||||
|
await ready
|
||||||
|
return { app, emit: source.emit, project, seen }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Probe(props: { seen: Event[]; onReady: (ctx: { project: ReturnType<typeof useProject> }) => void }) {
|
||||||
|
const project = useProject()
|
||||||
|
const event = useEvent()
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
event.subscribe((evt) => {
|
||||||
|
props.seen.push(evt)
|
||||||
|
})
|
||||||
|
props.onReady({ project })
|
||||||
|
})
|
||||||
|
|
||||||
|
return <box />
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -1011,6 +1011,8 @@ export type Event =
|
||||||
|
|
||||||
export type GlobalEvent = {
|
export type GlobalEvent = {
|
||||||
directory: string
|
directory: string
|
||||||
|
project?: string
|
||||||
|
workspace?: string
|
||||||
payload: Event
|
payload: Event
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9926,6 +9926,12 @@
|
||||||
"directory": {
|
"directory": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"project": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"workspace": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"payload": {
|
"payload": {
|
||||||
"$ref": "#/components/schemas/Event"
|
"$ref": "#/components/schemas/Event"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue