mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-22 20:05:23 +00:00
deslop
This commit is contained in:
4
bun.lock
4
bun.lock
@@ -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=="],
|
||||
|
||||
|
||||
@@ -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="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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__")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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"])
|
||||
})
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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?.())
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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("\\", "/")
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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")
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
48
packages/opencode/test/cli/effect-cmd-instance-als.test.ts
Normal file
48
packages/opencode/test/cli/effect-cmd-instance-als.test.ts
Normal 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)
|
||||
}),
|
||||
),
|
||||
})
|
||||
})
|
||||
128
packages/opencode/test/server/httpapi-parity.test.ts
Normal file
128
packages/opencode/test/server/httpapi-parity.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user