This commit is contained in:
LukeParkerDev
2026-05-04 11:20:57 +10:00
parent 6ada6e0db9
commit fcb4bc1b3a
48 changed files with 1766 additions and 2107 deletions

View File

@@ -715,7 +715,7 @@
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.57",
"effect": "4.0.0-beta.59",
"fuzzysort": "3.1.0",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
@@ -3078,7 +3078,7 @@
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"effect": ["effect@4.0.0-beta.57", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-rg32VgXnLKaPRs9tbRDaZ5jxmzNY7ojXt85gSHGUTwdlbWH5Ik+OCUY2q14TXliygPGoHwCAvNWS4bQJOqf00g=="],
"effect": ["effect@4.0.0-beta.59", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-xyUDLeHSe8d6lWGOvR6Fgn2HL6gYeTZ/S4Jzk9uc4ZUxMPPsNZlNXrvk0C7/utQFzeX7uAWcVnG2BjbA0SRoAA=="],
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-SLWRe4uPSRWgU+NPa1BywmrUtNVIC0Oy2mjmxclxk+s=",
"aarch64-linux": "sha256-toHEeIqMzrmThoV0B52juGKm4pa/aJN3gBFFtrSZp2Q=",
"aarch64-darwin": "sha256-lYUsUxq5zR2RXjqZTEdjduOncnlwvTlxDJVKWXJuKPY=",
"x86_64-darwin": "sha256-77XmuEYqGwb1mkEHfnghq1VtukFTneohA0FW6WDOk1U="
"x86_64-linux": "sha256-9wTDLZsuGjkWyVOb6AG2VRYPiaSj/lnXwVkSwNeDcns=",
"aarch64-linux": "sha256-gmKlL2fQxY8bo+//8m9e1TNYJK3RXa4i8xsgtd046bc=",
"aarch64-darwin": "sha256-ENSJK+7rZi3m342mjtGg9N0P6zWEypXMpI7QdFMydbc=",
"x86_64-darwin": "sha256-gkxCxGh5dlwj03vZdz20pbiAwFEDpAlu/5iU8cwZOGI="
}
}

View File

@@ -53,7 +53,7 @@
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.57",
"effect": "4.0.0-beta.59",
"ai": "6.0.168",
"cross-spawn": "7.0.6",
"hono": "4.10.7",

View File

@@ -1,8 +1,6 @@
import "@/index.css"
import { Button } from "@opencode-ai/ui/button"
import * as Sentry from "@sentry/solid"
import { I18nProvider } from "@opencode-ai/ui/context"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
@@ -13,9 +11,12 @@ import { ThemeProvider } from "@opencode-ai/ui/theme/context"
import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { Effect } from "effect"
import {
type Component,
createMemo,
createResource,
createSignal,
ErrorBoundary,
For,
type JSX,
@@ -23,7 +24,6 @@ import {
onCleanup,
type ParentProps,
Show,
startTransition,
Suspense,
} from "solid-js"
import { Dynamic } from "solid-js/web"
@@ -38,7 +38,6 @@ import { LayoutProvider } from "@/context/layout"
import { ModelsProvider } from "@/context/models"
import { NotificationProvider } from "@/context/notification"
import { PermissionProvider } from "@/context/permission"
import { usePlatform } from "@/context/platform"
import { PromptProvider } from "@/context/prompt"
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
import { SettingsProvider } from "@/context/settings"
@@ -47,6 +46,7 @@ import { WslServersProvider } from "@/context/wsl-servers"
import DirectoryLayout from "@/pages/directory-layout"
import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health"
const HomeRoute = lazy(() => import("@/pages/home"))
const loadSession = () => import("@/pages/session")
@@ -75,6 +75,7 @@ declare global {
__OPENCODE__?: {
updaterEnabled?: boolean
deepLinks?: string[]
wsl?: boolean
activeServer?: string
}
api?: {
@@ -175,48 +176,80 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
const server = useServer()
const healthy = createMemo(() => props.disableHealthCheck || server.healthy())
const checkServerHealth = useCheckServerHealth()
const splash = (
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
</div>
const [checkMode, setCheckMode] = createSignal<"blocking" | "background">("blocking")
// performs repeated health check with a grace period for
// non-http connections, otherwise fails instantly
const [startupHealthCheck, healthCheckActions] = createResource(() =>
props.disableHealthCheck
? true
: Effect.gen(function* () {
if (!server.current) return true
const { http, type } = server.current
while (true) {
const res = yield* Effect.promise(() => checkServerHealth(http))
if (res.healthy) return true
if (checkMode() === "background" || type === "http") return false
}
}).pipe(
Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.succeed(false) }),
Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
Effect.runPromise,
),
)
return (
<Show when={server.ready()} fallback={splash}>
<Suspense fallback={splash}>
<Show when={healthy() !== undefined} fallback={splash}>
<Show
when={healthy()}
fallback={
<ConnectionError
onServerSelected={(key) => {
startTransition(() => {
server.setActive(key)
})
}}
/>
}
>
{props.children}
</Show>
</Show>
</Suspense>
</Show>
<Suspense
fallback={
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
</div>
}
>
{/*<Show
when={checkMode() === "blocking" ? !startupHealthCheck.loading : startupHealthCheck.state !== "pending"}
fallback={
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
</div>
}
>*/}
{checkMode() === "blocking" ? startupHealthCheck() : startupHealthCheck.latest}
<Show
when={startupHealthCheck()}
fallback={
<ConnectionError
onRetry={() => {
if (checkMode() === "background") void healthCheckActions.refetch()
}}
onServerSelected={(key) => {
setCheckMode("blocking")
server.setActive(key)
void healthCheckActions.refetch()
}}
/>
}
>
{props.children}
</Show>
{/*</Show>*/}
</Suspense>
)
}
function ConnectionError(props: { onServerSelected?: (key: ServerConnection.Key) => void }) {
const dialog = useDialog()
function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: ServerConnection.Key) => void }) {
const language = useLanguage()
const platform = usePlatform()
const server = useServer()
const others = () => server.list.filter((s) => ServerConnection.key(s) !== server.key)
const name = createMemo(() => server.name || server.key)
const serverToken = "\u0000server\u0000"
const unreachable = createMemo(() => language.t("app.server.unreachable", { server: serverToken }).split(serverToken))
const canManage = createMemo(() => server.current?.type === "sidecar" && server.current?.variant === "wsl")
const timer = setInterval(() => props.onRetry?.(), 1000)
onCleanup(() => clearInterval(timer))
return (
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base gap-6 p-6">
@@ -228,34 +261,6 @@ function ConnectionError(props: { onServerSelected?: (key: ServerConnection.Key)
{unreachable()[1]}
</p>
<p class="mt-1 text-12-regular text-text-weak">{language.t("app.server.retrying")}</p>
<Show when={canManage() && !!platform.wslServers}>
<Button
variant="secondary"
size="large"
class="mt-4"
onClick={() => {
void import("@/components/dialog-select-server")
.then((x) => {
dialog.show(() => (
<x.DialogSelectServer
onNavigateHome={() => {
// We're above the Router here so useNavigate() isn't available.
// Update the browser URL directly; after server.setActive fires
// ServerKey remounts the Router which picks up "/" on init.
// Harmless under MemoryRouter (Electron), which restarts at "/".
if (typeof window !== "undefined" && window.history?.replaceState) {
window.history.replaceState(null, "", "/")
}
}}
/>
))
})
.catch((err) => console.error("Failed to load server dialog", err))
}}
>
Manage servers
</Button>
</Show>
</div>
<Show when={others().length > 0}>
<div class="flex flex-col gap-2 w-full max-w-sm">
@@ -295,21 +300,13 @@ export function AppInterface(props: {
children?: JSX.Element
defaultServer: ServerConnection.Key
servers?: Array<ServerConnection.Any>
serversReady?: boolean
router?: Component<BaseRouterProps>
disableHealthCheck?: boolean
}) {
// ServerKey wraps the whole Router so that switching `server.key` throws
// away any session / pty state from the previous server. Preserving the
// route across servers doesn't work because session ids, pty ids, and
// most URL-addressable resources are server-scoped — you'd 404 on every
// fetch. The click handler that swaps servers also navigates back to "/"
// so the fresh MemoryRouter doesn't try to re-resolve a now-dead URL.
return (
<ServerProvider
defaultServer={props.defaultServer}
disableHealthCheck={props.disableHealthCheck}
serversReady={props.serversReady}
servers={props.servers}
>
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>

View File

@@ -8,7 +8,7 @@ import { List } from "@opencode-ai/ui/list"
import { TextField } from "@opencode-ai/ui/text-field"
import { useMutation } from "@tanstack/solid-query"
import { showToast } from "@opencode-ai/ui/toast"
import { batch, createEffect, createMemo, onCleanup, Show, startTransition, untrack } from "solid-js"
import { batch, createEffect, createMemo, createResource, onCleanup, Show, startTransition, untrack } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { DialogWslServer } from "@/components/dialog-wsl-server"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
@@ -75,6 +75,32 @@ function isWslSidecar(conn: ServerConnection.Any): conn is ServerConnection.Side
return conn.type === "sidecar" && conn.variant === "wsl"
}
function useDefaultServer() {
const language = useLanguage()
const platform = usePlatform()
const [defaultKey, defaultActions] = createResource(
async () => {
try {
return (await platform.getDefaultServer?.()) ?? null
} catch (err) {
showRequestError(language, err)
return null
}
},
{ initialValue: null },
)
const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer)
const setDefault = async (key: ServerConnection.Key | null) => {
try {
await platform.setDefaultServer?.(key)
defaultActions.mutate(key)
} catch (err) {
showRequestError(language, err)
}
}
return { defaultKey, canDefault, setDefault }
}
function ServerForm(props: ServerFormProps) {
const language = useLanguage()
const keyDown = (event: KeyboardEvent) => {
@@ -146,6 +172,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
const platform = usePlatform()
const language = useLanguage()
const wslServers = useWslServers()
const defaultServer = useDefaultServer()
const checkServerHealth = useCheckServerHealth()
let disposed = false
onCleanup(() => {
@@ -271,7 +298,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
},
onSuccess: async (key) => {
server.remove(key)
if (server.defaultKey() === key) await server.setDefault(null)
if (defaultServer.defaultKey() === key) await defaultServer.setDefault(null)
},
onError: (err) => showRequestError(language, err),
}))
@@ -546,7 +573,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
async function handleRemove(key: ServerConnection.Key) {
server.remove(key)
if (server.defaultKey() === key) await server.setDefault(null)
if (defaultServer.defaultKey() === key) await defaultServer.setDefault(null)
}
return (
@@ -603,7 +630,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
const wsl = isWslSidecar(i)
const wslDistro = wsl ? i.distro : undefined
const blocked = () => health(key)?.healthy === false
const canChangeDefault = () => server.canDefault() && i.type !== "ssh"
const canChangeDefault = () => defaultServer.canDefault() && i.type !== "ssh"
const canRemove = () => i.type === "http" || wsl
const outdated = () => {
const check = wslCheck(i)
@@ -632,7 +659,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
version={wslCheck(i)?.version ?? undefined}
class="flex items-center gap-3 min-w-0 flex-1"
badge={
<Show when={server.defaultKey() === ServerConnection.key(i)}>
<Show when={defaultServer.defaultKey() === ServerConnection.key(i)}>
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")}
</span>
@@ -689,15 +716,15 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
<DropdownMenu.ItemLabel>Retry start</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={canChangeDefault() && server.defaultKey() !== key}>
<DropdownMenu.Item onSelect={() => void server.setDefault(key)}>
<Show when={canChangeDefault() && defaultServer.defaultKey() !== key}>
<DropdownMenu.Item onSelect={() => void defaultServer.setDefault(key)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={canChangeDefault() && server.defaultKey() === key}>
<DropdownMenu.Item onSelect={() => void server.setDefault(null)}>
<Show when={canChangeDefault() && defaultServer.defaultKey() === key}>
<DropdownMenu.Item onSelect={() => void defaultServer.setDefault(null)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel>

View File

@@ -1253,11 +1253,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
const [agentsQuery, globalProvidersQuery, providersQuery] = useQueries(() => ({
queries: [
loadAgentsQuery(sdk.directory),
loadProvidersQuery(null),
loadProvidersQuery(sdk.directory),
],
queries: [loadAgentsQuery(sdk.directory), loadProvidersQuery(null), loadProvidersQuery(sdk.directory)],
}))
const agentsLoading = () => agentsQuery.isLoading

View File

@@ -6,10 +6,9 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { Keybind } from "@opencode-ai/ui/keybind"
import { Spinner } from "@opencode-ai/ui/spinner"
import { showToast } from "@opencode-ai/ui/toast"
import { StatusPopover } from "../status-popover"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { getFilename } from "@opencode-ai/core/util/path"
import { createEffect, createMemo, For, onCleanup, onMount, Show } from "solid-js"
import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web"
import { useCommand } from "@/context/command"
@@ -25,6 +24,7 @@ import { useSessionLayout } from "@/pages/session/session-layout"
import { messageAgentColor } from "@/utils/agent"
import { decode64 } from "@/utils/base64"
import { Persist, persisted } from "@/utils/persist"
import { StatusPopover } from "../status-popover"
const OPEN_APPS = [
"vscode",
@@ -129,13 +129,6 @@ const showRequestError = (language: ReturnType<typeof useLanguage>, err: unknown
})
}
function titlebarMounts() {
return {
center: document.getElementById("opencode-titlebar-center") as HTMLDivElement | undefined,
right: document.getElementById("opencode-titlebar-right") as HTMLDivElement | undefined,
}
}
export function SessionHeader() {
const layout = useLayout()
const command = useCommand()
@@ -226,7 +219,6 @@ export function SessionHeader() {
const [openRequest, setOpenRequest] = createStore({
app: undefined as OpenApp | undefined,
})
const [mounts, setMounts] = createStore(titlebarMounts())
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
const current = createMemo(
@@ -240,19 +232,6 @@ export function SessionHeader() {
messageAgentColor(params.id ? sync.data.message[params.id] : undefined, sync.data.agent),
)
const syncMounts = () => {
const next = titlebarMounts()
if (mounts.center === next.center && mounts.right === next.right) return
setMounts(next)
}
onMount(() => {
syncMounts()
const observer = new MutationObserver(() => syncMounts())
observer.observe(document.body, { childList: true, subtree: true })
onCleanup(() => observer.disconnect())
})
const selectApp = (app: OpenApp) => {
if (!options().some((item) => item.id === app)) return
setPrefs("app", app)
@@ -290,8 +269,12 @@ export function SessionHeader() {
.catch((err: unknown) => showRequestError(language, err))
}
const centerMount = createMemo(() => mounts.center)
const rightMount = createMemo(() => mounts.right)
const [centerMount, setCenterMount] = createSignal<HTMLElement | null>(null)
const [rightMount, setRightMount] = createSignal<HTMLElement | null>(null)
onMount(() => {
setCenterMount(document.getElementById("opencode-titlebar-center"))
setRightMount(document.getElementById("opencode-titlebar-right"))
})
return (
<>

View File

@@ -6,15 +6,16 @@ import { Tabs } from "@opencode-ai/ui/tabs"
import { useMutation, useQueryClient } from "@tanstack/solid-query"
import { showToast } from "@opencode-ai/ui/toast"
import { useNavigate } from "@solidjs/router"
import { type Accessor, batch, createEffect, createMemo, For, type JSXElement, onCleanup, Show, startTransition } from "solid-js"
import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { loadMcpQuery } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
import { ServerConnection, useServer } from "@/context/server"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
import { loadMcpQuery } from "@/context/global-sync"
const pollMs = 10_000
@@ -36,7 +37,7 @@ const listServersByHealth = (
status: Record<ServerConnection.Key, ServerHealth | undefined>,
) => {
if (!list.length) return list
const order = new Map(list.map((conn, index) => [conn, index] as const))
const order = new Map(list.map((url, index) => [url, index] as const))
const rank = (value?: ServerHealth) => {
if (value?.healthy === true) return 0
if (value?.healthy === false) return 2
@@ -65,7 +66,7 @@ const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, enabled: Acc
let dead = false
const refresh = async () => {
const results: Record<ServerConnection.Key, ServerHealth> = {}
const results: Record<string, ServerHealth> = {}
await Promise.all(
list.map(async (conn) => {
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
@@ -86,6 +87,53 @@ const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, enabled: Acc
return status
}
const useDefaultServerKey = (
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
) => {
const [state, setState] = createStore({
url: undefined as string | undefined,
tick: 0,
})
createEffect(() => {
state.tick
let dead = false
const result = get?.()
if (!result) {
setState("url", undefined)
onCleanup(() => {
dead = true
})
return
}
if (result instanceof Promise) {
void result.then((next) => {
if (dead) return
setState("url", next ? normalizeServerUrl(next) : undefined)
})
onCleanup(() => {
dead = true
})
return
}
setState("url", normalizeServerUrl(result))
onCleanup(() => {
dead = true
})
})
return {
key: () => {
const u = state.url
if (!u) return
return ServerConnection.key({ type: "http", http: { url: u } })
},
refresh: () => setState("tick", (value) => value + 1),
}
}
const useMcpToggleMutation = () => {
const sync = useSync()
const sdk = useSDK()
@@ -111,10 +159,23 @@ const useMcpToggleMutation = () => {
export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
const sync = useSync()
const server = useServer()
const platform = usePlatform()
const dialog = useDialog()
const language = useLanguage()
const navigate = useNavigate()
const fail = (err: unknown) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
}
createEffect(() => {
if (!props.shown()) return
})
let dialogRun = 0
let dialogDead = false
onCleanup(() => {
@@ -131,6 +192,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
const health = useServerHealth(servers, props.shown)
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
const toggleMcp = useMcpToggleMutation()
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
@@ -189,18 +251,8 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
aria-disabled={blocked()}
onClick={() => {
if (blocked()) return
startTransition(() => {
batch(() => {
if (server.key !== key) {
if (typeof window !== "undefined" && window.history?.replaceState) {
window.history.replaceState(null, "", "/")
}
} else {
navigate("/")
}
server.setActive(key)
})
})
navigate("/")
queueMicrotask(() => server.setActive(key))
}}
>
<ServerHealthIndicator health={health[key]} />
@@ -212,7 +264,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
nameClass="text-14-regular text-text-base truncate"
versionClass="text-12-regular text-text-weak truncate"
badge={
<Show when={key === server.defaultKey()}>
<Show when={key === defaultServer.key()}>
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
{language.t("common.default")}
</span>
@@ -236,7 +288,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
const run = ++dialogRun
void import("./dialog-select-server").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogSelectServer onNavigateHome={() => navigate("/")} />)
dialog.show(() => <x.DialogSelectServer />, defaultServer.refresh)
})
}}
>

View File

@@ -62,26 +62,6 @@ const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = {
},
}
const getTerminalColors = (theme: ReturnType<typeof useTheme>): TerminalColors => {
const mode = theme.mode() === "dark" ? "dark" : "light"
const fallback = DEFAULT_TERMINAL_COLORS[mode]
const currentTheme = theme.themes()[theme.themeId()]
if (!currentTheme) return fallback
const variant = mode === "dark" ? currentTheme.dark : currentTheme.light
if (!variant?.seeds && !variant?.palette) return fallback
const resolved = resolveThemeVariant(variant, mode === "dark")
const text = resolved["text-stronger"] ?? fallback.foreground
const background = resolved["background-stronger"] ?? fallback.background
const alpha = mode === "dark" ? 0.25 : 0.2
const base = text.startsWith("#") ? (text as HexColor) : (fallback.foreground as HexColor)
return {
background,
foreground: text,
cursor: text,
selectionBackground: withAlpha(base, alpha),
}
}
const debugTerminal = (...values: unknown[]) => {
if (!import.meta.env.DEV) return
console.debug("[terminal]", ...values)
@@ -94,11 +74,6 @@ const errorName = (err: unknown) => {
return typeof errorName === "string" ? errorName : undefined
}
const logTerminal = (phase: string, input: Record<string, unknown>) => {
if (!import.meta.env.DEV) return
console.log(`[terminal ui] ${JSON.stringify({ phase, ...input })}`)
}
const useTerminalUiBindings = (input: {
container: HTMLDivElement
term: Term
@@ -258,7 +233,28 @@ export const Terminal = (props: TerminalProps) => {
})
}
const terminalColors = createMemo(() => getTerminalColors(theme))
const getTerminalColors = (): TerminalColors => {
const mode = theme.mode() === "dark" ? "dark" : "light"
const fallback = DEFAULT_TERMINAL_COLORS[mode]
const currentTheme = theme.themes()[theme.themeId()]
if (!currentTheme) return fallback
const variant = mode === "dark" ? currentTheme.dark : currentTheme.light
if (!variant?.seeds && !variant?.palette) return fallback
const resolved = resolveThemeVariant(variant, mode === "dark")
const text = resolved["text-stronger"] ?? fallback.foreground
const background = resolved["background-stronger"] ?? fallback.background
const alpha = mode === "dark" ? 0.25 : 0.2
const base = text.startsWith("#") ? (text as HexColor) : (fallback.foreground as HexColor)
const selectionBackground = withAlpha(base, alpha)
return {
background,
foreground: text,
cursor: text,
selectionBackground,
}
}
const terminalColors = createMemo(getTerminalColors)
const scheduleFit = () => {
if (disposed) return
@@ -454,32 +450,20 @@ export const Terminal = (props: TerminalProps) => {
output.flush(resolve)
})
// Defer the serialised `restore` buffer until the WebSocket actually
// opens against the live PTY. Previously we wrote it synchronously
// before connect, which painted stale content on screen whenever the
// sidecar had restarted (e.g. a server swap): every saved pty id
// belongs to the old sidecar, so connect eventually fails and the
// clone handler wipes the buffer — but you'd see the old bash/pwsh
// scrollback flash first. Now `restore` is only applied once we know
// the pty is real (handleOpen), and if connect fails clone clears
// `buffer` in the store so the next mount has nothing to replay.
fit.fit()
scheduleSize(t.cols, t.rows)
startResize()
let restored = false
const applyRestore = async () => {
if (restored) return
restored = true
if (!restore) return
logTerminal("restore.apply", {
id,
serverKey: server.key ?? null,
directory,
restoreLength: restore.length,
})
if (restore && restoreSize) {
await write(restore)
fit.fit()
scheduleSize(t.cols, t.rows)
if (scrollY !== undefined) t.scrollToLine(scrollY)
startResize()
} else {
fit.fit()
scheduleSize(t.cols, t.rows)
if (restore) {
await write(restore)
if (scrollY !== undefined) t.scrollToLine(scrollY)
}
startResize()
}
const once = { value: false }
@@ -536,16 +520,6 @@ export const Terminal = (props: TerminalProps) => {
next.password = password
}
logTerminal("socket.open", {
id,
serverKey: server.key ?? null,
directory,
restoreLength: restore.length,
sdkUrl: sdk.url,
currentUrl: url,
wsUrl: next.toString(),
})
const socket = new WebSocket(next)
socket.binaryType = "arraybuffer"
ws = socket
@@ -553,16 +527,6 @@ export const Terminal = (props: TerminalProps) => {
const handleOpen = () => {
if (disposed) return
tries = 0
logTerminal("socket.connected", {
id,
serverKey: server.key ?? null,
directory,
currentUrl: url,
})
// Paint the saved buffer now that we've confirmed the pty really
// exists on the current sidecar. Fire-and-forget: write()'s own
// flush keeps the data ordered with incoming WS messages.
void applyRestore()
local.onConnect?.()
scheduleSize(t.cols, t.rows)
}
@@ -617,14 +581,6 @@ export const Terminal = (props: TerminalProps) => {
socket.removeEventListener("close", handleClose)
if (disposed) return
if (event.code === 1000) return
logTerminal("socket.closed", {
id,
serverKey: server.key ?? null,
directory,
code: event.code,
reason: event.reason || null,
currentUrl: url,
})
retry(new Error(language.t("terminal.connectionLost.abnormalClose", { code: event.code })))
}
@@ -650,13 +606,6 @@ export const Terminal = (props: TerminalProps) => {
})
onCleanup(() => {
logTerminal("cleanup", {
id,
serverKey: server.key ?? null,
directory,
cursor,
restoreLength: restore.length,
})
disposed = true
if (fitFrame !== undefined) cancelAnimationFrame(fitFrame)
if (sizeTimer !== undefined) clearTimeout(sizeTimer)
@@ -664,30 +613,17 @@ export const Terminal = (props: TerminalProps) => {
drop?.()
if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close(1000)
// Defer finalize (persistTerminal + local cleanup()) to a microtask so
// that its synchronous store write inside `persistTerminal` — which
// flows through `props.onCleanup` -> `ops.update` -> `update()` in
// `context/terminal.tsx` and calls `setStore("all", i, ...)` — does
// NOT run inside the outer solid cleanNode cascade. Running it
// synchronously mid-cascade races with solid's recursive owned
// iteration (readSignal on a stale memo re-enters updateComputation,
// which nulls an ancestor's owned while the outer loop is still
// iterating it) and crashes with "Cannot read properties of null
// (reading '1')" at node.owned[i] inside chunk-EZWYHVNM.js cleanNode.
// queueMicrotask runs after the current sync reactive flush, so the
// store write lands in a fresh tick.
const finalize = () => {
persistTerminal({ term, addon: serializeAddon, cursor, id, onCleanup: props.onCleanup })
cleanup()
}
const schedule = () => queueMicrotask(finalize)
if (!output) {
schedule()
finalize()
return
}
output.flush(schedule)
output.flush(finalize)
})
return (

View File

@@ -95,15 +95,6 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
buffer.length = 0
}
const clearPending = () => {
if (timer) clearTimeout(timer)
timer = undefined
queue.length = 0
buffer.length = 0
coalesced.clear()
staleDeltas.clear()
}
const schedule = () => {
if (timer) return
const elapsed = Date.now() - last
@@ -211,10 +202,6 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
}
})().finally(() => {
run = undefined
if (abort.signal.aborted || !started) {
clearPending()
return
}
flush()
})
return run
@@ -238,7 +225,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
onCleanup(() => {
stop()
abort.abort()
clearPending()
flush()
})
const sdk = createSdkForServer({
@@ -248,9 +235,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
})
return {
get url() {
return server.current?.http.url ?? currentServer.http.url
},
url: currentServer.http.url,
client: sdk,
event: {
on: emitter.on.bind(emitter),

View File

@@ -112,15 +112,8 @@ export function createChildStoreManager(input: {
lifecycle.delete(key)
const dispose = disposers.get(key)
if (dispose) {
dispose()
disposers.delete(key)
// Defer the actual solid-js root disposal. When disposeDirectory runs
// from pinForOwner's onCleanup during a parent remount, calling
// dispose() here triggers a nested cleanNode cascade on the inner
// root while the outer cascade is mid-traversal, which corrupts
// solid-js's graph walk state and throws `Cannot read properties of
// null (reading '1')` at chunk-*.js:992. Running dispose on a
// microtask lets the outer cleanup finish first.
queueMicrotask(dispose)
}
delete children[key]
input.onDispose(key)

View File

@@ -147,6 +147,12 @@ export type Platform = {
/** Set the default server URL to use on app startup (platform-specific) */
setDefaultServer?(url: ServerConnection.Key | null): Promise<void> | void
/** Get the configured WSL integration (desktop only) */
getWslEnabled?(): Promise<boolean>
/** Set the configured WSL integration (desktop only) */
setWslEnabled?(config: boolean): Promise<void> | void
/** Manage WSL sidecar servers (Electron on Windows only) */
wslServers?: WslServersPlatform

View File

@@ -232,13 +232,10 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
const cache = new Map<string, PromptCacheEntry>()
const disposeAll = () => {
// Defer the dispose calls to a microtask; synchronous nested dispose
// inside a parent onCleanup corrupts solid-js's in-flight cleanNode
// traversal during mass remounts (see context/terminal.tsx for the
// same pattern).
const pending = Array.from(cache.values(), (entry) => entry.dispose)
for (const entry of cache.values()) {
entry.dispose()
}
cache.clear()
if (pending.length) queueMicrotask(() => pending.forEach((d) => d()))
}
onCleanup(disposeAll)

View File

@@ -1,11 +1,8 @@
import { showToast } from "@opencode-ai/ui/toast"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { type Accessor, batch, createEffect, createMemo, onCleanup, untrack } from "solid-js"
import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist"
import { useCheckServerHealth } from "@/utils/server-health"
import { useLanguage } from "./language"
import { usePlatform } from "./platform"
type StoredProject = { worktree: string; expanded: boolean }
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
@@ -26,7 +23,7 @@ export function serverName(conn?: ServerConnection.Any, ignoreDisplayName = fals
function projectsKey(key: ServerConnection.Key) {
if (!key) return ""
if (key === "sidecar" || key === "local:windows") return "local"
if (key === "sidecar") return "local"
if (isLocalHost(key)) return "local"
return key
}
@@ -84,7 +81,7 @@ export namespace ServerConnection {
return Key.make(conn.http.url)
case "sidecar": {
if (conn.variant === "wsl") return Key.make(`wsl:${conn.distro}`)
return Key.make("local:windows")
return Key.make("sidecar")
}
case "ssh":
return Key.make(`ssh:${conn.host}`)
@@ -100,13 +97,9 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
init: (props: {
defaultServer: ServerConnection.Key
disableHealthCheck?: boolean
serversReady?: boolean
servers?: Array<ServerConnection.Any>
}) => {
const checkServerHealth = useCheckServerHealth()
const language = useLanguage()
const platform = usePlatform()
const serversReady = () => props.serversReady ?? true
const [store, setStore, _, ready] = persisted(
Persist.global("server", ["server.v3"]),
@@ -144,7 +137,6 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const [state, setState] = createStore({
active: props.defaultServer,
default: props.defaultServer as ServerConnection.Key | null,
healthy: undefined as boolean | undefined,
})
@@ -179,28 +171,11 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
if (state.active !== input) setState("active", input)
}
async function setDefault(input: ServerConnection.Key | null) {
if (!platform.setDefaultServer) return input
try {
await platform.setDefaultServer(input)
setState("default", input)
return input
} catch (err) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
throw err
}
}
function nextActiveKey(exclude?: ServerConnection.Key) {
const available = allServers().filter((conn) => ServerConnection.key(conn) !== exclude)
const preferred = available.find((conn) => ServerConnection.key(conn) === props.defaultServer)
const next = preferred ?? available[0]
return next ? ServerConnection.key(next) : props.defaultServer
}
createEffect(() => {
if (typeof window === "undefined") return
window.__OPENCODE__ ??= {}
window.__OPENCODE__.activeServer = state.active
})
function add(input: ServerConnection.Http) {
const url_ = normalizeServerUrl(input.http.url)
@@ -223,39 +198,18 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
batch(() => {
setStore("list", list)
if (state.active === key) {
setState("active", nextActiveKey(key))
const next = list[0]
setState("active", next ? ServerConnection.Key.make(url(next)) : props.defaultServer)
}
})
}
const isReady = createMemo(() => ready() && !!state.active)
const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http).then((x) => x.healthy)
createEffect(() => {
const key = state.active
if (typeof window === "undefined") return
window.__OPENCODE__ ??= {}
window.__OPENCODE__.activeServer = key
})
const origin = createMemo(() => projectsKey(state.active))
const projectsList = createMemo(() => store.projects[origin()] ?? [])
const current: Accessor<ServerConnection.Any | undefined> = createMemo(() => {
const list = allServers()
const active = list.find((s) => ServerConnection.key(s) === state.active)
if (active) return active
if (!serversReady()) return
return list[0]
})
const healthTarget = createMemo(() => {
const conn = current()
if (!conn) return ""
return [ServerConnection.key(conn), conn.http.url, conn.http.username ?? "", conn.http.password ?? ""].join("\n")
})
const isReady = createMemo(() => ready() && !!current())
createEffect(() => {
healthTarget()
const current_ = untrack(current)
const current_ = current()
if (!current_) return
if (props.disableHealthCheck) {
@@ -266,14 +220,11 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
onCleanup(startHealthPolling(current_))
})
createEffect(() => {
if (!serversReady()) return
const list = allServers()
if (!list.length) return
if (list.some((conn) => ServerConnection.key(conn) === state.active)) return
setState("active", nextActiveKey(state.active))
})
const origin = createMemo(() => projectsKey(state.active))
const projectsList = createMemo(() => store.projects[origin()] ?? [])
const current: Accessor<ServerConnection.Any | undefined> = createMemo(
() => allServers().find((s) => ServerConnection.key(s) === state.active) ?? allServers()[0],
)
const isLocal = createMemo(() => {
const c = current()
return c?.type === "sidecar" || (c?.type === "http" && isLocalHost(c.http.url))
@@ -295,13 +246,6 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
get current() {
return current()
},
canDefault() {
return !!platform.getDefaultServer && !!platform.setDefaultServer
},
defaultKey() {
return state.default
},
setDefault,
setActive,
add,
remove,

View File

@@ -22,7 +22,7 @@ beforeAll(async () => {
})
describe("getWorkspaceTerminalCacheKey", () => {
test("uses the workspace cache key", () => {
test("uses workspace-only directory cache key", () => {
expect(getWorkspaceTerminalCacheKey("/repo")).toBe("/repo:__workspace__")
})
})

View File

@@ -3,7 +3,6 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "solid-js"
import { useParams } from "@solidjs/router"
import { useSDK } from "./sdk"
import { useServer } from "./server"
import type { Platform } from "./platform"
import { defaultTitle, titleNumber } from "./terminal-title"
import { Persist, persisted, removePersisted } from "@/utils/persist"
@@ -22,11 +21,6 @@ export type LocalPTY = {
const WORKSPACE_KEY = "__workspace__"
const MAX_TERMINAL_SESSIONS = 20
const debugTerminal = (phase: string, input: Record<string, unknown>) => {
if (!import.meta.env.DEV) return
console.log(`[terminal context] ${JSON.stringify({ phase, ...input })}`)
}
function record(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
@@ -117,11 +111,10 @@ const trimTerminal = (pty: LocalPTY) => {
}
export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform) {
const key = getWorkspaceTerminalCacheKey(dir)
for (const cache of caches) {
for (const [key, entry] of cache.entries()) {
if (!key.startsWith(`${dir}:`) || !key.endsWith(`:${WORKSPACE_KEY}`)) continue
entry.value.clear()
}
const entry = cache.get(key)
entry?.value.clear()
}
void removePersisted(Persist.workspace(dir, "terminal"), platform)
@@ -137,25 +130,14 @@ export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], plat
}
}
function createWorkspaceTerminalSession(
sdk: ReturnType<typeof useSDK>,
dir: string,
serverKey: string,
legacySessionID?: string,
) {
function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, legacySessionID?: string) {
const legacy = getLegacyTerminalStorageKeys(dir, legacySessionID)
const target = {
...Persist.workspace(dir, `${serverKey}:terminal`, legacy),
migrate: migrateTerminalState,
}
// Scope persisted terminal state by server so switching servers behaves
// like switching projects: a fresh session for the new server+dir pair,
// while the other server's state stays intact until you swap back. PTY
// ids, scrollback, and WebSocket connections are all server-scoped, so
// cross-server persistence was showing stale output on swap.
const [store, setStore, _, ready] = persisted(
target,
{
...Persist.workspace(dir, "terminal", legacy),
migrate: migrateTerminalState,
},
createStore<{
active?: string
all: LocalPTY[]
@@ -164,14 +146,6 @@ function createWorkspaceTerminalSession(
}),
)
debugTerminal("session.create", {
dir,
serverKey,
storage: target.storage,
key: target.key,
legacySessionID: legacySessionID ?? null,
})
const pickNextTerminalNumber = () => {
const existingTitleNumbers = new Set(
store.all.flatMap((pty) => {
@@ -212,16 +186,6 @@ function createWorkspaceTerminalSession(
onCleanup(unsub)
const update = (client: ReturnType<typeof useSDK>["client"], pty: Partial<LocalPTY> & { id: string }) => {
debugTerminal("session.update", {
dir,
serverKey,
id: pty.id,
title: pty.title ?? null,
hasBuffer: typeof pty.buffer === "string",
bufferLength: typeof pty.buffer === "string" ? pty.buffer.length : 0,
cursor: pty.cursor ?? null,
scrollY: pty.scrollY ?? null,
})
const index = store.all.findIndex((x) => x.id === pty.id)
const previous = index >= 0 ? store.all[index] : undefined
if (index >= 0) {
@@ -238,18 +202,11 @@ function createWorkspaceTerminalSession(
const currentIndex = store.all.findIndex((item) => item.id === pty.id)
if (currentIndex >= 0) setStore("all", currentIndex, previous)
}
console.error(
`Failed to update terminal ${JSON.stringify({
ptyID: pty.id,
title: pty.title,
error: error instanceof Error ? { name: error.name, message: error.message, stack: error.stack } : error,
})}`,
)
console.error("Failed to update terminal", error)
})
}
const clone = async (client: ReturnType<typeof useSDK>["client"], id: string) => {
debugTerminal("session.clone.start", { dir, serverKey, id })
const index = store.all.findIndex((x) => x.id === id)
const pty = store.all[index]
if (!pty) return
@@ -263,14 +220,6 @@ function createWorkspaceTerminalSession(
})
if (!next?.data) return
debugTerminal("session.clone.done", {
dir,
serverKey,
id,
nextID: next.data.id ?? null,
title: next.data.title ?? pty.title,
})
const active = store.active === pty.id
batch(() => {
@@ -303,19 +252,11 @@ function createWorkspaceTerminalSession(
new() {
const nextNumber = pickNextTerminalNumber()
debugTerminal("session.new", { dir, serverKey, nextNumber })
sdk.client.pty
.create({ title: defaultTitle(nextNumber) })
.then((pty: { data?: { id?: string; title?: string } }) => {
const id = pty.data?.id
if (!id) return
debugTerminal("session.new.done", {
dir,
serverKey,
id,
title: pty.data?.title ?? defaultTitle(nextNumber),
})
const newTerminal = {
id,
title: pty.data?.title ?? defaultTitle(nextNumber),
@@ -348,12 +289,6 @@ function createWorkspaceTerminalSession(
},
bind() {
const client = sdk.client
debugTerminal("session.bind", {
dir,
serverKey,
active: store.active ?? null,
all: store.all.map((item) => item.id),
})
return {
trim(id: string) {
const index = store.all.findIndex((x) => x.id === id)
@@ -422,7 +357,6 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
gate: false,
init: () => {
const sdk = useSDK()
const server = useServer()
const params = useParams()
const cache = new Map<string, TerminalCacheEntry>()
@@ -430,9 +364,10 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
onCleanup(() => caches.delete(cache))
const disposeAll = () => {
const pending = Array.from(cache.values(), (entry) => entry.dispose)
for (const entry of cache.values()) {
entry.dispose()
}
cache.clear()
for (const dispose of pending) dispose()
}
onCleanup(disposeAll)
@@ -447,30 +382,18 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
}
}
const loadWorkspace = (dir: string, serverKey: string, legacySessionID?: string) => {
const loadWorkspace = (dir: string, legacySessionID?: string) => {
// Terminals are workspace-scoped so tabs persist while switching sessions in the same directory.
const key = getWorkspaceTerminalCacheKey(dir)
const existing = cache.get(key)
if (existing) {
debugTerminal("workspace.cache.hit", {
dir,
serverKey,
key,
legacySessionID: legacySessionID ?? null,
})
cache.delete(key)
cache.set(key, existing)
return existing.value
}
debugTerminal("workspace.cache.miss", {
dir,
serverKey,
key,
legacySessionID: legacySessionID ?? null,
})
const entry = createRoot((dispose) => ({
value: createWorkspaceTerminalSession(sdk, dir, serverKey, legacySessionID),
value: createWorkspaceTerminalSession(sdk, dir, legacySessionID),
dispose,
}))
@@ -479,21 +402,16 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
return entry.value
}
const workspace = createMemo(() => {
const key = server.key
if (!key) return loadWorkspace(params.dir!, "", params.id)
return loadWorkspace(params.dir!, key, params.id)
})
const workspace = createMemo(() => loadWorkspace(params.dir!, params.id))
createEffect(
on(
() => ({ dir: params.dir, id: params.id }),
(next, prev) => {
const prevKey = server.key
if (!prev?.dir || !prevKey) return
if (!prev?.dir) return
if (next.dir === prev.dir && next.id === prev.id) return
if (next.dir === prev.dir && next.id) return
loadWorkspace(prev.dir, prevKey, prev.id).trimAll()
loadWorkspace(prev.dir, prev.id).trimAll()
},
{ defer: true },
),
@@ -508,7 +426,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
trim: (id: string) => workspace().trim(id),
trimAll: () => workspace().trimAll(),
clone: (id: string) => workspace().clone(id),
bind: () => workspace().bind(),
bind: () => workspace(),
open: (id: string) => workspace().open(id),
close: (id: string) => workspace().close(id),
move: (id: string, to: number) => workspace().move(id, to),

View File

@@ -1,5 +1,4 @@
export { AppBaseProviders, AppInterface } from "./app"
export { DialogWslServer } from "./components/dialog-wsl-server"
export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
export { useCommand } from "./context/command"
export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language"

View File

@@ -83,16 +83,9 @@ import {
LocalWorkspace,
SortableWorkspace,
WorkspaceDragOverlay,
workspaceSortableDirectory,
workspaceSortableId,
type WorkspaceSidebarContext,
} from "./layout/sidebar-workspace"
import {
ProjectDragOverlay,
SortableProject,
projectSortableWorktree,
type ProjectSidebarContext,
} from "./layout/sidebar-project"
import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
import { SidebarContent } from "./layout/sidebar-shell"
export default function Layout(props: ParentProps) {
@@ -1844,7 +1837,7 @@ export default function Layout(props: ParentProps) {
)
function handleDragStart(event: unknown) {
const id = projectSortableWorktree(getDraggableId(event))
const id = getDraggableId(event)
if (!id) return
setHoverProject(undefined)
setStore("activeProject", id)
@@ -1853,14 +1846,11 @@ export default function Layout(props: ParentProps) {
function handleDragOver(event: DragEvent) {
const { draggable, droppable } = event
if (draggable && droppable) {
const from = projectSortableWorktree(draggable.id?.toString())
const to = projectSortableWorktree(droppable.id?.toString())
if (!from || !to) return
const projects = layout.projects.list()
const fromIndex = projects.findIndex((p) => p.worktree === from)
const toIndex = projects.findIndex((p) => p.worktree === to)
const fromIndex = projects.findIndex((p) => p.worktree === draggable.id.toString())
const toIndex = projects.findIndex((p) => p.worktree === droppable.id.toString())
if (fromIndex !== toIndex && toIndex !== -1) {
layout.projects.move(from, toIndex)
layout.projects.move(draggable.id.toString(), toIndex)
}
}
}
@@ -1896,7 +1886,7 @@ export default function Layout(props: ParentProps) {
})
function handleWorkspaceDragStart(event: unknown) {
const id = workspaceSortableDirectory(getDraggableId(event))
const id = getDraggableId(event)
if (!id) return
setStore("activeWorkspace", id)
}
@@ -1904,16 +1894,13 @@ export default function Layout(props: ParentProps) {
function handleWorkspaceDragOver(event: DragEvent) {
const { draggable, droppable } = event
if (!draggable || !droppable) return
const from = workspaceSortableDirectory(draggable.id?.toString())
const to = workspaceSortableDirectory(droppable.id?.toString())
if (!from || !to) return
const project = sidebarProject()
if (!project) return
const ids = workspaceIds(project)
const fromIndex = ids.findIndex((dir) => dir === from)
const toIndex = ids.findIndex((dir) => dir === to)
const fromIndex = ids.findIndex((dir) => dir === draggable.id.toString())
const toIndex = ids.findIndex((dir) => dir === droppable.id.toString())
if (fromIndex === -1 || toIndex === -1) return
if (fromIndex === toIndex) return
@@ -2274,13 +2261,13 @@ export default function Layout(props: ParentProps) {
}}
class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]"
>
<SortableProvider ids={workspaces().map(workspaceSortableId)}>
<SortableProvider ids={workspaces()}>
<For each={workspaces()}>
{(directory) => (
<SortableWorkspace
ctx={workspaceSidebarCtx}
directory={directory}
project={project()!}
project={project()}
sortNow={sortNow}
mobile={panelProps.mobile}
/>
@@ -2337,7 +2324,6 @@ export default function Layout(props: ParentProps) {
}
const projects = () => layout.projects.list()
const projectIds = createMemo(() => projects().map((project) => project.worktree))
const projectOverlay = () => <ProjectDragOverlay projects={projects} activeProject={() => store.activeProject} />
const sidebarContent = (mobile?: boolean) => (
<SidebarContent
@@ -2345,17 +2331,9 @@ export default function Layout(props: ParentProps) {
opened={() => layout.sidebar.opened()}
aimMove={aim.move}
projects={projects}
projectIds={projectIds}
renderProject={(worktree) => {
const project = createMemo(() => projects().find((item) => item.worktree === worktree))
return (
<Show when={project()}>
{(project) => (
<SortableProject ctx={projectSidebarCtx} project={project()} sortNow={sortNow} mobile={mobile} />
)}
</Show>
)
}}
renderProject={(project) => (
<SortableProject ctx={projectSidebarCtx} project={project} sortNow={sortNow} mobile={mobile} />
)}
handleDragStart={handleDragStart}
handleDragEnd={handleDragEnd}
handleDragOver={handleDragOver}

View File

@@ -34,17 +34,6 @@ export type ProjectSidebarContext = {
sessionProps: Omit<SessionItemProps, "session" | "list" | "slug" | "mobile" | "dense">
}
const PROJECT_SORTABLE_PREFIX = "project:"
export function projectSortableId(worktree: string) {
return `${PROJECT_SORTABLE_PREFIX}${worktree}`
}
export function projectSortableWorktree(id: string | undefined) {
if (!id?.startsWith(PROJECT_SORTABLE_PREFIX)) return
return id.slice(PROJECT_SORTABLE_PREFIX.length)
}
export const ProjectDragOverlay = (props: {
projects: Accessor<LocalProject[]>
activeProject: Accessor<string | undefined>
@@ -286,7 +275,7 @@ export const SortableProject = (props: {
}): JSX.Element => {
const globalSync = useGlobalSync()
const language = useLanguage()
const sortable = createSortable(projectSortableId(props.project.worktree))
const sortable = createSortable(props.project.worktree)
const selected = createMemo(() => props.ctx.currentProject()?.worktree === props.project.worktree)
const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2))
const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project))

View File

@@ -11,15 +11,13 @@ import { ConstrainDragXAxis } from "@/utils/solid-dnd"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { type LocalProject } from "@/context/layout"
import { projectSortableId } from "./sidebar-project"
export const SidebarContent = (props: {
mobile?: boolean
opened: Accessor<boolean>
aimMove: (event: MouseEvent) => void
projects: Accessor<LocalProject[]>
projectIds: Accessor<string[]>
renderProject: (worktree: string) => JSX.Element
renderProject: (project: LocalProject) => JSX.Element
handleDragStart: (event: unknown) => void
handleDragEnd: () => void
handleDragOver: (event: DragEvent) => void
@@ -65,8 +63,8 @@ export const SidebarContent = (props: {
<DragDropSensors />
<ConstrainDragXAxis />
<div class="h-full w-full flex flex-col items-center gap-3 px-3 py-3 overflow-y-auto no-scrollbar">
<SortableProvider ids={props.projectIds().map(projectSortableId)}>
<For each={props.projectIds()}>{(worktree) => props.renderProject(worktree)}</For>
<SortableProvider ids={props.projects().map((p) => p.worktree)}>
<For each={props.projects()}>{(project) => props.renderProject(project)}</For>
</SortableProvider>
<Tooltip
placement={placement()}

View File

@@ -55,17 +55,6 @@ export type WorkspaceSidebarContext = {
setScrollContainerRef: (el: HTMLDivElement | undefined, mobile?: boolean) => void
}
const WORKSPACE_SORTABLE_PREFIX = "workspace:"
export function workspaceSortableId(directory: string) {
return `${WORKSPACE_SORTABLE_PREFIX}${directory}`
}
export function workspaceSortableDirectory(id: string | undefined) {
if (!id?.startsWith(WORKSPACE_SORTABLE_PREFIX)) return
return id.slice(WORKSPACE_SORTABLE_PREFIX.length)
}
export const WorkspaceDragOverlay = (props: {
sidebarProject: Accessor<LocalProject | undefined>
activeWorkspace: Accessor<string | undefined>
@@ -312,7 +301,7 @@ export const SortableWorkspace = (props: {
const params = useParams()
const globalSync = useGlobalSync()
const language = useLanguage()
const sortable = createSortable(workspaceSortableId(props.directory))
const sortable = createSortable(props.directory)
const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory, { bootstrap: false })
const [menu, setMenu] = createStore({
open: false,
@@ -325,9 +314,7 @@ export const SortableWorkspace = (props: {
const workspaceValue = createMemo(() => {
const branch = workspaceStore.vcs?.branch
const name = branch ?? getFilename(props.directory)
const projectId = props.project.id
if (!projectId) return name
return props.ctx.workspaceName(props.directory, projectId, branch) ?? name
return props.ctx.workspaceName(props.directory, props.project.id, branch) ?? name
})
const open = createMemo(() => props.ctx.workspaceExpanded(props.directory, local()))
const boot = createMemo(() => open() || active())
@@ -357,7 +344,7 @@ export const SortableWorkspace = (props: {
InlineEditor={props.ctx.InlineEditor}
renameWorkspace={props.ctx.renameWorkspace}
setEditor={props.ctx.setEditor}
projectId={props.project.id ?? ""}
projectId={props.project.id}
/>
)
@@ -460,21 +447,19 @@ export const LocalWorkspace = (props: {
}): JSX.Element => {
const globalSync = useGlobalSync()
const language = useLanguage()
const worktree = createMemo(() => props.project.worktree)
const workspace = createMemo(() => {
const [store, setStore] = globalSync.child(worktree())
const [store, setStore] = globalSync.child(props.project.worktree)
return { store, setStore }
})
const slug = createMemo(() => base64Encode(worktree()))
const slug = createMemo(() => base64Encode(props.project.worktree))
const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow()))
const count = createMemo(() => sessions()?.length ?? 0)
const query = useQuery(() => ({ ...loadSessionsQuery(worktree()) }))
const loading = createMemo(() => query.isPending && count() === 0)
const query = useQuery(() => ({ ...loadSessionsQuery(props.project.worktree) }))
const hasMore = createMemo(() => workspace().store.sessionTotal > count())
const loading = () => query.isLoading && count() === 0
const loadMore = async () => {
const dir = worktree()
workspace().setStore("limit", (limit) => (limit ?? 0) + 5)
await globalSync.project.loadSessions(dir)
await globalSync.project.loadSessions(props.project.worktree)
}
return (

View File

@@ -24,7 +24,7 @@ describe("createScopedCache", () => {
expect(disposed).toEqual(["b"])
})
test("disposes entries on delete and clear", async () => {
test("disposes entries on delete and clear", () => {
const disposed: string[] = []
const cache = createScopedCache((key) => ({ key }), {
dispose: (value) => disposed.push(value.key),
@@ -39,9 +39,6 @@ describe("createScopedCache", () => {
cache.clear()
expect(cache.peek("b")).toBeUndefined()
// clear() defers dispose to a microtask to avoid nested cleanNode cascades
// when called from inside an onCleanup; flush the queue before asserting.
await Promise.resolve()
expect(disposed).toEqual(["a", "b"])
})

View File

@@ -89,21 +89,10 @@ export function createScopedCache<T>(createValue: (key: string) => T, options: S
}
const clear = () => {
// Defer dispose() calls to a microtask. When clear() runs inside an
// onCleanup during a parent remount (e.g. context/file.tsx and
// context/comments.tsx both do this), synchronous dispose on cached
// createRoot entries starts a nested cleanNode cascade while the outer
// cascade is mid-traversal, corrupting solid-js's graph walk state and
// throwing `Cannot read properties of null (reading '1')` at
// chunk-*.js:992. Deferring lets the outer cleanup finish first.
const pending: Array<[string, Entry<T>]> = []
for (const entry of store) pending.push(entry)
store.clear()
if (pending.length && options.dispose) {
queueMicrotask(() => {
for (const [key, entry] of pending) dispose(key, entry)
})
for (const [key, entry] of store) {
dispose(key, entry)
}
store.clear()
}
return {

View File

@@ -87,10 +87,7 @@ export async function checkServerHealth(
signal,
})
.global.health()
.then((x) => {
if (x.error) return next(count, x.error)
return { healthy: x.data?.healthy === true, version: x.data?.version }
})
.then((x) => (x.error ? next(count, x.error) : { healthy: x.data?.healthy === true, version: x.data?.version }))
.catch((error) => next(count, error))
return attempt(0).finally(() => timeout?.clear?.())
}

View File

@@ -82,13 +82,6 @@ export default defineConfig({
plugins: [appPlugin, sentry],
publicDir: "../../../app/public",
root: "src/renderer",
server: {
host: "127.0.0.1",
strictPort: true,
hmr: {
host: "127.0.0.1",
},
},
define: {
"import.meta.env.VITE_OPENCODE_CHANNEL": JSON.stringify(channel),
},

View File

@@ -7,5 +7,4 @@ export const CHANNEL: Channel = raw === "dev" || raw === "beta" || raw === "prod
export const SETTINGS_STORE = "opencode.settings"
export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
export const WSL_SERVERS_KEY = "wslServers"
export const LEGACY_LOCAL_SERVER_KEY = "localServer"
export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev"

View File

@@ -37,13 +37,12 @@ const { autoUpdater } = pkg
import type { InitStep, ServerReadyData, SqliteMigrationProgress } from "../preload/types"
import { checkAppExists, resolveAppPath, wslPath } from "./apps"
import { CHANNEL, UPDATER_ENABLED, WSL_SERVERS_KEY } from "./constants"
import { CHANNEL, UPDATER_ENABLED } from "./constants"
import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc"
import { initLogging } from "./logging"
import { parseMarkdown } from "./markdown"
import { createMenu } from "./menu"
import { allocatePort, getDefaultServerUrl, setDefaultServerUrl, spawnLocalServer, spawnWslSidecar } from "./server"
import { getStore } from "./store"
import { createWslServersController } from "./wsl-servers"
import {
createLoadingWindow,
@@ -63,7 +62,6 @@ const loadingComplete = defer<void>()
const pendingDeepLinks: string[] = []
const serverReady = defer<ServerReadyData>()
void serverReady.promise.catch(() => undefined)
const logger = initLogging()
const wslServers = createWslServersController(
app.getVersion(),
@@ -83,16 +81,6 @@ logger.log("app starting", {
version: app.getVersion(),
packaged: app.isPackaged,
})
// NOTE: the first getStore() call here is intentional — it is the earliest
// point after `app.setName` / `app.setPath("userData", ...)` have run, so
// electron-store correctly resolves its root to the channel-specific
// userData dir (`...desktop.dev` in dev) rather than the package.json name.
logger.log("config paths", {
userData: app.getPath("userData"),
settingsStore: getStore().path,
wslServersKey: WSL_SERVERS_KEY,
wslServers: getStore().get(WSL_SERVERS_KEY) ?? null,
})
setupApp()
@@ -100,14 +88,6 @@ function setupApp() {
ensureLoopbackNoProxy()
app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>")
process.on("uncaughtException", (error) => {
logger.error("main process uncaught exception", error)
})
process.on("unhandledRejection", (reason) => {
logger.error("main process unhandled rejection", reason)
})
if (!app.requestSingleInstanceLock()) {
app.quit()
return
@@ -182,11 +162,6 @@ async function initialize() {
const url = `http://${hostname}:${port}`
const password = randomUUID()
const startupData: ServerReadyData = {
url,
username: "opencode",
password,
}
const loadingTask = (async () => {
logger.log("sidecar connection started", { url })
@@ -207,40 +182,26 @@ async function initialize() {
initEmitter.emit("sqlite", { type: "Done" })
}
logger.log("spawning windows sidecar", { url })
let startupError: Error | null = null
const startup = await (async () => {
try {
return await spawnLocalServer(hostname, port, password)
} catch (error) {
startupError = asError(error)
logger.error("windows sidecar startup failed", startupError)
return undefined
}
})()
server = startup?.listener ?? null
logger.log("spawning sidecar", { url })
const { listener, health } = await spawnLocalServer(hostname, port, password)
server = listener
serverReady.resolve({
url,
username: "opencode",
password,
})
// Initialize WSL sidecars in parallel; failures do not block app startup.
void wslServers.initialize().catch((error) => logger.error("wsl server initialization failed", asError(error)))
void wslServers.initialize().catch((error) => logger.error("wsl server initialization failed", error))
if (startup) {
await Promise.race([
startup.health.wait,
delay(30_000).then(() => {
throw new Error("Sidecar health check timed out")
}),
])
.then(() => {
serverReady.resolve(startupData)
})
.catch((error) => {
startupError = asError(error)
logger.error("sidecar health check failed", startupError)
serverReady.reject(startupError)
})
} else {
serverReady.reject(startupError ?? new Error("Local server startup failed"))
}
await Promise.race([
health.wait,
delay(30_000).then(() => {
throw new Error("Sidecar health check timed out")
}),
]).catch((error) => {
logger.error("sidecar health check failed", error)
})
logger.log("loading task finished")
})()
@@ -249,7 +210,6 @@ async function initialize() {
const show = await Promise.race([loadingTask.then(() => false), delay(1_000).then(() => true)])
if (show) {
overlay = createLoadingWindow()
wireWindowDiagnostics(overlay, "loading")
await delay(1_000)
}
}
@@ -262,67 +222,11 @@ async function initialize() {
}
mainWindow = createMainWindow()
wireWindowDiagnostics(mainWindow, "main")
wireMenu()
overlay?.close()
}
function wireWindowDiagnostics(win: BrowserWindow, label: string) {
win.webContents.on("console-message", (_event, level, message, line, sourceId) => {
// Render `message` as a block so multi-line stack traces survive; the
// previous shape stuffed the message into a JSON object which escaped
// `\n` and made stacks unreadable.
const location = sourceId ? ` [${sourceId}:${line}]` : ""
const text = `${label} renderer${location}\n${message}`
if (level >= 3) {
logger.error(text)
return
}
if (level >= 2) {
logger.warn(text)
return
}
logger.log(text)
})
win.webContents.on("did-fail-load", (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
logger.error(`${label} renderer failed load`, {
errorCode,
errorDescription,
validatedURL,
isMainFrame,
})
})
win.webContents.on("render-process-gone", (_event, details) => {
logger.error(`${label} renderer process gone`, details)
})
win.webContents.on("preload-error", (_event, path, error) => {
logger.error(`${label} preload error`, {
path,
error: error instanceof Error ? (error.stack ?? error.message) : String(error),
})
})
// DevTools accelerators on Windows/Linux where the menu isn't created.
win.webContents.on("before-input-event", (_event, input) => {
if (input.type !== "keyDown") return
const key = input.key
const toggle =
key === "F12" ||
(input.control && input.shift && (key === "I" || key === "i")) ||
(input.meta && input.alt && (key === "I" || key === "i"))
if (!toggle) return
win.webContents.toggleDevTools()
})
win.on("unresponsive", () => {
logger.error(`${label} window became unresponsive`)
})
}
function wireMenu() {
if (!mainWindow) return
createMenu({
@@ -543,10 +447,6 @@ function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function asError(error: unknown) {
return error instanceof Error ? error : new Error(String(error))
}
function defer<T>() {
let resolve!: (value: T) => void
let reject!: (error: Error) => void

View File

@@ -67,8 +67,7 @@ function migrateFile(datPath: string, filename: string) {
}
export function migrate() {
const store = getStore()
if (store.get(TAURI_MIGRATED_KEY)) {
if (getStore().get(TAURI_MIGRATED_KEY)) {
log.log("tauri migration: already done, skipping")
return
}

View File

@@ -10,18 +10,11 @@ import { type WslCommandLine, resolveWslOpencode, shellEscape, wslArgs } from ".
export type HealthCheck = { wait: Promise<void> }
export function getDefaultServerUrl(): string | null {
const store = getStore()
const value = store.get(DEFAULT_SERVER_URL_KEY)
if (typeof value !== "string") return null
if (value === "sidecar") {
store.set(DEFAULT_SERVER_URL_KEY, "local:windows")
return "local:windows"
}
return value
const value = getStore().get(DEFAULT_SERVER_URL_KEY)
return typeof value === "string" ? value : null
}
export function setDefaultServerUrl(url: string | null) {
const store = getStore()
if (url) {
getStore().set(DEFAULT_SERVER_URL_KEY, url)
return

View File

@@ -4,12 +4,10 @@ import { SETTINGS_STORE } from "./constants"
const cache = new Map<string, Store>()
// IMPORTANT: do NOT construct Store at module import time. electron-store
// resolves `app.getPath("userData")` in its constructor, but our index.ts
// only calls `app.setName` / `app.setPath("userData", ...)` AFTER module
// imports finish. Constructing eagerly wrote settings (e.g. the WSL server
// config) to the default `%APPDATA%\@opencode-ai\desktop-electron` folder
// instead of the proper `...desktop.dev` / channel dir.
// We cannot instantiate the electron-store at module load time because
// module import hoisting causes this to run before app.setPath("userData", ...)
// in index.ts has executed, which would result in files being written to the default directory
// (e.g. bad: %APPDATA%\@opencode-ai\desktop-electron\opencode.settings vs good: %APPDATA%\ai.opencode.desktop.dev\opencode.settings).
export function getStore(name = SETTINGS_STORE) {
const cached = cache.get(name)
if (cached) return cached

View File

@@ -177,9 +177,7 @@ export function registerRendererProtocol() {
function loadWindow(win: BrowserWindow, html: string) {
const devUrl = process.env.ELECTRON_RENDERER_URL
if (devUrl) {
const base = new URL(devUrl)
if (base.hostname === "localhost") base.hostname = "127.0.0.1"
const url = new URL(html, base)
const url = new URL(html, devUrl)
void win.loadURL(url.toString())
return
}

View File

@@ -11,7 +11,7 @@ import type {
WslServersEvent,
WslServersState,
} from "../preload/types"
import { LEGACY_LOCAL_SERVER_KEY, WSL_SERVERS_KEY } from "./constants"
import { WSL_SERVERS_KEY } from "./constants"
import { getStore } from "./store"
import {
installWslDistro,
@@ -375,24 +375,7 @@ function readPersistedServers(): WslServerConfig[] {
const list = Array.isArray(record.servers) ? record.servers : []
return list.flatMap(normalizePersistedServer)
}
const migrated = migrateLegacyLocalServer()
if (migrated.length) store.set(WSL_SERVERS_KEY, { servers: migrated })
return migrated
}
function migrateLegacyLocalServer(): WslServerConfig[] {
const legacy = getStore().get(LEGACY_LOCAL_SERVER_KEY)
if (!legacy || typeof legacy !== "object") return []
const record = legacy as Record<string, unknown>
if (record.mode !== "wsl") return []
const distro = typeof record.distro === "string" ? record.distro : null
if (!distro) return []
return [
{
id: wslServerIdForDistro(distro),
distro,
},
]
return []
}
function normalizePersistedServer(value: unknown): WslServerConfig[] {

View File

@@ -1,57 +1,5 @@
// @refresh reload
// V8's default Error.stackTraceLimit truncates at 10 frames; raise it so
// reported errors come with a useful frame budget.
Error.stackTraceLimit = 200
// Install global error listeners before any other module runs so that
// uncaught errors and rejected promises reach the main process with their
// full stacks intact. Electron's `console-message` event only forwards the
// rethrow site, so without these we lose the originating frame.
window.addEventListener("error", (event) => {
const err = event.error
const stack = err instanceof Error ? err.stack : null
console.error(
"[renderer uncaught]",
stack ?? event.message,
stack ? "" : `${event.filename}:${event.lineno}:${event.colno}`,
)
})
window.addEventListener("unhandledrejection", (event) => {
const reason = event.reason
// Log as much as possible: stack for Errors, JSON for plain objects with
// a fallback to a tagged shape so we never end up with just
// "[object Object]" in main.log.
if (reason instanceof Error) {
console.error("[renderer unhandled rejection]", reason.stack ?? reason.message ?? String(reason))
return
}
let serialized: string
try {
serialized = JSON.stringify(
reason,
(_key, value) => {
if (value instanceof Error) {
return { __error: true, name: value.name, message: value.message, stack: value.stack }
}
return value
},
2,
)
} catch {
serialized = String(reason)
}
console.error(
"[renderer unhandled rejection]",
`type=${typeof reason}`,
`ctor=${reason?.constructor?.name ?? "null"}`,
`keys=${reason && typeof reason === "object" ? Object.keys(reason).join(",") : "n/a"}`,
"value:",
serialized,
)
})
import {
ACCEPTED_FILE_EXTENSIONS,
ACCEPTED_FILE_TYPES,
@@ -76,7 +24,6 @@ import pkg from "../../package.json"
import { initI18n, t } from "./i18n"
import { webviewZoom } from "./webview-zoom"
import "./styles.css"
import { Button } from "@opencode-ai/ui/button"
import { Splash } from "@opencode-ai/ui/logo"
import { useTheme } from "@opencode-ai/ui/theme"
@@ -121,21 +68,6 @@ const listenForDeepLinks = () => {
return window.api.onDeepLink((urls) => emitDeepLinks(urls))
}
function LocalServerStartupError(props: { message: string }) {
return (
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base gap-6 p-6">
<div class="flex flex-col items-center max-w-md text-center">
<Splash class="w-12 h-15 mb-4" />
<p class="text-16-medium text-text-strong">Local Server failed to start</p>
<p class="mt-2 text-12-regular text-text-weak whitespace-pre-wrap break-words">{props.message}</p>
<Button variant="secondary" size="large" class="mt-4" onClick={() => window.api.relaunch()}>
Relaunch
</Button>
</div>
</div>
)
}
const createPlatform = (): Platform => {
const os = (() => {
const ua = navigator.userAgent
@@ -351,19 +283,7 @@ render(() => {
const [windowCount] = createResource(() => window.api.getWindowCount())
const [startup] = createResource(async () => {
try {
return {
error: null,
sidecar: await window.api.awaitInitialization(() => undefined),
}
} catch (error) {
return {
error: error instanceof Error ? error.message : String(error),
sidecar: null,
}
}
})
const [sidecar] = createResource(() => window.api.awaitInitialization(() => undefined))
const [defaultServer] = createResource(() => platform.getDefaultServer?.())
const [locale] = createResource(loadLocale)
@@ -405,12 +325,12 @@ render(() => {
const ready = createMemo(
() =>
!defaultServer.loading &&
!startup.loading &&
!sidecar.loading &&
!windowCount.loading &&
!locale.loading,
)
const servers = createMemo(() => {
const data = startup.latest?.sidecar
const data = sidecar()
const list: ServerConnection.Any[] = []
if (data) {
list.push({
@@ -442,14 +362,10 @@ render(() => {
return list
})
if (!ready()) return splash
if (startup.latest?.error) {
return <LocalServerStartupError message={startup.latest.error} />
}
return (
<AppInterface
defaultServer={defaultServer.latest ?? ServerConnection.Key.make("local:windows")}
serversReady={!platform.wslServers || !wslServers.isPending}
defaultServer={defaultServer.latest ?? ServerConnection.Key.make("sidecar")}
servers={servers()}
router={MemoryRouter}
>

View File

@@ -24,20 +24,7 @@ pub fn get_default_server_url(app: AppHandle) -> Result<Option<String>, String>
let value = store.get(DEFAULT_SERVER_URL_KEY);
match value {
Some(v) => match v.as_str() {
Some("sidecar") => {
store.set(
DEFAULT_SERVER_URL_KEY,
serde_json::Value::String("local:windows".to_string()),
);
store
.save()
.map_err(|e| format!("Failed to save settings: {}", e))?;
Ok(Some("local:windows".to_string()))
}
Some(value) => Ok(Some(value.to_string())),
None => Ok(None),
},
Some(v) => Ok(v.as_str().map(String::from)),
None => Ok(None),
}
}
@@ -67,18 +54,18 @@ pub async fn set_default_server_url(app: AppHandle, url: Option<String>) -> Resu
#[tauri::command]
#[specta::specta]
pub fn get_wsl_config(app: AppHandle) -> Result<WslConfig, String> {
let store = app
.store(SETTINGS_STORE)
.map_err(|e| format!("Failed to open settings store: {}", e))?;
pub fn get_wsl_config(_app: AppHandle) -> Result<WslConfig, String> {
// let store = app
// .store(SETTINGS_STORE)
// .map_err(|e| format!("Failed to open settings store: {}", e))?;
let enabled = store
.get(WSL_ENABLED_KEY)
.as_ref()
.and_then(|v| v.as_bool())
.unwrap_or(false);
// let enabled = store
// .get(WSL_ENABLED_KEY)
// .as_ref()
// .and_then(|v| v.as_bool())
// .unwrap_or(false);
Ok(WslConfig { enabled })
Ok(WslConfig { enabled: false })
}
#[tauri::command]

View File

@@ -71,21 +71,16 @@ const createPlatform = (): Platform => {
})()
const wslHome = async () => {
if (os !== "windows") return undefined
const wsl = await commands.getWslConfig().catch(() => null)
if (!wsl?.enabled) return undefined
if (os !== "windows" || !window.__OPENCODE__?.wsl) return undefined
return commands.wslPath("~", "windows").catch(() => undefined)
}
const handleWslPicker = async <T extends string | string[] | null>(result: T): Promise<T> => {
if (!result) return result
const wsl = await commands.getWslConfig().catch(() => null)
if (!wsl?.enabled) return result
const convert = (path: string) => commands.wslPath(path, "linux").catch(() => path)
const handleWslPicker = async <T extends string | string[]>(result: T | null): Promise<T | null> => {
if (!result || !window.__OPENCODE__?.wsl) return result
if (Array.isArray(result)) {
return (await Promise.all(result.map(convert))) as T
return Promise.all(result.map((path) => commands.wslPath(path, "linux").catch(() => path))) as any
}
return (await convert(result)) as T
return commands.wslPath(result, "linux").catch(() => result) as any
}
return {
@@ -354,6 +349,16 @@ const createPlatform = (): Platform => {
}
},
getWslEnabled: async () => {
const next = await commands.getWslConfig().catch(() => null)
if (next) return next.enabled
return window.__OPENCODE__!.wsl ?? false
},
setWslEnabled: async (enabled) => {
await commands.setWslConfig({ enabled })
},
getDefaultServer: async () => {
const url = await commands.getDefaultServerUrl().catch(() => null)
if (!url) return null
@@ -433,7 +438,11 @@ render(() => {
// Fetch sidecar credentials from Rust (available immediately, before health check)
const [sidecar] = createResource(() => commands.awaitInitialization(new Channel<InitStep>() as any))
const [defaultServer] = createResource(() => platform.getDefaultServer?.())
const [defaultServer] = createResource(() =>
platform.getDefaultServer?.().then((url) => {
if (url) return ServerConnection.key({ type: "http", http: { url } })
}),
)
const [locale] = createResource(loadLocale)
// Build the sidecar server connection once credentials arrive
@@ -482,7 +491,7 @@ render(() => {
{(_) => {
return (
<AppInterface
defaultServer={defaultServer.latest ?? ServerConnection.Key.make("local:windows")}
defaultServer={defaultServer.latest ?? ServerConnection.Key.make("sidecar")}
servers={servers()}
>
<Inner />

View File

@@ -61,6 +61,7 @@ const createEmbeddedWebUIBundle = async () => {
await $`bun run --cwd ${appDir} build`
const files = (await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: dist })))
.map((file) => file.replaceAll("\\", "/"))
.filter((file) => !file.endsWith(".map"))
.sort()
const imports = files.map((file, i) => {
const spec = path.relative(dir, path.join(dist, file)).replaceAll("\\", "/")

View File

@@ -1,6 +1,5 @@
import { cmd } from "./cmd"
import * as prompts from "@clack/prompts"
import { AppRuntime } from "@/effect/app-runtime"
import { UI } from "../ui"
import { Global } from "@opencode-ai/core/global"
import { Agent } from "../../agent/agent"
@@ -9,8 +8,7 @@ import path from "path"
import fs from "fs/promises"
import { Filesystem } from "@/util/filesystem"
import matter from "gray-matter"
import { Instance } from "../../project/instance"
import { WithInstance } from "../../project/with-instance"
import { InstanceRef } from "@/effect/instance-ref"
import { EOL } from "os"
import type { Argv } from "yargs"
import { Effect } from "effect"
@@ -35,7 +33,7 @@ const AVAILABLE_PERMISSIONS = [
"skill",
]
const AgentCreateCommand = cmd({
const AgentCreateCommand = effectCmd({
command: "create",
describe: "create a new agent",
builder: (yargs: Argv) =>
@@ -63,176 +61,172 @@ const AgentCreateCommand = cmd({
alias: ["m"],
describe: "model to use in the format of provider/model",
}),
async handler(args) {
await WithInstance.provide({
directory: process.cwd(),
async fn() {
const cliPath = args.path
const cliDescription = args.description
const cliMode = args.mode as AgentMode | undefined
const perms = args.permissions
handler: Effect.fn("Cli.agent.create")(function* (args) {
const maybeCtx = yield* InstanceRef
if (!maybeCtx) return yield* Effect.die("InstanceRef not provided")
const ctx = maybeCtx
const agentSvc = yield* Agent.Service
yield* Effect.promise(async () => {
const cliPath = args.path
const cliDescription = args.description
const cliMode = args.mode as AgentMode | undefined
const perms = args.permissions
const isFullyNonInteractive = cliPath && cliDescription && cliMode && perms !== undefined
const isFullyNonInteractive = cliPath && cliDescription && cliMode && perms !== undefined
if (!isFullyNonInteractive) {
UI.empty()
prompts.intro("Create agent")
}
if (!isFullyNonInteractive) {
UI.empty()
prompts.intro("Create agent")
}
const project = Instance.project
const project = ctx.project
// Determine scope/path
let targetPath: string
if (cliPath) {
targetPath = path.join(cliPath, "agent")
} else {
let scope: "global" | "project" = "global"
if (project.vcs === "git") {
const scopeResult = await prompts.select({
message: "Location",
options: [
{
label: "Current project",
value: "project" as const,
hint: Instance.worktree,
},
{
label: "Global",
value: "global" as const,
hint: Global.Path.config,
},
],
})
if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
scope = scopeResult
}
targetPath = path.join(
scope === "global" ? Global.Path.config : path.join(Instance.worktree, ".opencode"),
"agent",
)
}
// Get description
let description: string
if (cliDescription) {
description = cliDescription
} else {
const query = await prompts.text({
message: "Description",
placeholder: "What should this agent do?",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(query)) throw new UI.CancelledError()
description = query
}
// Generate agent
const spinner = prompts.spinner()
spinner.start("Generating agent configuration...")
const model = args.model ? Provider.parseModel(args.model) : undefined
const generated = await AppRuntime.runPromise(
Agent.Service.use((svc) => svc.generate({ description, model })),
).catch((error) => {
spinner.stop(`LLM failed to generate agent: ${error.message}`, 1)
if (isFullyNonInteractive) process.exit(1)
throw new UI.CancelledError()
})
spinner.stop(`Agent ${generated.identifier} generated`)
// Select permissions to allow
let selected: string[]
if (perms !== undefined) {
selected = perms ? perms.split(",").map((t) => t.trim()) : AVAILABLE_PERMISSIONS
} else {
const result = await prompts.multiselect({
message: "Select permissions to allow (Space to toggle)",
options: AVAILABLE_PERMISSIONS.map((permission) => ({
label: permission,
value: permission,
})),
initialValues: AVAILABLE_PERMISSIONS,
})
if (prompts.isCancel(result)) throw new UI.CancelledError()
selected = result
}
// Get mode
let mode: AgentMode
if (cliMode) {
mode = cliMode
} else {
const modeResult = await prompts.select({
message: "Agent mode",
// Determine scope/path
let targetPath: string
if (cliPath) {
targetPath = path.join(cliPath, "agent")
} else {
let scope: "global" | "project" = "global"
if (project.vcs === "git") {
const scopeResult = await prompts.select({
message: "Location",
options: [
{
label: "All",
value: "all" as const,
hint: "Can function in both primary and subagent roles",
label: "Current project",
value: "project" as const,
hint: ctx.worktree,
},
{
label: "Primary",
value: "primary" as const,
hint: "Acts as a primary/main agent",
},
{
label: "Subagent",
value: "subagent" as const,
hint: "Can be used as a subagent by other agents",
label: "Global",
value: "global" as const,
hint: Global.Path.config,
},
],
initialValue: "all" as const,
})
if (prompts.isCancel(modeResult)) throw new UI.CancelledError()
mode = modeResult
if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
scope = scopeResult
}
targetPath = path.join(scope === "global" ? Global.Path.config : path.join(ctx.worktree, ".opencode"), "agent")
}
// Build permissions config — deny anything not explicitly selected.
const permissions: Record<string, "deny"> = {}
for (const permission of AVAILABLE_PERMISSIONS) {
if (!selected.includes(permission)) {
permissions[permission] = "deny"
}
// Get description
let description: string
if (cliDescription) {
description = cliDescription
} else {
const query = await prompts.text({
message: "Description",
placeholder: "What should this agent do?",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(query)) throw new UI.CancelledError()
description = query
}
// Generate agent
const spinner = prompts.spinner()
spinner.start("Generating agent configuration...")
const model = args.model ? Provider.parseModel(args.model) : undefined
const generated = await Effect.runPromise(agentSvc.generate({ description, model })).catch((error) => {
spinner.stop(`LLM failed to generate agent: ${error.message}`, 1)
if (isFullyNonInteractive) process.exit(1)
throw new UI.CancelledError()
})
spinner.stop(`Agent ${generated.identifier} generated`)
// Select permissions to allow
let selected: string[]
if (perms !== undefined) {
selected = perms ? perms.split(",").map((t) => t.trim()) : AVAILABLE_PERMISSIONS
} else {
const result = await prompts.multiselect({
message: "Select permissions to allow (Space to toggle)",
options: AVAILABLE_PERMISSIONS.map((permission) => ({
label: permission,
value: permission,
})),
initialValues: AVAILABLE_PERMISSIONS,
})
if (prompts.isCancel(result)) throw new UI.CancelledError()
selected = result
}
// Get mode
let mode: AgentMode
if (cliMode) {
mode = cliMode
} else {
const modeResult = await prompts.select({
message: "Agent mode",
options: [
{
label: "All",
value: "all" as const,
hint: "Can function in both primary and subagent roles",
},
{
label: "Primary",
value: "primary" as const,
hint: "Acts as a primary/main agent",
},
{
label: "Subagent",
value: "subagent" as const,
hint: "Can be used as a subagent by other agents",
},
],
initialValue: "all" as const,
})
if (prompts.isCancel(modeResult)) throw new UI.CancelledError()
mode = modeResult
}
// Build permissions config — deny anything not explicitly selected.
const permissions: Record<string, "deny"> = {}
for (const permission of AVAILABLE_PERMISSIONS) {
if (!selected.includes(permission)) {
permissions[permission] = "deny"
}
}
// Build frontmatter
const frontmatter: {
description: string
mode: AgentMode
permission?: Record<string, "deny">
} = {
description: generated.whenToUse,
mode,
}
if (Object.keys(permissions).length > 0) {
frontmatter.permission = permissions
}
// Build frontmatter
const frontmatter: {
description: string
mode: AgentMode
permission?: Record<string, "deny">
} = {
description: generated.whenToUse,
mode,
}
if (Object.keys(permissions).length > 0) {
frontmatter.permission = permissions
}
// Write file
const content = matter.stringify(generated.systemPrompt, frontmatter)
const filePath = path.join(targetPath, `${generated.identifier}.md`)
// Write file
const content = matter.stringify(generated.systemPrompt, frontmatter)
const filePath = path.join(targetPath, `${generated.identifier}.md`)
await fs.mkdir(targetPath, { recursive: true })
if (await Filesystem.exists(filePath)) {
if (isFullyNonInteractive) {
console.error(`Error: Agent file already exists: ${filePath}`)
process.exit(1)
}
prompts.log.error(`Agent file already exists: ${filePath}`)
throw new UI.CancelledError()
}
await Filesystem.write(filePath, content)
await fs.mkdir(targetPath, { recursive: true })
if (await Filesystem.exists(filePath)) {
if (isFullyNonInteractive) {
console.log(filePath)
} else {
prompts.log.success(`Agent created: ${filePath}`)
prompts.outro("Done")
console.error(`Error: Agent file already exists: ${filePath}`)
process.exit(1)
}
},
prompts.log.error(`Agent file already exists: ${filePath}`)
throw new UI.CancelledError()
}
await Filesystem.write(filePath, content)
if (isFullyNonInteractive) {
console.log(filePath)
} else {
prompts.log.success(`Agent created: ${filePath}`)
prompts.outro("Done")
}
})
},
}),
})
const AgentListCommand = effectCmd({

View File

@@ -18,10 +18,9 @@ import type {
} from "@octokit/webhooks-types"
import { UI } from "../ui"
import { cmd } from "./cmd"
import { effectCmd } from "../effect-cmd"
import { ModelsDev } from "@/provider/models"
import { Instance } from "@/project/instance"
import { WithInstance } from "@/project/with-instance"
import { bootstrap } from "../bootstrap"
import { InstanceRef } from "@/effect/instance-ref"
import { SessionShare } from "@/share/session"
import { Session } from "@/session/session"
import type { SessionID } from "../../session/schema"
@@ -200,191 +199,192 @@ export const GithubCommand = cmd({
async handler() {},
})
export const GithubInstallCommand = cmd({
export const GithubInstallCommand = effectCmd({
command: "install",
describe: "install the GitHub agent",
async handler() {
await WithInstance.provide({
directory: process.cwd(),
async fn() {
{
UI.empty()
prompts.intro("Install GitHub agent")
const app = await getAppInfo()
await installGitHubApp()
handler: Effect.fn("Cli.github.install")(function* () {
const maybeCtx = yield* InstanceRef
if (!maybeCtx) return yield* Effect.die("InstanceRef not provided")
const ctx = maybeCtx
yield* Effect.promise(async () => {
{
UI.empty()
prompts.intro("Install GitHub agent")
const app = await getAppInfo()
await installGitHubApp()
const providers = await AppRuntime.runPromise(ModelsDev.Service.use((s) => s.get())).then((p) => {
// TODO: add guide for copilot, for now just hide it
delete p["github-copilot"]
return p
const providers = await AppRuntime.runPromise(ModelsDev.Service.use((s) => s.get())).then((p) => {
// TODO: add guide for copilot, for now just hide it
delete p["github-copilot"]
return p
})
const provider = await promptProvider()
const model = await promptModel()
//const key = await promptKey()
await addWorkflowFiles()
printNextSteps()
function printNextSteps() {
let step2
if (provider === "amazon-bedrock") {
step2 =
"Configure OIDC in AWS - https://docs.github.com/en/actions/how-tos/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services"
} else {
step2 = [
` 2. Add the following secrets in org or repo (${app.owner}/${app.repo}) settings`,
"",
...providers[provider].env.map((e) => ` - ${e}`),
].join("\n")
}
prompts.outro(
[
"Next steps:",
"",
` 1. Commit the \`${WORKFLOW_FILE}\` file and push`,
step2,
"",
" 3. Go to a GitHub issue and comment `/oc summarize` to see the agent in action",
"",
" Learn more about the GitHub agent - https://opencode.ai/docs/github/#usage-examples",
].join("\n"),
)
}
async function getAppInfo() {
const project = ctx.project
if (project.vcs !== "git") {
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
throw new UI.CancelledError()
}
// Get repo info
const info = await AppRuntime.runPromise(
Git.Service.use((git) => git.run(["remote", "get-url", "origin"], { cwd: ctx.worktree })),
).then((x) => x.text().trim())
const parsed = parseGitHubRemote(info)
if (!parsed) {
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
throw new UI.CancelledError()
}
return { owner: parsed.owner, repo: parsed.repo, root: ctx.worktree }
}
async function promptProvider() {
const priority: Record<string, number> = {
opencode: 0,
anthropic: 1,
openai: 2,
google: 3,
}
let provider = await prompts.select({
message: "Select provider",
maxItems: 8,
options: pipe(
providers,
values(),
sortBy(
(x) => priority[x.id] ?? 99,
(x) => x.name ?? x.id,
),
map((x) => ({
label: x.name,
value: x.id,
hint: priority[x.id] === 0 ? "recommended" : undefined,
})),
),
})
const provider = await promptProvider()
const model = await promptModel()
//const key = await promptKey()
if (prompts.isCancel(provider)) throw new UI.CancelledError()
await addWorkflowFiles()
printNextSteps()
return provider
}
function printNextSteps() {
let step2
if (provider === "amazon-bedrock") {
step2 =
"Configure OIDC in AWS - https://docs.github.com/en/actions/how-tos/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services"
} else {
step2 = [
` 2. Add the following secrets in org or repo (${app.owner}/${app.repo}) settings`,
"",
...providers[provider].env.map((e) => ` - ${e}`),
].join("\n")
async function promptModel() {
const providerData = providers[provider]!
const model = await prompts.select({
message: "Select model",
maxItems: 8,
options: pipe(
providerData.models,
values(),
sortBy((x) => x.name ?? x.id),
map((x) => ({
label: x.name ?? x.id,
value: x.id,
})),
),
})
if (prompts.isCancel(model)) throw new UI.CancelledError()
return model
}
async function installGitHubApp() {
const s = prompts.spinner()
s.start("Installing GitHub app")
// Get installation
const installation = await getInstallation()
if (installation) return s.stop("GitHub app already installed")
// Open browser
const url = "https://github.com/apps/opencode-agent"
const command =
process.platform === "darwin"
? `open "${url}"`
: process.platform === "win32"
? `start "" "${url}"`
: `xdg-open "${url}"`
exec(command, (error) => {
if (error) {
prompts.log.warn(`Could not open browser. Please visit: ${url}`)
}
})
prompts.outro(
[
"Next steps:",
"",
` 1. Commit the \`${WORKFLOW_FILE}\` file and push`,
step2,
"",
" 3. Go to a GitHub issue and comment `/oc summarize` to see the agent in action",
"",
" Learn more about the GitHub agent - https://opencode.ai/docs/github/#usage-examples",
].join("\n"),
)
}
async function getAppInfo() {
const project = Instance.project
if (project.vcs !== "git") {
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
throw new UI.CancelledError()
}
// Get repo info
const info = await AppRuntime.runPromise(
Git.Service.use((git) => git.run(["remote", "get-url", "origin"], { cwd: Instance.worktree })),
).then((x) => x.text().trim())
const parsed = parseGitHubRemote(info)
if (!parsed) {
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
throw new UI.CancelledError()
}
return { owner: parsed.owner, repo: parsed.repo, root: Instance.worktree }
}
async function promptProvider() {
const priority: Record<string, number> = {
opencode: 0,
anthropic: 1,
openai: 2,
google: 3,
}
let provider = await prompts.select({
message: "Select provider",
maxItems: 8,
options: pipe(
providers,
values(),
sortBy(
(x) => priority[x.id] ?? 99,
(x) => x.name ?? x.id,
),
map((x) => ({
label: x.name,
value: x.id,
hint: priority[x.id] === 0 ? "recommended" : undefined,
})),
),
})
if (prompts.isCancel(provider)) throw new UI.CancelledError()
return provider
}
async function promptModel() {
const providerData = providers[provider]!
const model = await prompts.select({
message: "Select model",
maxItems: 8,
options: pipe(
providerData.models,
values(),
sortBy((x) => x.name ?? x.id),
map((x) => ({
label: x.name ?? x.id,
value: x.id,
})),
),
})
if (prompts.isCancel(model)) throw new UI.CancelledError()
return model
}
async function installGitHubApp() {
const s = prompts.spinner()
s.start("Installing GitHub app")
// Get installation
// Wait for installation
s.message("Waiting for GitHub app to be installed")
const MAX_RETRIES = 120
let retries = 0
do {
const installation = await getInstallation()
if (installation) return s.stop("GitHub app already installed")
if (installation) break
// Open browser
const url = "https://github.com/apps/opencode-agent"
const command =
process.platform === "darwin"
? `open "${url}"`
: process.platform === "win32"
? `start "" "${url}"`
: `xdg-open "${url}"`
exec(command, (error) => {
if (error) {
prompts.log.warn(`Could not open browser. Please visit: ${url}`)
}
})
// Wait for installation
s.message("Waiting for GitHub app to be installed")
const MAX_RETRIES = 120
let retries = 0
do {
const installation = await getInstallation()
if (installation) break
if (retries > MAX_RETRIES) {
s.stop(
`Failed to detect GitHub app installation. Make sure to install the app for the \`${app.owner}/${app.repo}\` repository.`,
)
throw new UI.CancelledError()
}
retries++
await sleep(1000)
} while (true) // oxlint-disable-line no-constant-condition
s.stop("Installed GitHub app")
async function getInstallation() {
return await fetch(
`https://api.opencode.ai/get_github_app_installation?owner=${app.owner}&repo=${app.repo}`,
if (retries > MAX_RETRIES) {
s.stop(
`Failed to detect GitHub app installation. Make sure to install the app for the \`${app.owner}/${app.repo}\` repository.`,
)
.then((res) => res.json())
.then((data) => data.installation)
throw new UI.CancelledError()
}
retries++
await sleep(1000)
} while (true) // oxlint-disable-line no-constant-condition
s.stop("Installed GitHub app")
async function getInstallation() {
return await fetch(
`https://api.opencode.ai/get_github_app_installation?owner=${app.owner}&repo=${app.repo}`,
)
.then((res) => res.json())
.then((data) => data.installation)
}
}
async function addWorkflowFiles() {
const envStr =
provider === "amazon-bedrock"
? ""
: `\n env:${providers[provider].env.map((e) => `\n ${e}: \${{ secrets.${e} }}`).join("")}`
async function addWorkflowFiles() {
const envStr =
provider === "amazon-bedrock"
? ""
: `\n env:${providers[provider].env.map((e) => `\n ${e}: \${{ secrets.${e} }}`).join("")}`
await Filesystem.write(
path.join(app.root, WORKFLOW_FILE),
`name: opencode
await Filesystem.write(
path.join(app.root, WORKFLOW_FILE),
`name: opencode
on:
issue_comment:
@@ -415,17 +415,16 @@ jobs:
uses: anomalyco/opencode/github@latest${envStr}
with:
model: ${provider}/${model}`,
)
)
prompts.log.success(`Added workflow file: "${WORKFLOW_FILE}"`)
}
prompts.log.success(`Added workflow file: "${WORKFLOW_FILE}"`)
}
},
}
})
},
}),
})
export const GithubRunCommand = cmd({
export const GithubRunCommand = effectCmd({
command: "run",
describe: "run the GitHub agent",
builder: (yargs) =>
@@ -438,8 +437,10 @@ export const GithubRunCommand = cmd({
type: "string",
describe: "GitHub personal access token (github_pat_********)",
}),
async handler(args) {
await bootstrap(process.cwd(), async () => {
handler: Effect.fn("Cli.github.run")(function* (args) {
const ctx = yield* InstanceRef
if (!ctx) return yield* Effect.die("InstanceRef not provided")
yield* Effect.promise(async () => {
const isMock = args.token || args.event
const context = isMock ? (JSON.parse(args.event!) as Context) : github.context
@@ -502,21 +503,21 @@ export const GithubRunCommand = cmd({
: "issue"
: undefined
const gitText = async (args: string[]) => {
const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: Instance.worktree })))
const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: ctx.worktree })))
if (result.exitCode !== 0) {
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
}
return result.text().trim()
}
const gitRun = async (args: string[]) => {
const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: Instance.worktree })))
const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: ctx.worktree })))
if (result.exitCode !== 0) {
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
}
return result
}
const gitStatus = (args: string[]) =>
AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: Instance.worktree })))
AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: ctx.worktree })))
const commitChanges = async (summary: string, actor?: string) => {
const args = ["commit", "-m", summary]
if (actor) args.push("-m", `Co-authored-by: ${actor} <${actor}@users.noreply.github.com>`)
@@ -1646,5 +1647,5 @@ query($owner: String!, $repo: String!, $number: Int!) {
})
}
})
},
}),
})

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
import { Auth } from "../../auth"
import { AppRuntime } from "../../effect/app-runtime"
import { cmd } from "./cmd"
import { effectCmd } from "../effect-cmd"
import * as prompts from "@clack/prompts"
import { UI } from "../ui"
import { ModelsDev } from "@/provider/models"
@@ -13,7 +14,6 @@ import os from "os"
import { Config } from "@/config/config"
import { Global } from "@opencode-ai/core/global"
import { Plugin } from "../../plugin"
import { WithInstance } from "../../project/with-instance"
import type { Hooks } from "@opencode-ai/plugin"
import { Process } from "@/util/process"
import { text } from "node:stream/consumers"
@@ -232,58 +232,59 @@ export const ProvidersCommand = cmd({
async handler() {},
})
export const ProvidersListCommand = cmd({
export const ProvidersListCommand = effectCmd({
command: "list",
aliases: ["ls"],
describe: "list providers and credentials",
async handler(_args) {
UI.empty()
const authPath = path.join(Global.Path.data, "auth.json")
const homedir = os.homedir()
const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath
prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
const results = await AppRuntime.runPromise(
Effect.gen(function* () {
const auth = yield* Auth.Service
return Object.entries(yield* auth.all())
}),
)
const database = await getModels()
// Lists global credentials + provider env vars; no project instance needed.
instance: false,
handler: Effect.fn("Cli.providers.list")(function* (_args) {
const authSvc = yield* Auth.Service
const modelsDev = yield* ModelsDev.Service
yield* Effect.promise(async () => {
UI.empty()
const authPath = path.join(Global.Path.data, "auth.json")
const homedir = os.homedir()
const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath
prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
const results = Object.entries(await Effect.runPromise(authSvc.all()))
const database = await Effect.runPromise(modelsDev.get())
for (const [providerID, result] of results) {
const name = database[providerID]?.name || providerID
prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`)
}
for (const [providerID, result] of results) {
const name = database[providerID]?.name || providerID
prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`)
}
prompts.outro(`${results.length} credentials`)
prompts.outro(`${results.length} credentials`)
const activeEnvVars: Array<{ provider: string; envVar: string }> = []
const activeEnvVars: Array<{ provider: string; envVar: string }> = []
for (const [providerID, provider] of Object.entries(database)) {
for (const envVar of provider.env) {
if (process.env[envVar]) {
activeEnvVars.push({
provider: provider.name || providerID,
envVar,
})
for (const [providerID, provider] of Object.entries(database)) {
for (const envVar of provider.env) {
if (process.env[envVar]) {
activeEnvVars.push({
provider: provider.name || providerID,
envVar,
})
}
}
}
}
if (activeEnvVars.length > 0) {
UI.empty()
prompts.intro("Environment")
if (activeEnvVars.length > 0) {
UI.empty()
prompts.intro("Environment")
for (const { provider, envVar } of activeEnvVars) {
prompts.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`)
for (const { provider, envVar } of activeEnvVars) {
prompts.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`)
}
prompts.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s"))
}
prompts.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s"))
}
},
})
}),
})
export const ProvidersLoginCommand = cmd({
export const ProvidersLoginCommand = effectCmd({
command: "login [url]",
describe: "log in to a provider",
builder: (yargs) =>
@@ -302,228 +303,219 @@ export const ProvidersLoginCommand = cmd({
describe: "login method label (skips method selection)",
type: "string",
}),
async handler(args) {
await WithInstance.provide({
directory: process.cwd(),
async fn() {
UI.empty()
prompts.intro("Add credential")
if (args.url) {
const url = args.url.replace(/\/+$/, "")
const wellknown = (await fetch(`${url}/.well-known/opencode`).then((x) => x.json())) as {
auth: { command: string[]; env: string }
}
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
const proc = Process.spawn(wellknown.auth.command, {
stdout: "pipe",
})
if (!proc.stdout) {
prompts.log.error("Failed")
prompts.outro("Done")
return
}
const [exit, token] = await Promise.all([proc.exited, text(proc.stdout)])
if (exit !== 0) {
prompts.log.error("Failed")
prompts.outro("Done")
return
}
await put(url, {
type: "wellknown",
key: wellknown.auth.env,
token: token.trim(),
})
prompts.log.success("Logged into " + url)
handler: Effect.fn("Cli.providers.login")(function* (args) {
const cfgSvc = yield* Config.Service
const pluginSvc = yield* Plugin.Service
yield* Effect.promise(async () => {
UI.empty()
prompts.intro("Add credential")
if (args.url) {
const url = args.url.replace(/\/+$/, "")
const wellknown = (await fetch(`${url}/.well-known/opencode`).then((x) => x.json())) as {
auth: { command: string[]; env: string }
}
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
const proc = Process.spawn(wellknown.auth.command, {
stdout: "pipe",
stderr: "inherit",
})
if (!proc.stdout) {
prompts.log.error("Failed")
prompts.outro("Done")
return
}
await refreshModels().catch(() => {})
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
const disabled = new Set(config.disabled_providers ?? [])
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
const providers = await getModels().then((x) => {
const filtered: Record<string, (typeof x)[string]> = {}
for (const [key, value] of Object.entries(x)) {
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
filtered[key] = value
}
}
return filtered
})
const hooks = await AppRuntime.runPromise(
Effect.gen(function* () {
const plugin = yield* Plugin.Service
return yield* plugin.list()
}),
)
const priority: Record<string, number> = {
opencode: 0,
openai: 1,
"github-copilot": 2,
google: 3,
anthropic: 4,
openrouter: 5,
vercel: 6,
const [exit, token] = await Promise.all([proc.exited, text(proc.stdout)])
if (exit !== 0) {
prompts.log.error("Failed")
prompts.outro("Done")
return
}
const pluginProviders = resolvePluginProviders({
hooks,
existingProviders: providers,
disabled,
enabled,
providerNames: Object.fromEntries(Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name])),
await put(url, {
type: "wellknown",
key: wellknown.auth.env,
token: token.trim(),
})
const options = [
...pipe(
providers,
values(),
sortBy(
(x) => priority[x.id] ?? 99,
(x) => x.name ?? x.id,
),
map((x) => ({
label: x.name,
value: x.id,
hint: {
opencode: "recommended",
openai: "ChatGPT Plus/Pro or API key",
}[x.id],
})),
prompts.log.success("Logged into " + url)
prompts.outro("Done")
return
}
await refreshModels().catch(() => {})
const config = await Effect.runPromise(cfgSvc.get())
const disabled = new Set(config.disabled_providers ?? [])
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
const providers = await getModels().then((x) => {
const filtered: Record<string, (typeof x)[string]> = {}
for (const [key, value] of Object.entries(x)) {
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
filtered[key] = value
}
}
return filtered
})
const hooks = await Effect.runPromise(pluginSvc.list())
const priority: Record<string, number> = {
opencode: 0,
openai: 1,
"github-copilot": 2,
google: 3,
anthropic: 4,
openrouter: 5,
vercel: 6,
}
const pluginProviders = resolvePluginProviders({
hooks,
existingProviders: providers,
disabled,
enabled,
providerNames: Object.fromEntries(Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name])),
})
const options = [
...pipe(
providers,
values(),
sortBy(
(x) => priority[x.id] ?? 99,
(x) => x.name ?? x.id,
),
...pluginProviders.map((x) => ({
map((x) => ({
label: x.name,
value: x.id,
hint: "plugin",
hint: {
opencode: "recommended",
openai: "ChatGPT Plus/Pro or API key",
}[x.id],
})),
]
),
...pluginProviders.map((x) => ({
label: x.name,
value: x.id,
hint: "plugin",
})),
]
let provider: string
if (args.provider) {
const input = args.provider
const byID = options.find((x) => x.value === input)
const byName = options.find((x) => x.label.toLowerCase() === input.toLowerCase())
const match = byID ?? byName
if (!match) {
prompts.log.error(`Unknown provider "${input}"`)
process.exit(1)
}
provider = match.value
} else {
const selected = await prompts.autocomplete({
message: "Select provider",
maxItems: 8,
options: [
...options,
{
value: "other",
label: "Other",
},
],
})
if (prompts.isCancel(selected)) throw new UI.CancelledError()
provider = selected as string
let provider: string
if (args.provider) {
const input = args.provider
const byID = options.find((x) => x.value === input)
const byName = options.find((x) => x.label.toLowerCase() === input.toLowerCase())
const match = byID ?? byName
if (!match) {
prompts.log.error(`Unknown provider "${input}"`)
process.exit(1)
}
provider = match.value
} else {
const selected = await prompts.autocomplete({
message: "Select provider",
maxItems: 8,
options: [
...options,
{
value: "other",
label: "Other",
},
],
})
if (prompts.isCancel(selected)) throw new UI.CancelledError()
provider = selected as string
}
const plugin = hooks.findLast((x) => x.auth?.provider === provider)
if (plugin && plugin.auth) {
const handled = await handlePluginAuth({ auth: plugin.auth }, provider, args.method)
const plugin = hooks.findLast((x) => x.auth?.provider === provider)
if (plugin && plugin.auth) {
const handled = await handlePluginAuth({ auth: plugin.auth }, provider, args.method)
if (handled) return
}
if (provider === "other") {
const custom = await prompts.text({
message: "Enter provider id",
validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"),
})
if (prompts.isCancel(custom)) throw new UI.CancelledError()
provider = custom.replace(/^@ai-sdk\//, "")
const customPlugin = hooks.findLast((x) => x.auth?.provider === provider)
if (customPlugin && customPlugin.auth) {
const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider, args.method)
if (handled) return
}
if (provider === "other") {
const custom = await prompts.text({
message: "Enter provider id",
validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"),
})
if (prompts.isCancel(custom)) throw new UI.CancelledError()
provider = custom.replace(/^@ai-sdk\//, "")
prompts.log.warn(
`This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
)
}
const customPlugin = hooks.findLast((x) => x.auth?.provider === provider)
if (customPlugin && customPlugin.auth) {
const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider, args.method)
if (handled) return
}
if (provider === "amazon-bedrock") {
prompts.log.info(
"Amazon Bedrock authentication priority:\n" +
" 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" +
" 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" +
"Configure via opencode.json options (profile, region, endpoint) or\n" +
"AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_WEB_IDENTITY_TOKEN_FILE).",
)
}
prompts.log.warn(
`This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
)
}
if (provider === "opencode") {
prompts.log.info("Create an api key at https://opencode.ai/auth")
}
if (provider === "amazon-bedrock") {
prompts.log.info(
"Amazon Bedrock authentication priority:\n" +
" 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" +
" 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" +
"Configure via opencode.json options (profile, region, endpoint) or\n" +
"AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_WEB_IDENTITY_TOKEN_FILE).",
)
}
if (provider === "vercel") {
prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token")
}
if (provider === "opencode") {
prompts.log.info("Create an api key at https://opencode.ai/auth")
}
if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) {
prompts.log.info(
"Cloudflare AI Gateway can be configured with CLOUDFLARE_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_API_TOKEN environment variables. Read more: https://opencode.ai/docs/providers/#cloudflare-ai-gateway",
)
}
if (provider === "vercel") {
prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token")
}
const key = await prompts.password({
message: "Enter your API key",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(key)) throw new UI.CancelledError()
await put(provider, {
type: "api",
key,
})
if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) {
prompts.log.info(
"Cloudflare AI Gateway can be configured with CLOUDFLARE_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_API_TOKEN environment variables. Read more: https://opencode.ai/docs/providers/#cloudflare-ai-gateway",
)
}
const key = await prompts.password({
message: "Enter your API key",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(key)) throw new UI.CancelledError()
await put(provider, {
type: "api",
key,
})
prompts.outro("Done")
},
prompts.outro("Done")
})
},
}),
})
export const ProvidersLogoutCommand = cmd({
export const ProvidersLogoutCommand = effectCmd({
command: "logout",
describe: "log out from a configured provider",
async handler(_args) {
UI.empty()
const credentials: Array<[string, Auth.Info]> = await AppRuntime.runPromise(
Effect.gen(function* () {
const auth = yield* Auth.Service
return Object.entries(yield* auth.all())
}),
)
prompts.intro("Remove credential")
if (credentials.length === 0) {
prompts.log.error("No credentials found")
return
}
const database = await getModels()
const selected = await prompts.select({
message: "Select provider",
options: credentials.map(([key, value]) => ({
label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
value: key,
})),
// Removes a global auth credential; no project instance needed.
instance: false,
handler: Effect.fn("Cli.providers.logout")(function* (_args) {
const authSvc = yield* Auth.Service
const modelsDev = yield* ModelsDev.Service
yield* Effect.promise(async () => {
UI.empty()
const credentials: Array<[string, Auth.Info]> = Object.entries(await Effect.runPromise(authSvc.all()))
prompts.intro("Remove credential")
if (credentials.length === 0) {
prompts.log.error("No credentials found")
return
}
const database = await Effect.runPromise(modelsDev.get())
const selected = await prompts.select({
message: "Select provider",
options: credentials.map(([key, value]) => ({
label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
value: key,
})),
})
if (prompts.isCancel(selected)) throw new UI.CancelledError()
const providerID = selected as string
await Effect.runPromise(authSvc.remove(providerID))
prompts.outro("Logout successful")
})
if (prompts.isCancel(selected)) throw new UI.CancelledError()
const providerID = selected as string
await AppRuntime.runPromise(
Effect.gen(function* () {
const auth = yield* Auth.Service
yield* auth.remove(providerID)
}),
)
prompts.outro("Logout successful")
},
}),
})

View File

@@ -5,7 +5,6 @@ import { Database } from "@/storage/db"
import { SessionTable } from "../../session/session.sql"
import { Project } from "@/project/project"
import { InstanceRef } from "@/effect/instance-ref"
import { AppRuntime } from "@/effect/app-runtime"
interface SessionStats {
totalSessions: number
@@ -69,38 +68,28 @@ export const StatsCommand = effectCmd({
handler: Effect.fn("Cli.stats")(function* (args) {
const ctx = yield* InstanceRef
if (!ctx) return
return yield* run(args, ctx.project)
}),
})
const run = (
args: { days?: number; tools?: number; models?: unknown; project?: string },
currentProject: Project.Info,
) =>
Effect.promise(async () => {
const stats = await aggregateSessionStats(args.days, args.project, currentProject)
const stats = yield* aggregateSessionStats(args.days, args.project, ctx.project)
let modelLimit: number | undefined
if (args.models === true) {
modelLimit = Infinity
} else if (typeof args.models === "number") {
modelLimit = args.models
}
displayStats(stats, args.tools, modelLimit)
})
}),
})
async function getAllSessions(): Promise<Session.Info[]> {
const rows = Database.use((db) => db.select().from(SessionTable).all())
return rows.map((row) => Session.fromRow(row))
}
const getAllSessions = Effect.sync(() =>
Database.use((db) => db.select().from(SessionTable).all()).map((row) => Session.fromRow(row)),
)
export async function aggregateSessionStats(
const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* (
days?: number,
projectFilter?: string,
currentProject?: Project.Info,
): Promise<SessionStats> {
const sessions = await getAllSessions()
) {
const svc = yield* Session.Service
const sessions = yield* getAllSessions
const MS_IN_DAY = 24 * 60 * 60 * 1000
const cutoffTime = (() => {
@@ -169,122 +158,111 @@ export async function aggregateSessionStats(
const sessionTotalTokens: number[] = []
const BATCH_SIZE = 20
for (let i = 0; i < filteredSessions.length; i += BATCH_SIZE) {
const batch = filteredSessions.slice(i, i + BATCH_SIZE)
const results = yield* Effect.forEach(
filteredSessions,
(session) =>
Effect.gen(function* () {
const messages = yield* svc.messages({ sessionID: session.id })
const batchPromises = batch.map(async (session) => {
const messages = await AppRuntime.runPromise(
Session.Service.use((svc) => svc.messages({ sessionID: session.id })),
)
let sessionCost = 0
let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
let sessionToolUsage: Record<string, number> = {}
let sessionModelUsage: Record<
string,
{
messages: number
tokens: { input: number; output: number; cache: { read: number; write: number } }
cost: number
}
> = {}
let sessionCost = 0
let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
let sessionToolUsage: Record<string, number> = {}
let sessionModelUsage: Record<
string,
{
messages: number
tokens: {
input: number
output: number
cache: {
read: number
write: number
for (const message of messages) {
if (message.info.role === "assistant") {
sessionCost += message.info.cost || 0
const modelKey = `${message.info.providerID}/${message.info.modelID}`
if (!sessionModelUsage[modelKey]) {
sessionModelUsage[modelKey] = {
messages: 0,
tokens: { input: 0, output: 0, cache: { read: 0, write: 0 } },
cost: 0,
}
}
sessionModelUsage[modelKey].messages++
sessionModelUsage[modelKey].cost += message.info.cost || 0
if (message.info.tokens) {
sessionTokens.input += message.info.tokens.input || 0
sessionTokens.output += message.info.tokens.output || 0
sessionTokens.reasoning += message.info.tokens.reasoning || 0
sessionTokens.cache.read += message.info.tokens.cache?.read || 0
sessionTokens.cache.write += message.info.tokens.cache?.write || 0
sessionModelUsage[modelKey].tokens.input += message.info.tokens.input || 0
sessionModelUsage[modelKey].tokens.output +=
(message.info.tokens.output || 0) + (message.info.tokens.reasoning || 0)
sessionModelUsage[modelKey].tokens.cache.read += message.info.tokens.cache?.read || 0
sessionModelUsage[modelKey].tokens.cache.write += message.info.tokens.cache?.write || 0
}
}
cost: number
}
> = {}
for (const message of messages) {
if (message.info.role === "assistant") {
sessionCost += message.info.cost || 0
const modelKey = `${message.info.providerID}/${message.info.modelID}`
if (!sessionModelUsage[modelKey]) {
sessionModelUsage[modelKey] = {
messages: 0,
tokens: { input: 0, output: 0, cache: { read: 0, write: 0 } },
cost: 0,
for (const part of message.parts) {
if (part.type === "tool" && part.tool) {
sessionToolUsage[part.tool] = (sessionToolUsage[part.tool] || 0) + 1
}
}
sessionModelUsage[modelKey].messages++
sessionModelUsage[modelKey].cost += message.info.cost || 0
if (message.info.tokens) {
sessionTokens.input += message.info.tokens.input || 0
sessionTokens.output += message.info.tokens.output || 0
sessionTokens.reasoning += message.info.tokens.reasoning || 0
sessionTokens.cache.read += message.info.tokens.cache?.read || 0
sessionTokens.cache.write += message.info.tokens.cache?.write || 0
sessionModelUsage[modelKey].tokens.input += message.info.tokens.input || 0
sessionModelUsage[modelKey].tokens.output +=
(message.info.tokens.output || 0) + (message.info.tokens.reasoning || 0)
sessionModelUsage[modelKey].tokens.cache.read += message.info.tokens.cache?.read || 0
sessionModelUsage[modelKey].tokens.cache.write += message.info.tokens.cache?.write || 0
}
}
for (const part of message.parts) {
if (part.type === "tool" && part.tool) {
sessionToolUsage[part.tool] = (sessionToolUsage[part.tool] || 0) + 1
}
return {
messageCount: messages.length,
sessionCost,
sessionTokens,
sessionTotalTokens:
sessionTokens.input +
sessionTokens.output +
sessionTokens.reasoning +
sessionTokens.cache.read +
sessionTokens.cache.write,
sessionToolUsage,
sessionModelUsage,
earliestTime: cutoffTime > 0 ? session.time.updated : session.time.created,
latestTime: session.time.updated,
}
}),
{ concurrency: 20 },
)
for (const result of results) {
earliestTime = Math.min(earliestTime, result.earliestTime)
latestTime = Math.max(latestTime, result.latestTime)
sessionTotalTokens.push(result.sessionTotalTokens)
stats.totalMessages += result.messageCount
stats.totalCost += result.sessionCost
stats.totalTokens.input += result.sessionTokens.input
stats.totalTokens.output += result.sessionTokens.output
stats.totalTokens.reasoning += result.sessionTokens.reasoning
stats.totalTokens.cache.read += result.sessionTokens.cache.read
stats.totalTokens.cache.write += result.sessionTokens.cache.write
for (const [tool, count] of Object.entries(result.sessionToolUsage)) {
stats.toolUsage[tool] = (stats.toolUsage[tool] || 0) + count
}
for (const [model, usage] of Object.entries(result.sessionModelUsage)) {
if (!stats.modelUsage[model]) {
stats.modelUsage[model] = {
messages: 0,
tokens: { input: 0, output: 0, cache: { read: 0, write: 0 } },
cost: 0,
}
}
return {
messageCount: messages.length,
sessionCost,
sessionTokens,
sessionTotalTokens:
sessionTokens.input +
sessionTokens.output +
sessionTokens.reasoning +
sessionTokens.cache.read +
sessionTokens.cache.write,
sessionToolUsage,
sessionModelUsage,
earliestTime: cutoffTime > 0 ? session.time.updated : session.time.created,
latestTime: session.time.updated,
}
})
const batchResults = await Promise.all(batchPromises)
for (const result of batchResults) {
earliestTime = Math.min(earliestTime, result.earliestTime)
latestTime = Math.max(latestTime, result.latestTime)
sessionTotalTokens.push(result.sessionTotalTokens)
stats.totalMessages += result.messageCount
stats.totalCost += result.sessionCost
stats.totalTokens.input += result.sessionTokens.input
stats.totalTokens.output += result.sessionTokens.output
stats.totalTokens.reasoning += result.sessionTokens.reasoning
stats.totalTokens.cache.read += result.sessionTokens.cache.read
stats.totalTokens.cache.write += result.sessionTokens.cache.write
for (const [tool, count] of Object.entries(result.sessionToolUsage)) {
stats.toolUsage[tool] = (stats.toolUsage[tool] || 0) + count
}
for (const [model, usage] of Object.entries(result.sessionModelUsage)) {
if (!stats.modelUsage[model]) {
stats.modelUsage[model] = {
messages: 0,
tokens: { input: 0, output: 0, cache: { read: 0, write: 0 } },
cost: 0,
}
}
stats.modelUsage[model].messages += usage.messages
stats.modelUsage[model].tokens.input += usage.tokens.input
stats.modelUsage[model].tokens.output += usage.tokens.output
stats.modelUsage[model].tokens.cache.read += usage.tokens.cache.read
stats.modelUsage[model].tokens.cache.write += usage.tokens.cache.write
stats.modelUsage[model].cost += usage.cost
}
stats.modelUsage[model].messages += usage.messages
stats.modelUsage[model].tokens.input += usage.tokens.input
stats.modelUsage[model].tokens.output += usage.tokens.output
stats.modelUsage[model].tokens.cache.read += usage.tokens.cache.read
stats.modelUsage[model].tokens.cache.write += usage.tokens.cache.write
stats.modelUsage[model].cost += usage.cost
}
}
@@ -313,7 +291,7 @@ export async function aggregateSessionStats(
: sessionTotalTokens[mid]
return stats
}
})
export function displayStats(stats: SessionStats, toolLimit?: number, modelLimit?: number) {
const width = 56

View File

@@ -3,6 +3,7 @@ import { Effect, Schema } from "effect"
import { AppRuntime, type AppServices } from "@/effect/app-runtime"
import { InstanceStore } from "@/project/instance-store"
import { InstanceRef } from "@/effect/instance-ref"
import { Instance } from "@/project/instance"
import { cmd, type WithDoubleDash } from "./cmd/cmd"
/**
@@ -82,17 +83,21 @@ export const effectCmd = <Args, A>(opts: EffectCmdOpts<Args, A>) =>
return
}
const directory = opts.directory?.(args) ?? process.cwd()
await AppRuntime.runPromise(
InstanceStore.Service.use((store) =>
store.provide(
{ directory },
Effect.gen(function* () {
const ctx = yield* InstanceRef
const body = opts.handler(args)
return ctx ? yield* body.pipe(Effect.ensuring(store.dispose(ctx))) : yield* body
}),
),
),
// Two-phase: load ctx, then run body inside Instance.current ALS.
// Effect's InstanceRef is provided via fiber context, but that context is
// lost across `await` inside `Effect.promise(async () => ...)` callbacks
// — when handlers re-enter Effect via `AppRuntime.runPromise(svc.method())`
// there, attach() falls back to Instance.current ALS, which Node preserves
// across awaits. Matches the pre-effectCmd `bootstrap()` behavior.
const { store, ctx } = await AppRuntime.runPromise(
InstanceStore.Service.use((store) => store.load({ directory }).pipe(Effect.map((ctx) => ({ store, ctx })))),
)
try {
await Instance.restore(ctx, () =>
AppRuntime.runPromise(opts.handler(args).pipe(Effect.provideService(InstanceRef, ctx))),
)
} finally {
await AppRuntime.runPromise(store.dispose(ctx))
}
},
})

View File

@@ -18,7 +18,7 @@ import { Todo } from "@/session/todo"
import { MessageID, PartID, SessionID } from "@/session/schema"
import { NotFoundError } from "@/storage/storage"
import { NamedError } from "@opencode-ai/core/util/error"
import { Cause, Effect, Schema, Scope } from "effect"
import { Cause, Effect, Option, Schema, Scope } from "effect"
import * as Stream from "effect/Stream"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder, HttpApiError, HttpApiSchema } from "effect/unstable/httpapi"
@@ -125,7 +125,9 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
if (!page.cursor) return page.items
const request = yield* HttpServerRequest.HttpServerRequest
const url = new URL(request.url, "http://localhost")
// toURL() honors the Host + x-forwarded-proto headers, so the Link
// header echoes the real origin instead of a hard-coded localhost.
const url = Option.getOrElse(HttpServerRequest.toURL(request), () => new URL(request.url, "http://localhost"))
url.searchParams.set("limit", ctx.query.limit.toString())
url.searchParams.set("before", page.cursor)
return HttpServerResponse.jsonUnsafe(page.items, {

View File

@@ -11,6 +11,7 @@ import { ProjectID } from "@/project/schema"
import { ModelID, ProviderID } from "@/provider/schema"
import { SessionEvent } from "./session-event"
import { V2Schema } from "./schema"
import { optionalOmitUndefined } from "@/util/schema"
export const Delivery = Schema.Union([Schema.Literal("immediate"), Schema.Literal("deferred")]).annotate({
identifier: "Session.Delivery",
@@ -21,20 +22,20 @@ export const DefaultDelivery = "immediate" satisfies Delivery
export class Info extends Schema.Class<Info>("Session.Info")({
id: SessionID,
parentID: SessionID.pipe(Schema.optional),
parentID: optionalOmitUndefined(SessionID),
projectID: ProjectID,
workspaceID: WorkspaceID.pipe(Schema.optional),
path: Schema.String.pipe(Schema.optional),
agent: Schema.String.pipe(Schema.optional),
workspaceID: optionalOmitUndefined(WorkspaceID),
path: optionalOmitUndefined(Schema.String),
agent: optionalOmitUndefined(Schema.String),
model: Schema.Struct({
id: ModelID,
providerID: ProviderID,
variant: Schema.String.pipe(Schema.optional),
}).pipe(Schema.optional),
variant: optionalOmitUndefined(Schema.String),
}).pipe(optionalOmitUndefined),
time: Schema.Struct({
created: V2Schema.DateTimeUtcFromMillis,
updated: V2Schema.DateTimeUtcFromMillis,
archived: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional),
archived: optionalOmitUndefined(V2Schema.DateTimeUtcFromMillis),
}),
title: Schema.String,
/*
@@ -109,7 +110,7 @@ export const layer = Layer.effect(
decodeMessage({ ...row.data, id: row.id, type: row.type })
function fromRow(row: typeof SessionTable.$inferSelect): Info {
return {
return new Info({
id: SessionID.make(row.id),
projectID: ProjectID.make(row.project_id),
workspaceID: row.workspace_id ? WorkspaceID.make(row.workspace_id) : undefined,
@@ -129,7 +130,7 @@ export const layer = Layer.effect(
updated: DateTime.makeUnsafe(row.time_updated),
archived: row.time_archived ? DateTime.makeUnsafe(row.time_archived) : undefined,
},
}
})
}
const result: Interface = {

View File

@@ -0,0 +1,48 @@
import { afterEach, expect, test } from "bun:test"
import { Effect } from "effect"
import fs from "fs/promises"
import { Instance } from "../../src/project/instance"
import { disposeAllInstances, provideTestInstance, tmpdir } from "../fixture/fixture"
afterEach(async () => {
await disposeAllInstances()
})
// Regression for PR #25522: when an effectCmd handler does
// `yield* Effect.promise(async () => { ... await runPromise(svcMethod) ... })`,
// the inner runPromise creates a fresh fiber after `await` whose Effect context
// has lost the outer InstanceRef. Services that read `InstanceState.context`
// then fall back to `Instance.current` ALS, which must be installed at the JS
// callback boundary (Node ALS persists across awaits, Effect's fiber context
// does not). `provideTestInstance` mirrors effectCmd's load + ALS-restore wrap.
// Pins effect-cmd.ts directly: the pattern test below exercises the load +
// Instance.restore + dispose triple via the shared `provideTestInstance` fixture,
// so a regression that removed `Instance.restore` from effect-cmd.ts wouldn't
// fail it. This grep guards the actual production callsite.
test("effect-cmd.ts wraps the handler body in Instance.restore", async () => {
const source = await fs.readFile(new URL("../../src/cli/effect-cmd.ts", import.meta.url), "utf8")
expect(source).toContain("Instance.restore(ctx")
})
test("Instance.current reachable from inner runPromise inside Effect.promise(async)", async () => {
await using dir = await tmpdir({ git: true })
await provideTestInstance({
directory: dir.path,
fn: () =>
Effect.runPromise(
Effect.promise(async () => {
await new Promise((r) => setTimeout(r, 5))
const current = await Effect.runPromise(
Effect.sync(() => {
try {
return Instance.current
} catch {
return undefined
}
}),
)
expect(current?.directory).toBe(dir.path)
}),
),
})
})

View File

@@ -0,0 +1,128 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag"
import * as Log from "@opencode-ai/core/util/log"
import { WithInstance } from "../../src/project/with-instance"
import { Server } from "../../src/server/server"
import { Session } from "@/session/session"
import { MessageID } from "../../src/session/schema"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
await disposeAllInstances()
await resetDatabase()
})
function app(experimental: boolean) {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
return experimental ? Server.Default().app : Server.Legacy().app
}
function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
return Effect.runPromise(fx.pipe(Effect.provide(Session.defaultLayer)))
}
function createSessionWithMessages(directory: string, count: number) {
return WithInstance.provide({
directory,
fn: async () => {
const session = await runSession(Session.Service.use((svc) => svc.create({})))
for (let i = 0; i < count; i++) {
await runSession(
Effect.gen(function* () {
const svc = yield* Session.Service
yield* svc.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: session.id,
agent: "build",
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
time: { created: Date.now() },
})
}),
)
}
return session.id
},
})
}
// ──────────────────────────────────────────────────────────────────────────────
// Reproducer 1: Link header should reflect the request's actual Host header,
// not "localhost". HttpApi uses `new URL(request.url, "http://localhost")`
// which embeds localhost because request.url is path-only. Fix: use
// `HttpServerRequest.toURL(request)` which honors the Host header.
// ──────────────────────────────────────────────────────────────────────────────
describe("Link header host", () => {
test("HttpApi pagination Link header echoes request host", async () => {
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
const sessionID = await createSessionWithMessages(tmp.path, 3)
const response = await app(true).request(`/session/${sessionID}/message?limit=2`, {
headers: {
host: "opencode.test:4096",
"x-opencode-directory": tmp.path,
},
})
expect(response.status).toBe(200)
const link = response.headers.get("link")
expect(link).not.toBeNull()
// Link should contain the request's Host, not "localhost".
expect(link).toContain("opencode.test")
expect(link).not.toContain("localhost")
})
})
// ──────────────────────────────────────────────────────────────────────────────
// Reproducer 2: GET /session/{missing-id}/todo should return 404, not 500.
// The session.todo handler in HttpApi doesn't wrap with `mapNotFound`, so a
// `NotFoundError` from the service surfaces as a defect → 500. Hono's
// equivalent maps to 404 via `errors.notFound`.
//
// Affected endpoints (handlers without mapNotFound): todo, diff, summarize,
// fork, abort, init, deleteMessage, command, shell, revert, unrevert.
//
// FIXME: unskip when mapNotFound coverage is added (next PR).
// ──────────────────────────────────────────────────────────────────────────────
describe("404 mapping for missing session", () => {
test.todo("HttpApi /session/{missing}/todo returns 404 not 500", async () => {
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
const response = await app(true).request("/session/ses_does_not_exist/todo", {
headers: { "x-opencode-directory": tmp.path },
})
expect(response.status).toBe(404)
})
})
// ──────────────────────────────────────────────────────────────────────────────
// Reproducer 3: 404 response body shape should match Hono's NamedError
// envelope `{ name, data: { message } }`. HttpApi returns the typed-error
// shape `{ _tag }` instead. SDK consumers reading `error.data.message`
// see undefined.
//
// FIXME: unskip when error JSON shape policy is decided + applied (separate PR).
// ──────────────────────────────────────────────────────────────────────────────
describe("Error JSON shape parity", () => {
test.todo("HttpApi 404 body matches NamedError shape", async () => {
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
const response = await app(true).request("/session/ses_does_not_exist", {
headers: { "x-opencode-directory": tmp.path },
})
expect(response.status).toBe(404)
const body = (await response.json()) as { name?: string; data?: { message?: string } }
expect(body.name).toBe("NotFoundError")
expect(typeof body.data?.message).toBe("string")
})
})

View File

@@ -35,7 +35,7 @@
width: 100%;
max-height: 100%;
min-height: 280px;
overflow: hidden;
overflow: auto;
pointer-events: auto;
/* Hide scrollbar */
@@ -102,8 +102,7 @@
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow-y: auto;
overflow: hidden;
&:focus-visible {
outline: none;

View File

@@ -29,33 +29,33 @@ const Context = createContext<ReturnType<typeof init>>()
function init() {
const [active, setActive] = createSignal<Active | undefined>()
let timer: ReturnType<typeof setTimeout> | undefined
let locked = false
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
const lock = { value: false }
onCleanup(() => {
if (timer === undefined) return
clearTimeout(timer)
timer = undefined
if (timer.current === undefined) return
clearTimeout(timer.current)
timer.current = undefined
})
const close = () => {
const current = active()
if (!current || locked) return
locked = true
if (!current || lock.value) return
lock.value = true
current.onClose?.()
current.setClosing(true)
const id = current.id
if (timer !== undefined) {
clearTimeout(timer)
timer = undefined
if (timer.current !== undefined) {
clearTimeout(timer.current)
timer.current = undefined
}
timer = setTimeout(() => {
timer = undefined
timer.current = setTimeout(() => {
timer.current = undefined
current.dispose()
if (active()?.id === id) setActive(undefined)
locked = false
lock.value = false
}, 100)
}
@@ -80,11 +80,11 @@ function init() {
setActive(undefined)
}
if (timer !== undefined) {
clearTimeout(timer)
timer = undefined
if (timer.current !== undefined) {
clearTimeout(timer.current)
timer.current = undefined
}
locked = false
lock.value = false
const id = Math.random().toString(36).slice(2)
let dispose: (() => void) | undefined
@@ -105,7 +105,7 @@ function init() {
}}
>
<Kobalte.Portal>
<Kobalte.Overlay data-component="dialog-overlay" />
<Kobalte.Overlay data-component="dialog-overlay" onClick={close} />
{element()}
</Kobalte.Portal>
</Kobalte>
@@ -115,14 +115,7 @@ function init() {
if (!dispose || !setClosing) return
setActive({
id,
node,
dispose,
owner,
onClose,
setClosing,
})
setActive({ id, node, dispose, owner, onClose, setClosing })
}
return {