Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0a08c2267 |
@@ -27,7 +27,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.1",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -81,7 +81,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.1",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -115,7 +115,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.1",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -142,7 +142,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.1",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.64",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
@@ -166,7 +166,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.1",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -190,7 +190,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.1",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -223,7 +223,7 @@
|
||||
},
|
||||
"packages/desktop-electron": {
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.1",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
"electron-context-menu": "4.1.2",
|
||||
@@ -266,7 +266,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.1",
|
||||
"dependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -295,7 +295,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.1",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -311,7 +311,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.1",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -447,7 +447,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.1",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
@@ -481,7 +481,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.1",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -496,7 +496,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.1",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -531,7 +531,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.1",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -580,7 +580,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.1",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -591,7 +591,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.1",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.1",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.1",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.1",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.1",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"private": true,
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.1",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.1",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.4.2"
|
||||
version = "1.4.1"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.2/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.1/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.2/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.1/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.2/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.1/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.2/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.1/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.2/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.1/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.1",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.1",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
## 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,8 +4,6 @@ export const GlobalBus = new EventEmitter<{
|
||||
event: [
|
||||
{
|
||||
directory?: string
|
||||
project?: string
|
||||
workspace?: string
|
||||
payload: any
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import z from "zod"
|
||||
import { Effect, Exit, Layer, PubSub, Scope, ServiceMap, Stream } from "effect"
|
||||
import { Log } from "../util/log"
|
||||
import { Instance } from "../project/instance"
|
||||
import { BusEvent } from "./bus-event"
|
||||
import { GlobalBus } from "./global"
|
||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
|
||||
@@ -91,13 +91,8 @@ export namespace Bus {
|
||||
yield* PubSub.publish(s.wildcard, payload)
|
||||
|
||||
const dir = yield* InstanceState.directory
|
||||
const context = yield* InstanceState.context
|
||||
const workspace = yield* InstanceState.workspaceID
|
||||
|
||||
GlobalBus.emit("event", {
|
||||
directory: dir,
|
||||
project: context.project.id,
|
||||
workspace,
|
||||
payload,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -688,7 +688,6 @@ export const McpDebugCommand = cmd({
|
||||
clientId: oauthConfig?.clientId,
|
||||
clientSecret: oauthConfig?.clientSecret,
|
||||
scope: oauthConfig?.scope,
|
||||
redirectUri: oauthConfig?.redirectUri,
|
||||
},
|
||||
{
|
||||
onRedirect: async () => {},
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
batch,
|
||||
Show,
|
||||
on,
|
||||
onCleanup,
|
||||
} from "solid-js"
|
||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||
import { Flag } from "@/flag/flag"
|
||||
@@ -22,8 +23,6 @@ import { DialogProvider, useDialog } from "@tui/ui/dialog"
|
||||
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
|
||||
import { ErrorComponent } from "@tui/component/error-component"
|
||||
import { PluginRouteMissing } from "@tui/component/plugin-route-missing"
|
||||
import { ProjectProvider } from "@tui/context/project"
|
||||
import { useEvent } from "@tui/context/event"
|
||||
import { SDKProvider, useSDK } from "@tui/context/sdk"
|
||||
import { StartupLoading } from "@tui/component/startup-loading"
|
||||
import { SyncProvider, useSync } from "@tui/context/sync"
|
||||
@@ -36,6 +35,7 @@ import { DialogHelp } from "./ui/dialog-help"
|
||||
import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command"
|
||||
import { DialogAgent } from "@tui/component/dialog-agent"
|
||||
import { DialogSessionList } from "@tui/component/dialog-session-list"
|
||||
import { DialogWorkspaceList } from "@tui/component/dialog-workspace-list"
|
||||
import { DialogConsoleOrg } from "@tui/component/dialog-console-org"
|
||||
import { KeybindProvider, useKeybind } from "@tui/context/keybind"
|
||||
import { ThemeProvider, useTheme } from "@tui/context/theme"
|
||||
@@ -54,6 +54,7 @@ import { KVProvider, useKV } from "./context/kv"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { ArgsProvider, useArgs, type Args } from "./context/args"
|
||||
import open from "open"
|
||||
import { writeHeapSnapshot } from "v8"
|
||||
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
||||
import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
|
||||
import { TuiConfig } from "@/config/tui"
|
||||
@@ -215,29 +216,27 @@ export function tui(input: {
|
||||
headers={input.headers}
|
||||
events={input.events}
|
||||
>
|
||||
<ProjectProvider>
|
||||
<SyncProvider>
|
||||
<ThemeProvider mode={mode}>
|
||||
<LocalProvider>
|
||||
<KeybindProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<CommandProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<App onSnapshot={input.onSnapshot} />
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</CommandProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</KeybindProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</SyncProvider>
|
||||
</ProjectProvider>
|
||||
<SyncProvider>
|
||||
<ThemeProvider mode={mode}>
|
||||
<LocalProvider>
|
||||
<KeybindProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<CommandProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<App onSnapshot={input.onSnapshot} />
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</CommandProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</KeybindProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</SyncProvider>
|
||||
</SDKProvider>
|
||||
</TuiConfigProvider>
|
||||
</RouteProvider>
|
||||
@@ -261,7 +260,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
const kv = useKV()
|
||||
const command = useCommandDialog()
|
||||
const keybind = useKeybind()
|
||||
const event = useEvent()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const themeState = useTheme()
|
||||
@@ -285,7 +283,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
route,
|
||||
routes,
|
||||
bump: () => setRouteRev((x) => x + 1),
|
||||
event,
|
||||
sdk,
|
||||
sync,
|
||||
theme: themeState,
|
||||
@@ -464,6 +461,22 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
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",
|
||||
suggested: route.data.type === "session",
|
||||
@@ -478,9 +491,12 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
const current = promptRef.current
|
||||
// Don't require focus - if there's any text, preserve it
|
||||
const currentPrompt = current?.current?.input ? current.current : undefined
|
||||
const workspaceID =
|
||||
route.data.type === "session" ? sync.session.get(route.data.sessionID)?.workspaceID : undefined
|
||||
route.navigate({
|
||||
type: "home",
|
||||
initialPrompt: currentPrompt,
|
||||
workspaceID,
|
||||
})
|
||||
dialog.clear()
|
||||
},
|
||||
@@ -790,11 +806,11 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
},
|
||||
])
|
||||
|
||||
event.on(TuiEvent.CommandExecute.type, (evt) => {
|
||||
sdk.event.on(TuiEvent.CommandExecute.type, (evt) => {
|
||||
command.trigger(evt.properties.command)
|
||||
})
|
||||
|
||||
event.on(TuiEvent.ToastShow.type, (evt) => {
|
||||
sdk.event.on(TuiEvent.ToastShow.type, (evt) => {
|
||||
toast.show({
|
||||
title: evt.properties.title,
|
||||
message: evt.properties.message,
|
||||
@@ -803,14 +819,14 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
})
|
||||
})
|
||||
|
||||
event.on(TuiEvent.SessionSelect.type, (evt) => {
|
||||
sdk.event.on(TuiEvent.SessionSelect.type, (evt) => {
|
||||
route.navigate({
|
||||
type: "session",
|
||||
sessionID: evt.properties.sessionID,
|
||||
})
|
||||
})
|
||||
|
||||
event.on("session.deleted", (evt) => {
|
||||
sdk.event.on("session.deleted", (evt) => {
|
||||
if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
|
||||
route.navigate({ type: "home" })
|
||||
toast.show({
|
||||
@@ -820,7 +836,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
}
|
||||
})
|
||||
|
||||
event.on("session.error", (evt) => {
|
||||
sdk.event.on("session.error", (evt) => {
|
||||
const error = evt.properties.error
|
||||
if (error && typeof error === "object" && error.name === "MessageAbortedError") return
|
||||
const message = errorMessage(error)
|
||||
@@ -832,7 +848,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
})
|
||||
})
|
||||
|
||||
event.on("installation.update-available", async (evt) => {
|
||||
sdk.event.on("installation.update-available", async (evt) => {
|
||||
const version = evt.properties.version
|
||||
|
||||
const skipped = kv.get("skipped_version")
|
||||
|
||||
@@ -1,50 +1,17 @@
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import { createMemo, createResource, createSignal, onMount } from "solid-js"
|
||||
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 { useSDK } from "../context/sdk"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { createDebouncedSignal } from "../util/signal"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { DialogSessionRename } from "./dialog-session-rename"
|
||||
import { DialogWorkspaceCreate, openWorkspaceSession } from "./dialog-workspace-create"
|
||||
import { useKV } from "../context/kv"
|
||||
import { createDebouncedSignal } from "../util/signal"
|
||||
import { Spinner } from "./spinner"
|
||||
|
||||
const color = [
|
||||
RGBA.fromHex("#ff7a90"),
|
||||
RGBA.fromHex("#f8c555"),
|
||||
RGBA.fromHex("#70d6a3"),
|
||||
RGBA.fromHex("#57c7ff"),
|
||||
RGBA.fromHex("#bb9af7"),
|
||||
RGBA.fromHex("#ff9e64"),
|
||||
]
|
||||
|
||||
const shape = ["■", "◆", "▲", "▶", "▼", "◀", "●", "◉", "◈", "◊"]
|
||||
const action = "__workspace_new__"
|
||||
|
||||
function hash(text: string) {
|
||||
let sum = 0
|
||||
for (const char of text) {
|
||||
sum = (sum * 31 + char.charCodeAt(0)) >>> 0
|
||||
}
|
||||
return sum
|
||||
}
|
||||
|
||||
function mark(id?: string) {
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
const sum = hash(id)
|
||||
return {
|
||||
fg: color[sum % color.length]!,
|
||||
text: shape[sum % shape.length]!,
|
||||
}
|
||||
}
|
||||
|
||||
export function DialogSessionList() {
|
||||
const dialog = useDialog()
|
||||
const route = useRoute()
|
||||
@@ -52,60 +19,44 @@ export function DialogSessionList() {
|
||||
const keybind = useKeybind()
|
||||
const { theme } = useTheme()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const kv = useKV()
|
||||
|
||||
const [toDelete, setToDelete] = createSignal<string>()
|
||||
const [search, setSearch] = createDebouncedSignal("", 150)
|
||||
|
||||
const load = async (search?: string) => {
|
||||
const result = await sdk.client.session.list({
|
||||
roots: true,
|
||||
...(search ? { search, limit: 30 } : {}),
|
||||
})
|
||||
return result.data ?? []
|
||||
}
|
||||
|
||||
const [listed, listedActions] = createResource(async () => load())
|
||||
const [found] = createResource(search, async (query) => {
|
||||
const [searchResults] = createResource(search, async (query) => {
|
||||
if (!query) return undefined
|
||||
return load(query)
|
||||
const result = await sdk.client.session.list({ search: query, limit: 30 })
|
||||
return result.data ?? []
|
||||
})
|
||||
|
||||
const current = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
|
||||
const sessions = createMemo(() => found() ?? listed() ?? sync.data.session)
|
||||
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
|
||||
|
||||
const sessions = createMemo(() => searchResults() ?? sync.data.session)
|
||||
|
||||
const options = createMemo(() => {
|
||||
const today = new Date().toDateString()
|
||||
return [
|
||||
{
|
||||
title: "+ New workspace session",
|
||||
value: action,
|
||||
category: "Actions",
|
||||
description: "Create a new workspace, then open a session there",
|
||||
},
|
||||
...sessions()
|
||||
.filter((item) => item.parentID === undefined)
|
||||
.toSorted((a, b) => b.time.updated - a.time.updated)
|
||||
.map((item) => {
|
||||
const badge = mark(item.workspaceID)
|
||||
const date = new Date(item.time.updated)
|
||||
let category = date.toDateString()
|
||||
if (category === today) {
|
||||
category = "Today"
|
||||
}
|
||||
const deleting = toDelete() === item.id
|
||||
const status = sync.data.session_status?.[item.id]
|
||||
const working = status?.type === "busy"
|
||||
return {
|
||||
title: deleting ? `Press ${keybind.print("session_delete")} again to confirm` : item.title,
|
||||
bg: deleting ? theme.error : undefined,
|
||||
value: item.id,
|
||||
category,
|
||||
footer: Locale.time(item.time.updated),
|
||||
gutter: working ? <Spinner /> : undefined,
|
||||
margin: badge ? <text fg={badge.fg}>{badge.text}</text> : undefined,
|
||||
}
|
||||
}),
|
||||
]
|
||||
return sessions()
|
||||
.filter((x) => x.parentID === undefined)
|
||||
.toSorted((a, b) => b.time.updated - a.time.updated)
|
||||
.map((x) => {
|
||||
const date = new Date(x.time.updated)
|
||||
let category = date.toDateString()
|
||||
if (category === today) {
|
||||
category = "Today"
|
||||
}
|
||||
const isDeleting = toDelete() === x.id
|
||||
const status = sync.data.session_status?.[x.id]
|
||||
const isWorking = status?.type === "busy"
|
||||
return {
|
||||
title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title,
|
||||
bg: isDeleting ? theme.error : undefined,
|
||||
value: x.id,
|
||||
category,
|
||||
footer: Locale.time(x.time.updated),
|
||||
gutter: isWorking ? <Spinner /> : undefined,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
@@ -117,29 +68,12 @@ export function DialogSessionList() {
|
||||
title="Sessions"
|
||||
options={options()}
|
||||
skipFilter={true}
|
||||
current={current()}
|
||||
current={currentSessionID()}
|
||||
onFilter={setSearch}
|
||||
onMove={() => {
|
||||
setToDelete(undefined)
|
||||
}}
|
||||
onSelect={(option) => {
|
||||
if (option.value === action) {
|
||||
dialog.replace(() => (
|
||||
<DialogWorkspaceCreate
|
||||
onSelect={(workspaceID) =>
|
||||
openWorkspaceSession({
|
||||
dialog,
|
||||
route,
|
||||
sdk,
|
||||
sync,
|
||||
toast,
|
||||
workspaceID,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))
|
||||
return
|
||||
}
|
||||
route.navigate({
|
||||
type: "session",
|
||||
sessionID: option.value,
|
||||
@@ -151,27 +85,10 @@ export function DialogSessionList() {
|
||||
keybind: keybind.all.session_delete?.[0],
|
||||
title: "delete",
|
||||
onTrigger: async (option) => {
|
||||
if (option.value === action) return
|
||||
if (toDelete() === option.value) {
|
||||
const deleted = await sdk.client.session
|
||||
.delete({
|
||||
sessionID: option.value,
|
||||
})
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
if (!deleted) {
|
||||
toast.show({
|
||||
message: "Failed to delete session",
|
||||
variant: "error",
|
||||
})
|
||||
setToDelete(undefined)
|
||||
return
|
||||
}
|
||||
listedActions.mutate((items) => items?.filter((item) => item.id !== option.value))
|
||||
sync.set(
|
||||
"session",
|
||||
sync.data.session.filter((item) => item.id !== option.value),
|
||||
)
|
||||
sdk.client.session.delete({
|
||||
sessionID: option.value,
|
||||
})
|
||||
setToDelete(undefined)
|
||||
return
|
||||
}
|
||||
@@ -182,7 +99,6 @@ export function DialogSessionList() {
|
||||
keybind: keybind.all.session_rename?.[0],
|
||||
title: "rename",
|
||||
onTrigger: async (option) => {
|
||||
if (option.value === action) return
|
||||
dialog.replace(() => <DialogSessionRename session={option.value} />)
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
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)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
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
|
||||
options.push(
|
||||
...sortedFiles.map((item): AutocompleteOption => {
|
||||
const baseDir = (sync.path.directory || process.cwd()).replace(/\/+$/, "")
|
||||
const baseDir = (sync.data.path.directory || process.cwd()).replace(/\/+$/, "")
|
||||
const fullPath = `${baseDir}/${item}`
|
||||
const urlObj = pathToFileURL(fullPath)
|
||||
let filename = item
|
||||
|
||||
@@ -10,7 +10,6 @@ import { EmptyBorder, SplitBorder } from "@tui/component/border"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { useEvent } from "@tui/context/event"
|
||||
import { MessageID, PartID } from "@/session/schema"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { useKeybind } from "@tui/context/keybind"
|
||||
@@ -116,9 +115,8 @@ export function Prompt(props: PromptProps) {
|
||||
const agentStyleId = syntax().getStyleId("extmark.agent")!
|
||||
const pasteStyleId = syntax().getStyleId("extmark.paste")!
|
||||
let promptPartTypeId = 0
|
||||
const event = useEvent()
|
||||
|
||||
event.on(TuiEvent.PromptAppend.type, (evt) => {
|
||||
sdk.event.on(TuiEvent.PromptAppend.type, (evt) => {
|
||||
if (!input || input.isDestroyed) return
|
||||
input.insertText(evt.properties.text)
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { createMemo, createSignal, createResource, onMount, Show } from "solid-js"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { useKeybind } from "../../context/keybind"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
import { DialogSessionRename } from "../dialog-session-rename"
|
||||
import { useKV } from "../../context/kv"
|
||||
import { createDebouncedSignal } from "../../util/signal"
|
||||
import { Spinner } from "../spinner"
|
||||
import { useToast } from "../../ui/toast"
|
||||
|
||||
export function DialogSessionList(props: { workspaceID?: string; localOnly?: boolean } = {}) {
|
||||
const dialog = useDialog()
|
||||
const route = useRoute()
|
||||
const sync = useSync()
|
||||
const keybind = useKeybind()
|
||||
const { theme } = useTheme()
|
||||
const sdk = useSDK()
|
||||
const kv = useKV()
|
||||
const toast = useToast()
|
||||
const [toDelete, setToDelete] = createSignal<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,13 +1,11 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { useProject } from "./project"
|
||||
import { useSync } from "./sync"
|
||||
import { Global } from "@/global"
|
||||
|
||||
export function useDirectory() {
|
||||
const project = useProject()
|
||||
const sync = useSync()
|
||||
return createMemo(() => {
|
||||
const directory = project.instance.path().directory || process.cwd()
|
||||
const directory = sync.data.path.directory || process.cwd()
|
||||
const result = directory.replace(Global.Path.home, "~")
|
||||
if (sync.data.vcs?.branch) return result + ":" + sync.data.vcs.branch
|
||||
return result
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
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,6 +5,7 @@ import type { PromptInfo } from "../component/prompt/history"
|
||||
export type HomeRoute = {
|
||||
type: "home"
|
||||
initialPrompt?: PromptInfo
|
||||
workspaceID?: string
|
||||
}
|
||||
|
||||
export type SessionRoute = {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import type { GlobalEvent, Event } from "@opencode-ai/sdk/v2"
|
||||
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { batch, onCleanup, onMount } from "solid-js"
|
||||
|
||||
export type EventSource = {
|
||||
subscribe: (handler: (event: GlobalEvent) => void) => Promise<() => void>
|
||||
subscribe: (directory: string | undefined, handler: (event: Event) => void) => Promise<() => void>
|
||||
}
|
||||
|
||||
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
@@ -33,10 +32,10 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
let sdk = createSDK()
|
||||
|
||||
const emitter = createGlobalEmitter<{
|
||||
event: GlobalEvent
|
||||
[key in Event["type"]]: Extract<Event, { type: key }>
|
||||
}>()
|
||||
|
||||
let queue: GlobalEvent[] = []
|
||||
let queue: Event[] = []
|
||||
let timer: Timer | undefined
|
||||
let last = 0
|
||||
|
||||
@@ -49,12 +48,12 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
// Batch all event emissions so all store updates result in a single render
|
||||
batch(() => {
|
||||
for (const event of events) {
|
||||
emitter.emit("event", event)
|
||||
emitter.emit(event.type, event)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleEvent = (event: GlobalEvent) => {
|
||||
const handleEvent = (event: Event) => {
|
||||
queue.push(event)
|
||||
const elapsed = Date.now() - last
|
||||
|
||||
@@ -75,7 +74,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
;(async () => {
|
||||
while (true) {
|
||||
if (abort.signal.aborted || ctrl.signal.aborted) break
|
||||
const events = await sdk.global.event({ signal: ctrl.signal })
|
||||
const events = await sdk.event.subscribe({}, { signal: ctrl.signal })
|
||||
|
||||
for await (const event of events.stream) {
|
||||
if (ctrl.signal.aborted) break
|
||||
@@ -90,7 +89,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
|
||||
onMount(async () => {
|
||||
if (props.events) {
|
||||
const unsub = await props.events.subscribe(handleEvent)
|
||||
const unsub = await props.events.subscribe(props.directory, handleEvent)
|
||||
onCleanup(unsub)
|
||||
} else {
|
||||
startSSE()
|
||||
|
||||
@@ -17,19 +17,18 @@ import type {
|
||||
ProviderListResponse,
|
||||
ProviderAuthMethod,
|
||||
VcsInfo,
|
||||
Workspace,
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { useProject } from "@tui/context/project"
|
||||
import { useEvent } from "@tui/context/event"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import type { Snapshot } from "@/snapshot"
|
||||
import { useExit } from "./exit"
|
||||
import { useArgs } from "./args"
|
||||
import { batch, createEffect, on } from "solid-js"
|
||||
import { batch, onMount } from "solid-js"
|
||||
import { Log } from "@/util/log"
|
||||
import type { Path } from "@opencode-ai/sdk"
|
||||
import type { Workspace } from "@opencode-ai/sdk/v2"
|
||||
import { ConsoleState, emptyConsoleState, type ConsoleState as ConsoleStateType } from "@/config/console-state"
|
||||
|
||||
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
@@ -75,8 +74,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
[key: string]: McpResource
|
||||
}
|
||||
formatter: FormatterStatus[]
|
||||
workspaceList: Workspace[]
|
||||
vcs: VcsInfo | undefined
|
||||
path: Path
|
||||
workspaceList: Workspace[]
|
||||
}>({
|
||||
provider_next: {
|
||||
all: [],
|
||||
@@ -103,25 +103,21 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
mcp: {},
|
||||
mcp_resource: {},
|
||||
formatter: [],
|
||||
workspaceList: [],
|
||||
vcs: undefined,
|
||||
path: { state: "", config: "", worktree: "", directory: "" },
|
||||
workspaceList: [],
|
||||
})
|
||||
|
||||
const event = useEvent()
|
||||
const project = useProject()
|
||||
const sdk = useSDK()
|
||||
|
||||
async function syncWorkspaces() {
|
||||
const workspace = project.workspace.current()
|
||||
const result = await sdk.client.experimental.workspace.list().catch(() => undefined)
|
||||
if (!result?.data) return
|
||||
setStore("workspaceList", reconcile(result.data))
|
||||
if (!result.data.some((item) => item.id === workspace)) {
|
||||
project.workspace.set(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
event.subscribe((event) => {
|
||||
sdk.event.listen((e) => {
|
||||
const event = e.details
|
||||
switch (event.type) {
|
||||
case "server.instance.disposed":
|
||||
bootstrap()
|
||||
@@ -348,8 +344,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
}
|
||||
|
||||
case "lsp.updated": {
|
||||
const workspace = project.workspace.current()
|
||||
sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", x.data!))
|
||||
sdk.client.lsp.status().then((x) => setStore("lsp", x.data!))
|
||||
break
|
||||
}
|
||||
|
||||
@@ -365,28 +360,25 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
|
||||
async function bootstrap() {
|
||||
console.log("bootstrapping")
|
||||
const workspace = project.workspace.current()
|
||||
const start = Date.now() - 30 * 24 * 60 * 60 * 1000
|
||||
const sessionListPromise = sdk.client.session
|
||||
.list({ start: start, workspace })
|
||||
.list({ start: start })
|
||||
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
|
||||
|
||||
// blocking - include session.list when continuing a session
|
||||
const providersPromise = sdk.client.config.providers({ workspace }, { throwOnError: true })
|
||||
const providerListPromise = sdk.client.provider.list({ workspace }, { throwOnError: true })
|
||||
const providersPromise = sdk.client.config.providers({}, { throwOnError: true })
|
||||
const providerListPromise = sdk.client.provider.list({}, { throwOnError: true })
|
||||
const consoleStatePromise = sdk.client.experimental.console
|
||||
.get({ workspace }, { throwOnError: true })
|
||||
.get({}, { throwOnError: true })
|
||||
.then((x) => ConsoleState.parse(x.data))
|
||||
.catch(() => emptyConsoleState)
|
||||
const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
|
||||
const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
|
||||
const projectPromise = project.sync()
|
||||
const agentsPromise = sdk.client.app.agents({}, { throwOnError: true })
|
||||
const configPromise = sdk.client.config.get({}, { throwOnError: true })
|
||||
const blockingRequests: Promise<unknown>[] = [
|
||||
providersPromise,
|
||||
providerListPromise,
|
||||
agentsPromise,
|
||||
configPromise,
|
||||
projectPromise,
|
||||
...(args.continue ? [sessionListPromise] : []),
|
||||
]
|
||||
|
||||
@@ -431,18 +423,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
Promise.all([
|
||||
...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]),
|
||||
consoleStatePromise.then((consoleState) => setStore("console_state", reconcile(consoleState))),
|
||||
sdk.client.command.list({ workspace }).then((x) => setStore("command", reconcile(x.data ?? []))),
|
||||
sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", reconcile(x.data!))),
|
||||
sdk.client.mcp.status({ workspace }).then((x) => setStore("mcp", reconcile(x.data!))),
|
||||
sdk.client.experimental.resource
|
||||
.list({ workspace })
|
||||
.then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))),
|
||||
sdk.client.formatter.status({ workspace }).then((x) => setStore("formatter", reconcile(x.data!))),
|
||||
sdk.client.session.status({ workspace }).then((x) => {
|
||||
sdk.client.command.list().then((x) => setStore("command", reconcile(x.data ?? []))),
|
||||
sdk.client.lsp.status().then((x) => setStore("lsp", reconcile(x.data!))),
|
||||
sdk.client.mcp.status().then((x) => setStore("mcp", reconcile(x.data!))),
|
||||
sdk.client.experimental.resource.list().then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))),
|
||||
sdk.client.formatter.status().then((x) => setStore("formatter", reconcile(x.data!))),
|
||||
sdk.client.session.status().then((x) => {
|
||||
setStore("session_status", reconcile(x.data!))
|
||||
}),
|
||||
sdk.client.provider.auth({ workspace }).then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
|
||||
sdk.client.vcs.get({ workspace }).then((x) => setStore("vcs", reconcile(x.data))),
|
||||
sdk.client.provider.auth().then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
|
||||
sdk.client.vcs.get().then((x) => setStore("vcs", reconcile(x.data))),
|
||||
sdk.client.path.get().then((x) => setStore("path", reconcile(x.data!))),
|
||||
syncWorkspaces(),
|
||||
]).then(() => {
|
||||
setStore("status", "complete")
|
||||
@@ -458,17 +449,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
})
|
||||
}
|
||||
|
||||
const fullSyncedSessions = new Set<string>()
|
||||
createEffect(
|
||||
on(
|
||||
() => project.workspace.current(),
|
||||
() => {
|
||||
fullSyncedSessions.clear()
|
||||
void bootstrap()
|
||||
},
|
||||
),
|
||||
)
|
||||
onMount(() => {
|
||||
bootstrap()
|
||||
})
|
||||
|
||||
const fullSyncedSessions = new Set<string>()
|
||||
const result = {
|
||||
data: store,
|
||||
set: setStore,
|
||||
@@ -478,9 +463,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
get ready() {
|
||||
return store.status !== "loading"
|
||||
},
|
||||
get path() {
|
||||
return project.instance.path()
|
||||
},
|
||||
session: {
|
||||
get(sessionID: string) {
|
||||
const match = Binary.search(store.session, sessionID, (s) => s.id)
|
||||
@@ -499,12 +481,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
},
|
||||
async sync(sessionID: string) {
|
||||
if (fullSyncedSessions.has(sessionID)) return
|
||||
const workspace = project.workspace.current()
|
||||
const [session, messages, todo, diff] = await Promise.all([
|
||||
sdk.client.session.get({ sessionID, workspace }, { throwOnError: true }),
|
||||
sdk.client.session.messages({ sessionID, limit: 100, workspace }),
|
||||
sdk.client.session.todo({ sessionID, workspace }),
|
||||
sdk.client.session.diff({ sessionID, workspace }),
|
||||
sdk.client.session.get({ sessionID }, { throwOnError: true }),
|
||||
sdk.client.session.messages({ sessionID, limit: 100 }),
|
||||
sdk.client.session.todo({ sessionID }),
|
||||
sdk.client.session.diff({ sessionID }),
|
||||
])
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
@@ -523,11 +504,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
},
|
||||
},
|
||||
workspace: {
|
||||
list() {
|
||||
return store.workspaceList
|
||||
},
|
||||
get(workspaceID: string) {
|
||||
return store.workspaceList.find((item) => item.id === workspaceID)
|
||||
return store.workspaceList.find((workspace) => workspace.id === workspaceID)
|
||||
},
|
||||
sync: syncWorkspaces,
|
||||
},
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { ParsedKey } from "@opentui/core"
|
||||
import type { TuiDialogSelectOption, TuiPluginApi, TuiRouteDefinition, TuiSlotProps } from "@opencode-ai/plugin/tui"
|
||||
import type { useCommandDialog } from "@tui/component/dialog-command"
|
||||
import type { useEvent } from "@tui/context/event"
|
||||
import type { useKeybind } from "@tui/context/keybind"
|
||||
import type { useRoute } from "@tui/context/route"
|
||||
import type { useSDK } from "@tui/context/sdk"
|
||||
@@ -37,7 +36,6 @@ type Input = {
|
||||
route: ReturnType<typeof useRoute>
|
||||
routes: RouteMap
|
||||
bump: () => void
|
||||
event: ReturnType<typeof useEvent>
|
||||
sdk: ReturnType<typeof useSDK>
|
||||
sync: ReturnType<typeof useSync>
|
||||
theme: ReturnType<typeof useTheme>
|
||||
@@ -138,7 +136,7 @@ function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
|
||||
return sync.data.provider
|
||||
},
|
||||
get path() {
|
||||
return sync.path
|
||||
return sync.data.path
|
||||
},
|
||||
get vcs() {
|
||||
if (!sync.data.vcs) return
|
||||
@@ -344,7 +342,7 @@ export function createTuiApi(input: Input): TuiPluginApi {
|
||||
get client() {
|
||||
return input.sdk.client
|
||||
},
|
||||
event: input.event,
|
||||
event: input.sdk.event,
|
||||
renderer: input.renderer,
|
||||
slots: {
|
||||
register() {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Prompt, type PromptRef } from "@tui/component/prompt"
|
||||
import { createEffect, createSignal } from "solid-js"
|
||||
import { Logo } from "../component/logo"
|
||||
import { useProject } from "../context/project"
|
||||
import { useSync } from "../context/sync"
|
||||
import { Toast } from "../ui/toast"
|
||||
import { useArgs } from "../context/args"
|
||||
@@ -19,7 +18,6 @@ const placeholder = {
|
||||
|
||||
export function Home() {
|
||||
const sync = useSync()
|
||||
const project = useProject()
|
||||
const route = useRouteData("home")
|
||||
const promptRef = usePromptRef()
|
||||
const [ref, setRef] = createSignal<PromptRef | undefined>()
|
||||
@@ -65,16 +63,11 @@ export function Home() {
|
||||
</box>
|
||||
<box height={1} minHeight={0} flexShrink={1} />
|
||||
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
|
||||
<TuiPluginRuntime.Slot
|
||||
name="home_prompt"
|
||||
mode="replace"
|
||||
workspace_id={project.workspace.current()}
|
||||
ref={bind}
|
||||
>
|
||||
<TuiPluginRuntime.Slot name="home_prompt" mode="replace" workspace_id={route.workspaceID} ref={bind}>
|
||||
<Prompt
|
||||
ref={bind}
|
||||
workspaceID={project.workspace.current()}
|
||||
right={<TuiPluginRuntime.Slot name="home_prompt_right" workspace_id={project.workspace.current()} />}
|
||||
workspaceID={route.workspaceID}
|
||||
right={<TuiPluginRuntime.Slot name="home_prompt_right" workspace_id={route.workspaceID} />}
|
||||
placeholders={placeholder}
|
||||
/>
|
||||
</TuiPluginRuntime.Slot>
|
||||
|
||||
@@ -15,9 +15,7 @@ import {
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import path from "path"
|
||||
import { useRoute, useRouteData } from "@tui/context/route"
|
||||
import { useProject } from "@tui/context/project"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { useEvent } from "@tui/context/event"
|
||||
import { SplitBorder } from "@tui/component/border"
|
||||
import { Spinner } from "@tui/component/spinner"
|
||||
import { selectedForeground, useTheme } from "@tui/context/theme"
|
||||
@@ -118,8 +116,6 @@ export function Session() {
|
||||
const route = useRouteData("session")
|
||||
const { navigate } = useRoute()
|
||||
const sync = useSync()
|
||||
const event = useEvent()
|
||||
const project = useProject()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const kv = useKV()
|
||||
const { theme } = useTheme()
|
||||
@@ -159,7 +155,7 @@ export function Session() {
|
||||
const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide")
|
||||
const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true)
|
||||
const [showAssistantMetadata, setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true)
|
||||
const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", false)
|
||||
const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", true)
|
||||
const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word")
|
||||
const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true)
|
||||
const [showGenericToolOutput, setShowGenericToolOutput] = kv.signal("generic_tool_output_visibility", false)
|
||||
@@ -176,16 +172,10 @@ export function Session() {
|
||||
const providers = createMemo(() => Model.index(sync.data.provider))
|
||||
|
||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
|
||||
const toast = useToast()
|
||||
const sdk = useSDK()
|
||||
|
||||
createEffect(async () => {
|
||||
await sdk.client.session
|
||||
.get({ sessionID: route.sessionID }, { throwOnError: true })
|
||||
.then((x) => {
|
||||
project.workspace.set(x.data?.workspaceID)
|
||||
})
|
||||
.then(() => sync.session.sync(route.sessionID))
|
||||
await sync.session
|
||||
.sync(route.sessionID)
|
||||
.then(() => {
|
||||
if (scroll) scroll.scrollBy(100_000)
|
||||
})
|
||||
@@ -199,10 +189,13 @@ export function Session() {
|
||||
})
|
||||
})
|
||||
|
||||
const toast = useToast()
|
||||
const sdk = useSDK()
|
||||
|
||||
// Handle initial prompt from fork
|
||||
let seeded = false
|
||||
let lastSwitch: string | undefined = undefined
|
||||
event.on("message.part.updated", (evt) => {
|
||||
sdk.event.on("message.part.updated", (evt) => {
|
||||
const part = evt.properties.part
|
||||
if (part.type !== "tool") return
|
||||
if (part.sessionID !== route.sessionID) return
|
||||
@@ -231,7 +224,7 @@ export function Session() {
|
||||
const dialog = useDialog()
|
||||
const renderer = useRenderer()
|
||||
|
||||
event.on("session.status", (evt) => {
|
||||
sdk.event.on("session.status", (evt) => {
|
||||
if (evt.properties.sessionID !== route.sessionID) return
|
||||
if (evt.properties.status.type !== "retry") return
|
||||
if (evt.properties.status.message !== SessionRetry.GO_UPSELL_MESSAGE) return
|
||||
@@ -1798,7 +1791,7 @@ function Bash(props: ToolProps<typeof BashTool>) {
|
||||
const workdir = props.input.workdir
|
||||
if (!workdir || workdir === ".") return undefined
|
||||
|
||||
const base = sync.path.directory
|
||||
const base = sync.data.path.directory
|
||||
if (!base) return undefined
|
||||
|
||||
const absolute = path.resolve(base, workdir)
|
||||
|
||||
@@ -10,7 +10,7 @@ import { errorMessage } from "@/util/error"
|
||||
import { withTimeout } from "@/util/timeout"
|
||||
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||
import type { Event } from "@opencode-ai/sdk/v2"
|
||||
import type { EventSource } from "./context/sdk"
|
||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||
import { TuiConfig } from "@/config/tui"
|
||||
@@ -43,10 +43,18 @@ function createWorkerFetch(client: RpcClient): typeof fetch {
|
||||
|
||||
function createEventSource(client: RpcClient): EventSource {
|
||||
return {
|
||||
subscribe: async (handler) => {
|
||||
return client.on<GlobalEvent>("global.event", (e) => {
|
||||
handler(e)
|
||||
subscribe: async (directory, handler) => {
|
||||
const id = await client.call("subscribe", { directory })
|
||||
const unsub = client.on<{ id: string; event: Event }>("event", (e) => {
|
||||
if (e.id === id) {
|
||||
handler(e.event)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
unsub()
|
||||
client.call("unsubscribe", { id })
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,6 @@ export interface DialogSelectOption<T = any> {
|
||||
disabled?: boolean
|
||||
bg?: RGBA
|
||||
gutter?: JSX.Element
|
||||
margin?: JSX.Element
|
||||
onSelect?: (ctx: DialogContext) => void
|
||||
}
|
||||
|
||||
@@ -313,7 +312,6 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
<box
|
||||
id={JSON.stringify(option.value)}
|
||||
flexDirection="row"
|
||||
position="relative"
|
||||
onMouseMove={() => {
|
||||
setStore("input", "mouse")
|
||||
}}
|
||||
@@ -337,11 +335,6 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
paddingRight={3}
|
||||
gap={1}
|
||||
>
|
||||
<Show when={!current() && option.margin}>
|
||||
<box position="absolute" left={1} flexShrink={0}>
|
||||
{option.margin}
|
||||
</box>
|
||||
</Show>
|
||||
<Option
|
||||
title={option.title}
|
||||
footer={flatten() ? (option.category ?? option.footer) : option.footer}
|
||||
|
||||
@@ -6,10 +6,13 @@ import { InstanceBootstrap } from "@/project/bootstrap"
|
||||
import { Rpc } from "@/util/rpc"
|
||||
import { upgrade } from "@/cli/upgrade"
|
||||
import { Config } from "@/config/config"
|
||||
import { Bus } from "@/bus"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||
import type { Event } from "@opencode-ai/sdk/v2"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { writeHeapSnapshot } from "node:v8"
|
||||
import { WorkspaceID } from "@/control-plane/schema"
|
||||
import { Heap } from "@/cli/heap"
|
||||
|
||||
await Log.init({
|
||||
@@ -42,6 +45,87 @@ GlobalBus.on("event", (event) => {
|
||||
|
||||
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 = {
|
||||
async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) {
|
||||
const headers = { ...input.headers }
|
||||
@@ -83,9 +167,19 @@ export const rpc = {
|
||||
async reload() {
|
||||
await Config.invalidate(true)
|
||||
},
|
||||
async subscribe(input: { directory: string | undefined }) {
|
||||
return startEventStream(input.directory || process.cwd())
|
||||
},
|
||||
async unsubscribe(input: { id: string }) {
|
||||
stopEventStream(input.id)
|
||||
},
|
||||
async shutdown() {
|
||||
Log.Default.info("worker shutting down")
|
||||
|
||||
for (const id of [...eventStreams.keys()]) {
|
||||
stopEventStream(id)
|
||||
}
|
||||
|
||||
await Instance.disposeAll()
|
||||
if (server) await server.stop(true)
|
||||
},
|
||||
|
||||
@@ -399,10 +399,6 @@ export namespace Config {
|
||||
.describe("OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted."),
|
||||
clientSecret: z.string().optional().describe("OAuth client secret (if required by the authorization server)"),
|
||||
scope: z.string().optional().describe("OAuth scopes to request during authorization"),
|
||||
redirectUri: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback)."),
|
||||
})
|
||||
.strict()
|
||||
.meta({
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
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
|
||||
}
|
||||
|
||||
// await parseSSE(res.body, stop, (event) => {
|
||||
// GlobalBus.emit("event", {
|
||||
// directory: space.id,
|
||||
// payload: event,
|
||||
// })
|
||||
// })
|
||||
await parseSSE(res.body, stop, (event) => {
|
||||
GlobalBus.emit("event", {
|
||||
directory: space.id,
|
||||
payload: event,
|
||||
})
|
||||
})
|
||||
|
||||
// Wait 250ms and retry if SSE connection fails
|
||||
await sleep(250)
|
||||
|
||||
@@ -4,7 +4,3 @@ import type { InstanceContext } from "@/project/instance"
|
||||
export const InstanceRef = ServiceMap.Reference<InstanceContext | undefined>("~opencode/InstanceRef", {
|
||||
defaultValue: () => undefined,
|
||||
})
|
||||
|
||||
export const WorkspaceRef = ServiceMap.Reference<string | undefined>("~opencode/WorkspaceRef", {
|
||||
defaultValue: () => undefined,
|
||||
})
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { Effect, Fiber, ScopedCache, Scope, ServiceMap } from "effect"
|
||||
import { Instance, type InstanceContext } from "@/project/instance"
|
||||
import { Context } from "@/util/context"
|
||||
import { InstanceRef, WorkspaceRef } from "./instance-ref"
|
||||
import { InstanceRef } from "./instance-ref"
|
||||
import { registerDisposer } from "./instance-registry"
|
||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
|
||||
const TypeId = "~opencode/InstanceState"
|
||||
|
||||
@@ -29,10 +28,6 @@ export namespace InstanceState {
|
||||
return (yield* InstanceRef) ?? Instance.current
|
||||
})
|
||||
|
||||
export const workspaceID = Effect.gen(function* () {
|
||||
return (yield* WorkspaceRef) ?? WorkspaceContext.workspaceID
|
||||
})
|
||||
|
||||
export const directory = Effect.map(context, (ctx) => ctx.directory)
|
||||
|
||||
export const make = <A, E = never, R = never>(
|
||||
|
||||
@@ -2,17 +2,15 @@ import { Effect, Layer, ManagedRuntime } from "effect"
|
||||
import * as ServiceMap from "effect/ServiceMap"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { Context } from "@/util/context"
|
||||
import { InstanceRef, WorkspaceRef } from "./instance-ref"
|
||||
import { InstanceRef } from "./instance-ref"
|
||||
import { Observability } from "./oltp"
|
||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
|
||||
export const memoMap = Layer.makeMemoMapUnsafe()
|
||||
|
||||
function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> {
|
||||
try {
|
||||
const ctx = Instance.current
|
||||
const workspaceID = WorkspaceContext.workspaceID
|
||||
return effect.pipe(Effect.provideService(InstanceRef, ctx), Effect.provideService(WorkspaceRef, workspaceID))
|
||||
return Effect.provideService(effect, InstanceRef, ctx)
|
||||
} catch (err) {
|
||||
if (!(err instanceof Context.NotFound)) throw err
|
||||
}
|
||||
|
||||
@@ -286,7 +286,6 @@ export namespace MCP {
|
||||
clientId: oauthConfig?.clientId,
|
||||
clientSecret: oauthConfig?.clientSecret,
|
||||
scope: oauthConfig?.scope,
|
||||
redirectUri: oauthConfig?.redirectUri,
|
||||
},
|
||||
{
|
||||
onRedirect: async (url) => {
|
||||
@@ -717,16 +716,13 @@ export namespace MCP {
|
||||
if (mcpConfig.type !== "remote") throw new Error(`MCP server ${mcpName} is not a remote server`)
|
||||
if (mcpConfig.oauth === false) throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`)
|
||||
|
||||
// OAuth config is optional - if not provided, we'll use auto-discovery
|
||||
const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined
|
||||
|
||||
// Start the callback server with custom redirectUri if configured
|
||||
yield* Effect.promise(() => McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri))
|
||||
yield* Effect.promise(() => McpOAuthCallback.ensureRunning())
|
||||
|
||||
const oauthState = Array.from(crypto.getRandomValues(new Uint8Array(32)))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("")
|
||||
yield* auth.updateOAuthState(mcpName, oauthState)
|
||||
const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined
|
||||
let capturedUrl: URL | undefined
|
||||
const authProvider = new McpOAuthProvider(
|
||||
mcpName,
|
||||
@@ -735,7 +731,6 @@ export namespace MCP {
|
||||
clientId: oauthConfig?.clientId,
|
||||
clientSecret: oauthConfig?.clientSecret,
|
||||
scope: oauthConfig?.scope,
|
||||
redirectUri: oauthConfig?.redirectUri,
|
||||
},
|
||||
{
|
||||
onRedirect: async (url) => {
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { createConnection } from "net"
|
||||
import { createServer } from "http"
|
||||
import { Log } from "../util/log"
|
||||
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH, parseRedirectUri } from "./oauth-provider"
|
||||
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider"
|
||||
|
||||
const log = Log.create({ service: "mcp.oauth-callback" })
|
||||
|
||||
// Current callback server configuration (may differ from defaults if custom redirectUri is used)
|
||||
let currentPort = OAUTH_CALLBACK_PORT
|
||||
let currentPath = OAUTH_CALLBACK_PATH
|
||||
|
||||
const HTML_SUCCESS = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
@@ -75,9 +71,9 @@ export namespace McpOAuthCallback {
|
||||
}
|
||||
|
||||
function handleRequest(req: import("http").IncomingMessage, res: import("http").ServerResponse) {
|
||||
const url = new URL(req.url || "/", `http://localhost:${currentPort}`)
|
||||
const url = new URL(req.url || "/", `http://localhost:${OAUTH_CALLBACK_PORT}`)
|
||||
|
||||
if (url.pathname !== currentPath) {
|
||||
if (url.pathname !== OAUTH_CALLBACK_PATH) {
|
||||
res.writeHead(404)
|
||||
res.end("Not found")
|
||||
return
|
||||
@@ -139,31 +135,19 @@ export namespace McpOAuthCallback {
|
||||
res.end(HTML_SUCCESS)
|
||||
}
|
||||
|
||||
export async function ensureRunning(redirectUri?: string): Promise<void> {
|
||||
// Parse the redirect URI to get port and path (uses defaults if not provided)
|
||||
const { port, path } = parseRedirectUri(redirectUri)
|
||||
|
||||
// If server is running on a different port/path, stop it first
|
||||
if (server && (currentPort !== port || currentPath !== path)) {
|
||||
log.info("stopping oauth callback server to reconfigure", { oldPort: currentPort, newPort: port })
|
||||
await stop()
|
||||
}
|
||||
|
||||
export async function ensureRunning(): Promise<void> {
|
||||
if (server) return
|
||||
|
||||
const running = await isPortInUse(port)
|
||||
const running = await isPortInUse()
|
||||
if (running) {
|
||||
log.info("oauth callback server already running on another instance", { port })
|
||||
log.info("oauth callback server already running on another instance", { port: OAUTH_CALLBACK_PORT })
|
||||
return
|
||||
}
|
||||
|
||||
currentPort = port
|
||||
currentPath = path
|
||||
|
||||
server = createServer(handleRequest)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server!.listen(currentPort, () => {
|
||||
log.info("oauth callback server started", { port: currentPort, path: currentPath })
|
||||
server!.listen(OAUTH_CALLBACK_PORT, () => {
|
||||
log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
|
||||
resolve()
|
||||
})
|
||||
server!.on("error", reject)
|
||||
@@ -198,9 +182,9 @@ export namespace McpOAuthCallback {
|
||||
}
|
||||
}
|
||||
|
||||
export async function isPortInUse(port: number = OAUTH_CALLBACK_PORT): Promise<boolean> {
|
||||
export async function isPortInUse(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = createConnection(port, "127.0.0.1")
|
||||
const socket = createConnection(OAUTH_CALLBACK_PORT, "127.0.0.1")
|
||||
socket.on("connect", () => {
|
||||
socket.destroy()
|
||||
resolve(true)
|
||||
|
||||
@@ -17,7 +17,6 @@ export interface McpOAuthConfig {
|
||||
clientId?: string
|
||||
clientSecret?: string
|
||||
scope?: string
|
||||
redirectUri?: string
|
||||
}
|
||||
|
||||
export interface McpOAuthCallbacks {
|
||||
@@ -33,9 +32,6 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
||||
) {}
|
||||
|
||||
get redirectUrl(): string {
|
||||
if (this.config.redirectUri) {
|
||||
return this.config.redirectUri
|
||||
}
|
||||
return `http://127.0.0.1:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}`
|
||||
}
|
||||
|
||||
@@ -187,22 +183,3 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
||||
}
|
||||
|
||||
export { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH }
|
||||
|
||||
/**
|
||||
* Parse a redirect URI to extract port and path for the callback server.
|
||||
* Returns defaults if the URI can't be parsed.
|
||||
*/
|
||||
export function parseRedirectUri(redirectUri?: string): { port: number; path: string } {
|
||||
if (!redirectUri) {
|
||||
return { port: OAUTH_CALLBACK_PORT, path: OAUTH_CALLBACK_PATH }
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(redirectUri)
|
||||
const port = url.port ? parseInt(url.port, 10) : url.protocol === "https:" ? 443 : 80
|
||||
const path = url.pathname || OAUTH_CALLBACK_PATH
|
||||
return { port, path }
|
||||
} catch {
|
||||
return { port: OAUTH_CALLBACK_PORT, path: OAUTH_CALLBACK_PATH }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,9 +376,9 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
"gpt-5.4",
|
||||
"gpt-5.4-mini",
|
||||
])
|
||||
for (const [modelId, model] of Object.entries(provider.models)) {
|
||||
for (const modelId of Object.keys(provider.models)) {
|
||||
if (modelId.includes("codex")) continue
|
||||
if (allowedModels.has(model.api.id)) continue
|
||||
if (allowedModels.has(modelId)) continue
|
||||
delete provider.models[modelId]
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import { iife } from "@/util/iife"
|
||||
import { Log } from "@/util/log"
|
||||
import { Context } from "../util/context"
|
||||
import { Project } from "./project"
|
||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
import { State } from "./state"
|
||||
|
||||
export interface InstanceContext {
|
||||
@@ -21,9 +20,19 @@ const disposal = {
|
||||
all: undefined as Promise<void> | undefined,
|
||||
}
|
||||
|
||||
function emitDisposed(directory: string) {}
|
||||
function emit(directory: string) {
|
||||
GlobalBus.emit("event", {
|
||||
directory,
|
||||
payload: {
|
||||
type: "server.instance.disposed",
|
||||
properties: {
|
||||
directory,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function boot(input: { directory: string; init?: () => Promise<any>; worktree?: string; project?: Project.Info }) {
|
||||
function boot(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
|
||||
return iife(async () => {
|
||||
const ctx =
|
||||
input.project && input.worktree
|
||||
@@ -84,7 +93,6 @@ export const Instance = {
|
||||
get project() {
|
||||
return context.use().project
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if a path is within the project boundary.
|
||||
* Returns true if path is inside Instance.directory OR Instance.worktree.
|
||||
@@ -123,39 +131,15 @@ export const Instance = {
|
||||
await Promise.all([State.dispose(directory), disposeInstance(directory)])
|
||||
cache.delete(directory)
|
||||
const next = track(directory, boot({ ...input, directory }))
|
||||
|
||||
GlobalBus.emit("event", {
|
||||
directory,
|
||||
project: input.project?.id,
|
||||
workspace: WorkspaceContext.workspaceID,
|
||||
payload: {
|
||||
type: "server.instance.disposed",
|
||||
properties: {
|
||||
directory,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
emit(directory)
|
||||
return await next
|
||||
},
|
||||
async dispose() {
|
||||
const directory = Instance.directory
|
||||
const project = Instance.project
|
||||
Log.Default.info("disposing instance", { directory })
|
||||
await Promise.all([State.dispose(directory), disposeInstance(directory)])
|
||||
cache.delete(directory)
|
||||
|
||||
GlobalBus.emit("event", {
|
||||
directory,
|
||||
project: project.id,
|
||||
workspace: WorkspaceContext.workspaceID,
|
||||
payload: {
|
||||
type: "server.instance.disposed",
|
||||
properties: {
|
||||
directory,
|
||||
},
|
||||
},
|
||||
})
|
||||
emit(directory)
|
||||
},
|
||||
async disposeAll() {
|
||||
if (disposal.all) return disposal.all
|
||||
|
||||
@@ -137,8 +137,6 @@ export namespace Project {
|
||||
const emitUpdated = (data: Info) =>
|
||||
Effect.sync(() =>
|
||||
GlobalBus.emit("event", {
|
||||
directory: "global",
|
||||
project: data.id,
|
||||
payload: { type: Event.Updated.type, properties: data },
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -22,27 +22,6 @@ export namespace ModelsDev {
|
||||
)
|
||||
const ttl = 5 * 60 * 1000
|
||||
|
||||
type JsonValue = string | number | boolean | null | { [key: string]: JsonValue } | JsonValue[]
|
||||
|
||||
const JsonValue: z.ZodType<JsonValue> = z.lazy(() =>
|
||||
z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(JsonValue), z.record(z.string(), JsonValue)]),
|
||||
)
|
||||
|
||||
const Cost = z.object({
|
||||
input: z.number(),
|
||||
output: z.number(),
|
||||
cache_read: z.number().optional(),
|
||||
cache_write: z.number().optional(),
|
||||
context_over_200k: z
|
||||
.object({
|
||||
input: z.number(),
|
||||
output: z.number(),
|
||||
cache_read: z.number().optional(),
|
||||
cache_write: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
|
||||
export const Model = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
@@ -62,7 +41,22 @@ export namespace ModelsDev {
|
||||
.strict(),
|
||||
])
|
||||
.optional(),
|
||||
cost: Cost.optional(),
|
||||
cost: z
|
||||
.object({
|
||||
input: z.number(),
|
||||
output: z.number(),
|
||||
cache_read: z.number().optional(),
|
||||
cache_write: z.number().optional(),
|
||||
context_over_200k: z
|
||||
.object({
|
||||
input: z.number(),
|
||||
output: z.number(),
|
||||
cache_read: z.number().optional(),
|
||||
cache_write: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
limit: z.object({
|
||||
context: z.number(),
|
||||
input: z.number().optional(),
|
||||
@@ -74,24 +68,7 @@ export namespace ModelsDev {
|
||||
output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
|
||||
})
|
||||
.optional(),
|
||||
experimental: z
|
||||
.object({
|
||||
modes: z
|
||||
.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
cost: Cost.optional(),
|
||||
provider: z
|
||||
.object({
|
||||
body: z.record(z.string(), JsonValue).optional(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
experimental: z.boolean().optional(),
|
||||
status: z.enum(["alpha", "beta", "deprecated"]).optional(),
|
||||
provider: z.object({ npm: z.string().optional(), api: z.string().optional() }).optional(),
|
||||
})
|
||||
|
||||
@@ -926,28 +926,6 @@ export namespace Provider {
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Provider") {}
|
||||
|
||||
function cost(c: ModelsDev.Model["cost"]): Model["cost"] {
|
||||
const result: Model["cost"] = {
|
||||
input: c?.input ?? 0,
|
||||
output: c?.output ?? 0,
|
||||
cache: {
|
||||
read: c?.cache_read ?? 0,
|
||||
write: c?.cache_write ?? 0,
|
||||
},
|
||||
}
|
||||
if (c?.context_over_200k) {
|
||||
result.experimentalOver200K = {
|
||||
cache: {
|
||||
read: c.context_over_200k.cache_read ?? 0,
|
||||
write: c.context_over_200k.cache_write ?? 0,
|
||||
},
|
||||
input: c.context_over_200k.input,
|
||||
output: c.context_over_200k.output,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model {
|
||||
const m: Model = {
|
||||
id: ModelID.make(model.id),
|
||||
@@ -962,7 +940,24 @@ export namespace Provider {
|
||||
status: model.status ?? "active",
|
||||
headers: {},
|
||||
options: {},
|
||||
cost: cost(model.cost),
|
||||
cost: {
|
||||
input: model.cost?.input ?? 0,
|
||||
output: model.cost?.output ?? 0,
|
||||
cache: {
|
||||
read: model.cost?.cache_read ?? 0,
|
||||
write: model.cost?.cache_write ?? 0,
|
||||
},
|
||||
experimentalOver200K: model.cost?.context_over_200k
|
||||
? {
|
||||
cache: {
|
||||
read: model.cost.context_over_200k.cache_read ?? 0,
|
||||
write: model.cost.context_over_200k.cache_write ?? 0,
|
||||
},
|
||||
input: model.cost.context_over_200k.input,
|
||||
output: model.cost.context_over_200k.output,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
limit: {
|
||||
context: model.limit.context,
|
||||
input: model.limit.input,
|
||||
@@ -999,31 +994,13 @@ export namespace Provider {
|
||||
}
|
||||
|
||||
export function fromModelsDevProvider(provider: ModelsDev.Provider): Info {
|
||||
const models: Record<string, Model> = {}
|
||||
for (const [key, model] of Object.entries(provider.models)) {
|
||||
models[key] = fromModelsDevModel(provider, model)
|
||||
for (const [mode, opts] of Object.entries(model.experimental?.modes ?? {})) {
|
||||
const id = `${model.id}-${mode}`
|
||||
const m = fromModelsDevModel(provider, model)
|
||||
m.id = ModelID.make(id)
|
||||
m.name = `${model.name} ${mode[0].toUpperCase()}${mode.slice(1)}`
|
||||
if (opts.cost) m.cost = mergeDeep(m.cost, cost(opts.cost))
|
||||
// convert body params to camelCase for ai sdk compatibility
|
||||
if (opts.provider?.body)
|
||||
m.options = Object.fromEntries(
|
||||
Object.entries(opts.provider.body).map(([k, v]) => [k.replace(/_([a-z])/g, (_, c) => c.toUpperCase()), v]),
|
||||
)
|
||||
if (opts.provider?.headers) m.headers = opts.provider.headers
|
||||
models[id] = m
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: ProviderID.make(provider.id),
|
||||
source: "custom",
|
||||
name: provider.name,
|
||||
env: provider.env ?? [],
|
||||
options: {},
|
||||
models,
|
||||
models: mapValues(provider.models, (model) => fromModelsDevModel(provider, model)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,9 +9,6 @@ import { Filesystem } from "@/util/filesystem"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||
import { InstanceRoutes } from "./instance"
|
||||
import { Session } from "@/session"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
|
||||
type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" }
|
||||
|
||||
@@ -29,16 +26,6 @@ function local(method: string, path: string) {
|
||||
return false
|
||||
}
|
||||
|
||||
async function getSessionWorkspace(url: URL) {
|
||||
if (url.pathname === "/session/status") return null
|
||||
|
||||
const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1]
|
||||
if (!id) return null
|
||||
|
||||
const session = await Session.get(SessionID.make(id)).catch(() => undefined)
|
||||
return session?.workspaceID
|
||||
}
|
||||
|
||||
export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): MiddlewareHandler {
|
||||
const routes = lazy(() => InstanceRoutes(upgrade))
|
||||
|
||||
@@ -55,12 +42,13 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
||||
)
|
||||
|
||||
const url = new URL(c.req.url)
|
||||
const workspaceParam = url.searchParams.get("workspace") || c.req.header("x-opencode-workspace")
|
||||
|
||||
const sessionWorkspaceID = await getSessionWorkspace(url)
|
||||
const workspaceID = sessionWorkspaceID || url.searchParams.get("workspace")
|
||||
// TODO: If session is being routed, force it to lookup the
|
||||
// project/workspace
|
||||
|
||||
// If no workspace is provided we use the project
|
||||
if (!workspaceID) {
|
||||
// If no workspace is provided we use the "project" workspace
|
||||
if (!workspaceParam) {
|
||||
return Instance.provide({
|
||||
directory,
|
||||
init: InstanceBootstrap,
|
||||
@@ -70,7 +58,8 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
||||
})
|
||||
}
|
||||
|
||||
const workspace = await Workspace.get(WorkspaceID.make(workspaceID))
|
||||
const workspaceID = WorkspaceID.make(workspaceParam)
|
||||
const workspace = await Workspace.get(workspaceID)
|
||||
if (!workspace) {
|
||||
return new Response(`Workspace not found: ${workspaceID}`, {
|
||||
status: 500,
|
||||
@@ -84,16 +73,12 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
||||
const target = await adaptor.target(workspace)
|
||||
|
||||
if (target.type === "local") {
|
||||
return WorkspaceContext.provide({
|
||||
workspaceID: WorkspaceID.make(workspaceID),
|
||||
fn: () =>
|
||||
Instance.provide({
|
||||
directory: target.directory,
|
||||
init: InstanceBootstrap,
|
||||
async fn() {
|
||||
return routes().fetch(c.req.raw, c.env)
|
||||
},
|
||||
}),
|
||||
return Instance.provide({
|
||||
directory: target.directory,
|
||||
init: InstanceBootstrap,
|
||||
async fn() {
|
||||
return routes().fetch(c.req.raw, c.env)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -105,8 +105,6 @@ export const GlobalRoutes = lazy(() =>
|
||||
z
|
||||
.object({
|
||||
directory: z.string(),
|
||||
project: z.string().optional(),
|
||||
workspace: z.string().optional(),
|
||||
payload: BusEvent.payloads(),
|
||||
})
|
||||
.meta({
|
||||
|
||||
@@ -121,6 +121,7 @@ export const SessionRoutes = lazy(() =>
|
||||
),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
log.info("SEARCH", { url: c.req.url })
|
||||
const session = await Session.get(sessionID)
|
||||
return c.json(session)
|
||||
},
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Installation } from "../installation"
|
||||
import { Database, NotFoundError, eq, and, gte, isNull, desc, like, inArray, lt } from "../storage/db"
|
||||
import { SyncEvent } from "../sync"
|
||||
import type { SQL } from "../storage/db"
|
||||
import { PartTable, SessionTable } from "./session.sql"
|
||||
import { SessionTable } from "./session.sql"
|
||||
import { ProjectTable } from "../project/project.sql"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { Log } from "../util/log"
|
||||
@@ -345,11 +345,6 @@ export namespace Session {
|
||||
messageID: MessageID
|
||||
partID: PartID
|
||||
}) => Effect.Effect<PartID>
|
||||
readonly getPart: (input: {
|
||||
sessionID: SessionID
|
||||
messageID: MessageID
|
||||
partID: PartID
|
||||
}) => Effect.Effect<MessageV2.Part | undefined>
|
||||
readonly updatePart: <T extends MessageV2.Part>(part: T) => Effect.Effect<T>
|
||||
readonly updatePartDelta: (input: {
|
||||
sessionID: SessionID
|
||||
@@ -497,29 +492,6 @@ export namespace Session {
|
||||
return part
|
||||
}).pipe(Effect.withSpan("Session.updatePart"))
|
||||
|
||||
const getPart: Interface["getPart"] = Effect.fn("Session.getPart")(function* (input) {
|
||||
const row = Database.use((db) =>
|
||||
db
|
||||
.select()
|
||||
.from(PartTable)
|
||||
.where(
|
||||
and(
|
||||
eq(PartTable.session_id, input.sessionID),
|
||||
eq(PartTable.message_id, input.messageID),
|
||||
eq(PartTable.id, input.partID),
|
||||
),
|
||||
)
|
||||
.get(),
|
||||
)
|
||||
if (!row) return
|
||||
return {
|
||||
...row.data,
|
||||
id: row.id,
|
||||
sessionID: row.session_id,
|
||||
messageID: row.message_id,
|
||||
} as MessageV2.Part
|
||||
})
|
||||
|
||||
const create = Effect.fn("Session.create")(function* (input?: {
|
||||
parentID?: SessionID
|
||||
title?: string
|
||||
@@ -703,7 +675,6 @@ export namespace Session {
|
||||
removeMessage,
|
||||
removePart,
|
||||
updatePart,
|
||||
getPart,
|
||||
updatePartDelta,
|
||||
initialize,
|
||||
})
|
||||
|
||||
@@ -176,22 +176,12 @@ export namespace SessionProcessor {
|
||||
if (ctx.assistantMessage.summary) {
|
||||
throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`)
|
||||
}
|
||||
const pointer = ctx.toolcalls[value.toolCallId]
|
||||
const match = yield* session.getPart({
|
||||
partID: pointer.id,
|
||||
messageID: pointer.messageID,
|
||||
sessionID: pointer.sessionID,
|
||||
})
|
||||
if (!match || match.type !== "tool") return
|
||||
const match = ctx.toolcalls[value.toolCallId]
|
||||
if (!match) return
|
||||
ctx.toolcalls[value.toolCallId] = yield* session.updatePart({
|
||||
...match,
|
||||
tool: value.toolName,
|
||||
state: {
|
||||
...match.state,
|
||||
status: "running",
|
||||
input: value.input,
|
||||
time: { start: Date.now() },
|
||||
},
|
||||
state: { status: "running", input: value.input, time: { start: Date.now() } },
|
||||
metadata: match.metadata?.providerExecuted
|
||||
? { ...value.providerMetadata, providerExecuted: true }
|
||||
: value.providerMetadata,
|
||||
@@ -247,7 +237,6 @@ export namespace SessionProcessor {
|
||||
case "tool-error": {
|
||||
const match = ctx.toolcalls[value.toolCallId]
|
||||
if (!match || match.state.status !== "running") return
|
||||
|
||||
yield* session.updatePart({
|
||||
...match,
|
||||
state: {
|
||||
|
||||
@@ -9,7 +9,6 @@ import { SessionPrompt } from "../session/prompt"
|
||||
import { Config } from "../config/config"
|
||||
import { Permission } from "@/permission"
|
||||
import { Effect } from "effect"
|
||||
import { Log } from "@/util/log"
|
||||
|
||||
const id = "task"
|
||||
|
||||
|
||||
@@ -245,7 +245,6 @@ export namespace Worktree {
|
||||
|
||||
const boot = Effect.fnUntraced(function* (info: Info, startCommand?: string) {
|
||||
const ctx = yield* InstanceState.context
|
||||
const workspaceID = yield* InstanceState.workspaceID
|
||||
const projectID = ctx.project.id
|
||||
const extra = startCommand?.trim()
|
||||
|
||||
@@ -255,8 +254,6 @@ export namespace Worktree {
|
||||
log.error("worktree checkout failed", { directory: info.directory, message })
|
||||
GlobalBus.emit("event", {
|
||||
directory: info.directory,
|
||||
project: ctx.project.id,
|
||||
workspace: workspaceID,
|
||||
payload: { type: Event.Failed.type, properties: { message } },
|
||||
})
|
||||
return
|
||||
@@ -274,8 +271,6 @@ export namespace Worktree {
|
||||
log.error("worktree bootstrap failed", { directory: info.directory, message })
|
||||
GlobalBus.emit("event", {
|
||||
directory: info.directory,
|
||||
project: ctx.project.id,
|
||||
workspace: workspaceID,
|
||||
payload: { type: Event.Failed.type, properties: { message } },
|
||||
})
|
||||
return false
|
||||
@@ -285,8 +280,6 @@ export namespace Worktree {
|
||||
|
||||
GlobalBus.emit("event", {
|
||||
directory: info.directory,
|
||||
project: ctx.project.id,
|
||||
workspace: workspaceID,
|
||||
payload: {
|
||||
type: Event.Ready.type,
|
||||
properties: { name: info.name, branch: info.branch },
|
||||
|
||||
@@ -1,293 +0,0 @@
|
||||
/** @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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,175 +0,0 @@
|
||||
/** @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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,34 +0,0 @@
|
||||
import { test, expect, describe, afterEach } from "bun:test"
|
||||
import { McpOAuthCallback } from "../../src/mcp/oauth-callback"
|
||||
import { parseRedirectUri } from "../../src/mcp/oauth-provider"
|
||||
|
||||
describe("parseRedirectUri", () => {
|
||||
test("returns defaults when no URI provided", () => {
|
||||
const result = parseRedirectUri()
|
||||
expect(result.port).toBe(19876)
|
||||
expect(result.path).toBe("/mcp/oauth/callback")
|
||||
})
|
||||
|
||||
test("parses port and path from URI", () => {
|
||||
const result = parseRedirectUri("http://127.0.0.1:8080/oauth/callback")
|
||||
expect(result.port).toBe(8080)
|
||||
expect(result.path).toBe("/oauth/callback")
|
||||
})
|
||||
|
||||
test("returns defaults for invalid URI", () => {
|
||||
const result = parseRedirectUri("not-a-valid-url")
|
||||
expect(result.port).toBe(19876)
|
||||
expect(result.path).toBe("/mcp/oauth/callback")
|
||||
})
|
||||
})
|
||||
|
||||
describe("McpOAuthCallback.ensureRunning", () => {
|
||||
afterEach(async () => {
|
||||
await McpOAuthCallback.stop()
|
||||
})
|
||||
|
||||
test("starts server with custom redirectUri port and path", async () => {
|
||||
await McpOAuthCallback.ensureRunning("http://127.0.0.1:18000/custom/callback")
|
||||
expect(McpOAuthCallback.isRunning()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -22,40 +22,56 @@ const ITERATIONS = 50
|
||||
|
||||
const getHeapMB = () => {
|
||||
Bun.gc(true)
|
||||
Bun.sleepSync(25)
|
||||
return process.memoryUsage().heapUsed / MB
|
||||
}
|
||||
|
||||
describe("memory: abort controller leak", () => {
|
||||
test("webfetch does not leak memory over many invocations", async () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const tool = await WebFetchTool.init()
|
||||
test("webfetch clears abort timers over many invocations", async () => {
|
||||
type TimerID = number
|
||||
|
||||
// Warm up
|
||||
await tool.execute({ url: "https://example.com", format: "text" }, ctx).catch(() => {})
|
||||
const prevFetch = globalThis.fetch
|
||||
const prevSetTimeout = globalThis.setTimeout
|
||||
const prevClearTimeout = globalThis.clearTimeout
|
||||
const active = new Set<TimerID>()
|
||||
|
||||
Bun.gc(true)
|
||||
const baseline = getHeapMB()
|
||||
globalThis.fetch = (async () =>
|
||||
new Response("hello from webfetch", {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "text/plain; charset=utf-8",
|
||||
},
|
||||
})) as unknown as typeof fetch
|
||||
globalThis.setTimeout = ((handler: TimerHandler, timeout?: number, ...args: any[]) => {
|
||||
const id = prevSetTimeout(handler, timeout, ...args) as unknown as TimerID
|
||||
active.add(id)
|
||||
return id as unknown as ReturnType<typeof setTimeout>
|
||||
}) as unknown as typeof setTimeout
|
||||
globalThis.clearTimeout = ((id?: Parameters<typeof clearTimeout>[0]) => {
|
||||
if (id !== undefined) active.delete(id as unknown as TimerID)
|
||||
return prevClearTimeout(id)
|
||||
}) as unknown as typeof clearTimeout
|
||||
|
||||
try {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const tool = await WebFetchTool.init()
|
||||
|
||||
// Run many fetches
|
||||
for (let i = 0; i < ITERATIONS; i++) {
|
||||
await tool.execute({ url: "https://example.com", format: "text" }, ctx).catch(() => {})
|
||||
}
|
||||
|
||||
Bun.gc(true)
|
||||
const after = getHeapMB()
|
||||
const growth = after - baseline
|
||||
for (let i = 0; i < ITERATIONS; i++) {
|
||||
await tool.execute({ url: "https://example.com", format: "text" }, ctx).catch(() => {})
|
||||
}
|
||||
|
||||
console.log(`Baseline: ${baseline.toFixed(2)} MB`)
|
||||
console.log(`After ${ITERATIONS} fetches: ${after.toFixed(2)} MB`)
|
||||
console.log(`Growth: ${growth.toFixed(2)} MB`)
|
||||
|
||||
// Memory growth should be minimal - less than 1MB per 10 requests
|
||||
// With the old closure pattern, this would grow ~0.5MB per request
|
||||
expect(growth).toBeLessThan(ITERATIONS / 10)
|
||||
},
|
||||
})
|
||||
expect(active.size).toBe(0)
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
globalThis.fetch = prevFetch
|
||||
globalThis.setTimeout = prevSetTimeout
|
||||
globalThis.clearTimeout = prevClearTimeout
|
||||
}
|
||||
}, 60000)
|
||||
|
||||
test("compare closure vs bind pattern directly", async () => {
|
||||
|
||||
@@ -6,7 +6,6 @@ import { tmpdir } from "../fixture/fixture"
|
||||
import { Global } from "../../src/global"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Plugin } from "../../src/plugin/index"
|
||||
import { ModelsDev } from "../../src/provider/models"
|
||||
import { Provider } from "../../src/provider/provider"
|
||||
import { ProviderID, ModelID } from "../../src/provider/schema"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
@@ -1824,73 +1823,6 @@ test("custom model inherits api.url from models.dev provider", async () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("mode cost preserves over-200k pricing from base model", () => {
|
||||
const provider = {
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
env: [],
|
||||
api: "https://api.openai.com/v1",
|
||||
models: {
|
||||
"gpt-5.4": {
|
||||
id: "gpt-5.4",
|
||||
name: "GPT-5.4",
|
||||
family: "gpt",
|
||||
release_date: "2026-03-05",
|
||||
attachment: true,
|
||||
reasoning: true,
|
||||
temperature: false,
|
||||
tool_call: true,
|
||||
cost: {
|
||||
input: 2.5,
|
||||
output: 15,
|
||||
cache_read: 0.25,
|
||||
context_over_200k: {
|
||||
input: 5,
|
||||
output: 22.5,
|
||||
cache_read: 0.5,
|
||||
},
|
||||
},
|
||||
limit: {
|
||||
context: 1_050_000,
|
||||
input: 922_000,
|
||||
output: 128_000,
|
||||
},
|
||||
experimental: {
|
||||
modes: {
|
||||
fast: {
|
||||
cost: {
|
||||
input: 5,
|
||||
output: 30,
|
||||
cache_read: 0.5,
|
||||
},
|
||||
provider: {
|
||||
body: {
|
||||
service_tier: "priority",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ModelsDev.Provider
|
||||
|
||||
const model = Provider.fromModelsDevProvider(provider).models["gpt-5.4-fast"]
|
||||
expect(model.cost.input).toEqual(5)
|
||||
expect(model.cost.output).toEqual(30)
|
||||
expect(model.cost.cache.read).toEqual(0.5)
|
||||
expect(model.cost.cache.write).toEqual(0)
|
||||
expect(model.options["serviceTier"]).toEqual("priority")
|
||||
expect(model.cost.experimentalOver200K).toEqual({
|
||||
input: 5,
|
||||
output: 22.5,
|
||||
cache: {
|
||||
read: 0.5,
|
||||
write: 0,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("model variants are generated for reasoning models", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.1",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.1",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1011,8 +1011,6 @@ export type Event =
|
||||
|
||||
export type GlobalEvent = {
|
||||
directory: string
|
||||
project?: string
|
||||
workspace?: string
|
||||
payload: Event
|
||||
}
|
||||
|
||||
@@ -1377,10 +1375,6 @@ export type McpOAuthConfig = {
|
||||
* OAuth scopes to request during authorization
|
||||
*/
|
||||
scope?: string
|
||||
/**
|
||||
* OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).
|
||||
*/
|
||||
redirectUri?: string
|
||||
}
|
||||
|
||||
export type McpRemoteConfig = {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createOpencodeServer } from "./server.js"
|
||||
import type { ServerOptions } from "./server.js"
|
||||
|
||||
export * as data from "./data.js"
|
||||
import * as data from "./data.js"
|
||||
|
||||
export async function createOpencode(options?: ServerOptions) {
|
||||
const server = await createOpencodeServer({
|
||||
|
||||
@@ -9926,12 +9926,6 @@
|
||||
"directory": {
|
||||
"type": "string"
|
||||
},
|
||||
"project": {
|
||||
"type": "string"
|
||||
},
|
||||
"workspace": {
|
||||
"type": "string"
|
||||
},
|
||||
"payload": {
|
||||
"$ref": "#/components/schemas/Event"
|
||||
}
|
||||
@@ -10888,10 +10882,6 @@
|
||||
"scope": {
|
||||
"description": "OAuth scopes to request during authorization",
|
||||
"type": "string"
|
||||
},
|
||||
"redirectUri": {
|
||||
"description": "OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.1",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.1",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@opencode-ai/web",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.1",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "opencode",
|
||||
"displayName": "opencode",
|
||||
"description": "opencode for VS Code",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.1",
|
||||
"publisher": "sst-dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Reference in New Issue
Block a user