Compare commits
6
Commits
beta
..
production
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2eb0606998 | ||
|
|
cc230feca1 | ||
|
|
3ce9b4be0d | ||
|
|
fff7781fa8 | ||
|
|
0a72298062 | ||
|
|
7c320fd463 |
@@ -629,6 +629,7 @@
|
|||||||
"name": "@opencode-ai/stats-app",
|
"name": "@opencode-ai/stats-app",
|
||||||
"version": "1.15.11",
|
"version": "1.15.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ibm/plex": "6.4.1",
|
||||||
"@opencode-ai/stats-core": "workspace:*",
|
"@opencode-ai/stats-core": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
"@solidjs/meta": "catalog:",
|
"@solidjs/meta": "catalog:",
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
"dev:desktop": "bun --cwd packages/desktop dev",
|
"dev:desktop": "bun --cwd packages/desktop dev",
|
||||||
"dev:web": "bun --cwd packages/app dev",
|
"dev:web": "bun --cwd packages/app dev",
|
||||||
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
||||||
"dev:stats": "bun sst shell --stage=dev -- bun run --cwd packages/stats/app dev",
|
"dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev",
|
||||||
"dev:storybook": "bun --cwd packages/storybook storybook",
|
"dev:storybook": "bun --cwd packages/storybook storybook",
|
||||||
"lint": "oxlint",
|
"lint": "oxlint",
|
||||||
"typecheck": "bun turbo typecheck",
|
"typecheck": "bun turbo typecheck",
|
||||||
|
|||||||
+24
-29
@@ -43,7 +43,6 @@ import { PromptProvider } from "@/context/prompt"
|
|||||||
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
||||||
import { SettingsProvider, useSettings } from "@/context/settings"
|
import { SettingsProvider, useSettings } from "@/context/settings"
|
||||||
import { TerminalProvider } from "@/context/terminal"
|
import { TerminalProvider } from "@/context/terminal"
|
||||||
import { WslServersProvider } from "@/context/wsl-servers"
|
|
||||||
import DirectoryLayout from "@/pages/directory-layout"
|
import DirectoryLayout from "@/pages/directory-layout"
|
||||||
import Layout from "@/pages/layout"
|
import Layout from "@/pages/layout"
|
||||||
import { ErrorPage } from "./pages/error"
|
import { ErrorPage } from "./pages/error"
|
||||||
@@ -72,7 +71,7 @@ declare global {
|
|||||||
__OPENCODE__?: {
|
__OPENCODE__?: {
|
||||||
updaterEnabled?: boolean
|
updaterEnabled?: boolean
|
||||||
deepLinks?: string[]
|
deepLinks?: string[]
|
||||||
activeServer?: string
|
wsl?: boolean
|
||||||
}
|
}
|
||||||
api?: {
|
api?: {
|
||||||
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
|
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
|
||||||
@@ -172,13 +171,11 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<WslServersProvider>
|
<DialogProvider>
|
||||||
<DialogProvider>
|
<MarkedProvider>
|
||||||
<MarkedProvider>
|
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
|
||||||
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
|
</MarkedProvider>
|
||||||
</MarkedProvider>
|
</DialogProvider>
|
||||||
</DialogProvider>
|
|
||||||
</WslServersProvider>
|
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</UiI18nBridge>
|
</UiI18nBridge>
|
||||||
@@ -301,11 +298,11 @@ function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key:
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ServerKey(props: { children: (key: ServerConnection.Key) => JSX.Element }) {
|
function ServerKey(props: ParentProps) {
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
return (
|
return (
|
||||||
<Show when={server.key} keyed>
|
<Show when={server.key} keyed>
|
||||||
{(key) => props.children(key)}
|
{props.children}
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -322,24 +319,22 @@ export function AppInterface(props: {
|
|||||||
<ServersProvider>
|
<ServersProvider>
|
||||||
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
|
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
|
||||||
<ServerKey>
|
<ServerKey>
|
||||||
{() => (
|
<QueryProvider>
|
||||||
<QueryProvider>
|
<ServerSDKProvider>
|
||||||
<ServerSDKProvider>
|
<ServerSyncProvider>
|
||||||
<ServerSyncProvider>
|
<Dynamic
|
||||||
<Dynamic
|
component={props.router ?? Router}
|
||||||
component={props.router ?? Router}
|
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
|
||||||
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
|
>
|
||||||
>
|
<Route path="/" component={HomeRoute} />
|
||||||
<Route path="/" component={HomeRoute} />
|
<Route path="/:dir" component={DirectoryLayout}>
|
||||||
<Route path="/:dir" component={DirectoryLayout}>
|
<Route path="/" component={() => <Navigate href="session" />} />
|
||||||
<Route path="/" component={() => <Navigate href="session" />} />
|
<Route path="/session/:id?" component={SessionRoute} />
|
||||||
<Route path="/session/:id?" component={SessionRoute} />
|
</Route>
|
||||||
</Route>
|
</Dynamic>
|
||||||
</Dynamic>
|
</ServerSyncProvider>
|
||||||
</ServerSyncProvider>
|
</ServerSDKProvider>
|
||||||
</ServerSDKProvider>
|
</QueryProvider>
|
||||||
</QueryProvider>
|
|
||||||
)}
|
|
||||||
</ServerKey>
|
</ServerKey>
|
||||||
</ConnectionGate>
|
</ConnectionGate>
|
||||||
</ServersProvider>
|
</ServersProvider>
|
||||||
|
|||||||
@@ -5,26 +5,20 @@ import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
|||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { useMutation } from "@tanstack/solid-query"
|
import { useMutation } from "@tanstack/solid-query"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { batch, createEffect, createMemo, createResource, For, onCleanup, Show, untrack } from "solid-js"
|
import { useNavigate } from "@solidjs/router"
|
||||||
|
import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
|
||||||
import { createStore, reconcile } from "solid-js/store"
|
import { createStore, reconcile } from "solid-js/store"
|
||||||
import { DialogWslServer } from "@/components/dialog-wsl-server"
|
|
||||||
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||||
import { useWslServers } from "@/context/wsl-servers"
|
|
||||||
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
|
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
|
||||||
|
|
||||||
const DEFAULT_USERNAME = "opencode"
|
const DEFAULT_USERNAME = "opencode"
|
||||||
|
|
||||||
interface DialogSelectServerProps {
|
|
||||||
onNavigateHome?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ServerFormProps {
|
interface ServerFormProps {
|
||||||
value: string
|
value: string
|
||||||
name: string
|
name: string
|
||||||
@@ -33,6 +27,7 @@ interface ServerFormProps {
|
|||||||
placeholder: string
|
placeholder: string
|
||||||
busy: boolean
|
busy: boolean
|
||||||
error: string
|
error: string
|
||||||
|
status: boolean | undefined
|
||||||
onChange: (value: string) => void
|
onChange: (value: string) => void
|
||||||
onNameChange: (value: string) => void
|
onNameChange: (value: string) => void
|
||||||
onUsernameChange: (value: string) => void
|
onUsernameChange: (value: string) => void
|
||||||
@@ -49,17 +44,15 @@ function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function isWslSidecar(conn: ServerConnection.Any): conn is ServerConnection.Sidecar & { variant: "wsl" } {
|
|
||||||
return conn.type === "sidecar" && conn.variant === "wsl"
|
|
||||||
}
|
|
||||||
|
|
||||||
function useDefaultServer() {
|
function useDefaultServer() {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const [defaultKey, defaultActions] = createResource(
|
const [defaultKey, defaultUrlActions] = createResource(
|
||||||
async () => {
|
async () => {
|
||||||
try {
|
try {
|
||||||
return (await platform.getDefaultServer?.()) ?? null
|
const key = await platform.getDefaultServer?.()
|
||||||
|
if (!key) return null
|
||||||
|
return key
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showRequestError(language, err)
|
showRequestError(language, err)
|
||||||
return null
|
return null
|
||||||
@@ -67,18 +60,52 @@ function useDefaultServer() {
|
|||||||
},
|
},
|
||||||
{ initialValue: null },
|
{ initialValue: null },
|
||||||
)
|
)
|
||||||
|
|
||||||
const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer)
|
const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer)
|
||||||
const setDefault = async (key: ServerConnection.Key | null) => {
|
const setDefault = async (key: ServerConnection.Key | null) => {
|
||||||
try {
|
try {
|
||||||
await platform.setDefaultServer?.(key)
|
await platform.setDefaultServer?.(key)
|
||||||
defaultActions.mutate(key)
|
defaultUrlActions.mutate(key)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showRequestError(language, err)
|
showRequestError(language, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { defaultKey, canDefault, setDefault }
|
return { defaultKey, canDefault, setDefault }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function useServerPreview() {
|
||||||
|
const checkServerHealth = useCheckServerHealth()
|
||||||
|
|
||||||
|
const looksComplete = (value: string) => {
|
||||||
|
const normalized = normalizeServerUrl(value)
|
||||||
|
if (!normalized) return false
|
||||||
|
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
|
||||||
|
if (!host) return false
|
||||||
|
if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true
|
||||||
|
return host.includes(".") || host.includes(":")
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewStatus = async (
|
||||||
|
value: string,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
setStatus: (value: boolean | undefined) => void,
|
||||||
|
) => {
|
||||||
|
setStatus(undefined)
|
||||||
|
if (!looksComplete(value)) return
|
||||||
|
const normalized = normalizeServerUrl(value)
|
||||||
|
if (!normalized) return
|
||||||
|
const http: ServerConnection.HttpBase = { url: normalized }
|
||||||
|
if (username) http.username = username
|
||||||
|
if (password) http.password = password
|
||||||
|
const result = await checkServerHealth(http)
|
||||||
|
setStatus(result.healthy)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { previewStatus }
|
||||||
|
}
|
||||||
|
|
||||||
function ServerForm(props: ServerFormProps) {
|
function ServerForm(props: ServerFormProps) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const keyDown = (event: KeyboardEvent) => {
|
const keyDown = (event: KeyboardEvent) => {
|
||||||
@@ -144,18 +171,15 @@ function ServerForm(props: ServerFormProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DialogSelectServer(props: DialogSelectServerProps = {}) {
|
export function DialogSelectServer() {
|
||||||
|
const navigate = useNavigate()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const wslServers = useWslServers()
|
const { defaultKey, canDefault, setDefault } = useDefaultServer()
|
||||||
const defaultServer = useDefaultServer()
|
const { previewStatus } = useServerPreview()
|
||||||
const checkServerHealth = useCheckServerHealth()
|
const checkServerHealth = useCheckServerHealth()
|
||||||
let disposed = false
|
|
||||||
onCleanup(() => {
|
|
||||||
disposed = true
|
|
||||||
})
|
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
|
status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
|
||||||
addServer: {
|
addServer: {
|
||||||
@@ -165,9 +189,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
|
|||||||
password: "",
|
password: "",
|
||||||
error: "",
|
error: "",
|
||||||
showForm: false,
|
showForm: false,
|
||||||
},
|
status: undefined as boolean | undefined,
|
||||||
addWsl: {
|
|
||||||
showWizard: false,
|
|
||||||
},
|
},
|
||||||
editServer: {
|
editServer: {
|
||||||
id: undefined as string | undefined,
|
id: undefined as string | undefined,
|
||||||
@@ -176,6 +198,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
|
|||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
error: "",
|
error: "",
|
||||||
|
status: undefined as boolean | undefined,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -187,6 +210,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
|
|||||||
password: "",
|
password: "",
|
||||||
error: "",
|
error: "",
|
||||||
showForm: false,
|
showForm: false,
|
||||||
|
status: undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const resetEdit = () => {
|
const resetEdit = () => {
|
||||||
@@ -197,6 +221,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
|
|||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
error: "",
|
error: "",
|
||||||
|
status: undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,32 +294,6 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const removeWslMutation = useMutation(() => ({
|
|
||||||
mutationFn: async (key: ServerConnection.Key) => {
|
|
||||||
await platform.wslServers?.removeServer(key)
|
|
||||||
return key
|
|
||||||
},
|
|
||||||
onSuccess: async (key) => {
|
|
||||||
if (defaultServer.defaultKey() === key) await defaultServer.setDefault(null)
|
|
||||||
server.remove(key)
|
|
||||||
},
|
|
||||||
onError: (err) => showRequestError(language, err),
|
|
||||||
}))
|
|
||||||
|
|
||||||
const retryWslMutation = useMutation(() => ({
|
|
||||||
mutationFn: async (key: ServerConnection.Key) => {
|
|
||||||
await platform.wslServers?.startServer(key)
|
|
||||||
},
|
|
||||||
onError: (err) => showRequestError(language, err),
|
|
||||||
}))
|
|
||||||
|
|
||||||
const updateWslMutation = useMutation(() => ({
|
|
||||||
mutationFn: async (distro: string) => {
|
|
||||||
await platform.wslServers?.installOpencode(distro)
|
|
||||||
},
|
|
||||||
onError: (err) => showRequestError(language, err),
|
|
||||||
}))
|
|
||||||
|
|
||||||
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
|
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
|
||||||
const active = server.key
|
const active = server.key
|
||||||
const newConn = server.add(next)
|
const newConn = server.add(next)
|
||||||
@@ -313,32 +312,6 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const current = createMemo(() => items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0])
|
const current = createMemo(() => items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0])
|
||||||
const wslState = () => wslServers.data
|
|
||||||
const healthPollKey = createMemo(() =>
|
|
||||||
items()
|
|
||||||
.map((conn) =>
|
|
||||||
[ServerConnection.key(conn), conn.http.url, conn.http.username ?? "", conn.http.password ?? ""].join("\n"),
|
|
||||||
)
|
|
||||||
.join("\n\n"),
|
|
||||||
)
|
|
||||||
const health = (key: ServerConnection.Key) => store.status[key]
|
|
||||||
const wslRuntime = (conn: ServerConnection.Any) => {
|
|
||||||
if (!isWslSidecar(conn)) return
|
|
||||||
return wslState()?.servers.find((item) => item.config.id === ServerConnection.key(conn))?.runtime
|
|
||||||
}
|
|
||||||
const nonReadyWslServers = createMemo(() =>
|
|
||||||
(wslState()?.servers ?? []).filter((item) => item.runtime.kind !== "ready"),
|
|
||||||
)
|
|
||||||
const canRetryWsl = (conn: ServerConnection.Any) => {
|
|
||||||
const runtime = wslRuntime(conn)
|
|
||||||
return runtime?.kind === "failed" || runtime?.kind === "stopped"
|
|
||||||
}
|
|
||||||
const canRetryWslRuntime = (kind: string) => kind === "failed" || kind === "stopped"
|
|
||||||
const wslRuntimeLabel = (kind: string) => {
|
|
||||||
if (kind === "starting") return "Starting"
|
|
||||||
if (kind === "failed") return "Failed"
|
|
||||||
return "Stopped"
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortedItems = createMemo(() => {
|
const sortedItems = createMemo(() => {
|
||||||
const list = items()
|
const list = items()
|
||||||
@@ -353,7 +326,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
|
|||||||
return list.slice().sort((a, b) => {
|
return list.slice().sort((a, b) => {
|
||||||
if (a === active) return -1
|
if (a === active) return -1
|
||||||
if (b === active) return 1
|
if (b === active) return 1
|
||||||
const diff = rank(health(ServerConnection.key(a))) - rank(health(ServerConnection.key(b)))
|
const diff = rank(store.status[ServerConnection.key(a)]) - rank(store.status[ServerConnection.key(b)])
|
||||||
if (diff !== 0) return diff
|
if (diff !== 0) return diff
|
||||||
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
||||||
})
|
})
|
||||||
@@ -361,60 +334,39 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
|
|||||||
|
|
||||||
async function refreshHealth() {
|
async function refreshHealth() {
|
||||||
const results: Record<ServerConnection.Key, ServerHealth> = {}
|
const results: Record<ServerConnection.Key, ServerHealth> = {}
|
||||||
const list = untrack(items)
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
list.map(async (conn) => {
|
items().map(async (conn) => {
|
||||||
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
|
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
if (disposed) return
|
|
||||||
setStore("status", reconcile(results))
|
setStore("status", reconcile(results))
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
healthPollKey()
|
items()
|
||||||
void refreshHealth()
|
void refreshHealth()
|
||||||
const interval = setInterval(refreshHealth, 10_000)
|
const interval = setInterval(refreshHealth, 10_000)
|
||||||
onCleanup(() => clearInterval(interval))
|
onCleanup(() => clearInterval(interval))
|
||||||
})
|
})
|
||||||
|
|
||||||
const wslCheck = (conn: ServerConnection.Any) => {
|
|
||||||
if (!isWslSidecar(conn)) return null
|
|
||||||
return wslState()?.opencodeChecks[conn.distro] ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
async function select(conn: ServerConnection.Any, persist?: boolean) {
|
async function select(conn: ServerConnection.Any, persist?: boolean) {
|
||||||
if (!persist && health(ServerConnection.key(conn))?.healthy === false) return
|
if (!persist && store.status[ServerConnection.key(conn)]?.healthy === false) return
|
||||||
const nextKey = ServerConnection.key(conn)
|
dialog.close()
|
||||||
const changed = server.key !== nextKey
|
if (persist && conn.type === "http") {
|
||||||
|
server.add(conn)
|
||||||
const navigateHome = () => props.onNavigateHome?.()
|
navigate("/")
|
||||||
|
|
||||||
const apply = () => {
|
|
||||||
dialog.close()
|
|
||||||
if (persist && conn.type === "http") {
|
|
||||||
server.add(conn)
|
|
||||||
navigateHome()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
batch(() => {
|
|
||||||
navigateHome()
|
|
||||||
server.setActive(nextKey)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!changed) {
|
|
||||||
await apply()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
navigate("/")
|
||||||
apply()
|
queueMicrotask(() => server.setActive(ServerConnection.key(conn)))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddChange = (value: string) => {
|
const handleAddChange = (value: string) => {
|
||||||
if (addMutation.isPending) return
|
if (addMutation.isPending) return
|
||||||
setStore("addServer", { url: value, error: "" })
|
setStore("addServer", { url: value, error: "" })
|
||||||
|
void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
|
||||||
|
setStore("addServer", { status: next }),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddNameChange = (value: string) => {
|
const handleAddNameChange = (value: string) => {
|
||||||
@@ -425,16 +377,25 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
|
|||||||
const handleAddUsernameChange = (value: string) => {
|
const handleAddUsernameChange = (value: string) => {
|
||||||
if (addMutation.isPending) return
|
if (addMutation.isPending) return
|
||||||
setStore("addServer", { username: value, error: "" })
|
setStore("addServer", { username: value, error: "" })
|
||||||
|
void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
|
||||||
|
setStore("addServer", { status: next }),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddPasswordChange = (value: string) => {
|
const handleAddPasswordChange = (value: string) => {
|
||||||
if (addMutation.isPending) return
|
if (addMutation.isPending) return
|
||||||
setStore("addServer", { password: value, error: "" })
|
setStore("addServer", { password: value, error: "" })
|
||||||
|
void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
|
||||||
|
setStore("addServer", { status: next }),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEditChange = (value: string) => {
|
const handleEditChange = (value: string) => {
|
||||||
if (editMutation.isPending) return
|
if (editMutation.isPending) return
|
||||||
setStore("editServer", { value, error: "" })
|
setStore("editServer", { value, error: "" })
|
||||||
|
void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
|
||||||
|
setStore("editServer", { status: next }),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEditNameChange = (value: string) => {
|
const handleEditNameChange = (value: string) => {
|
||||||
@@ -445,15 +406,20 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
|
|||||||
const handleEditUsernameChange = (value: string) => {
|
const handleEditUsernameChange = (value: string) => {
|
||||||
if (editMutation.isPending) return
|
if (editMutation.isPending) return
|
||||||
setStore("editServer", { username: value, error: "" })
|
setStore("editServer", { username: value, error: "" })
|
||||||
|
void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
|
||||||
|
setStore("editServer", { status: next }),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEditPasswordChange = (value: string) => {
|
const handleEditPasswordChange = (value: string) => {
|
||||||
if (editMutation.isPending) return
|
if (editMutation.isPending) return
|
||||||
setStore("editServer", { password: value, error: "" })
|
setStore("editServer", { password: value, error: "" })
|
||||||
|
void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
|
||||||
|
setStore("editServer", { status: next }),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const mode = createMemo<"list" | "add-wsl" | "add" | "edit">(() => {
|
const mode = createMemo<"list" | "add" | "edit">(() => {
|
||||||
if (store.addWsl.showWizard) return "add-wsl"
|
|
||||||
if (store.editServer.id) return "edit"
|
if (store.editServer.id) return "edit"
|
||||||
if (store.addServer.showForm) return "add"
|
if (store.addServer.showForm) return "add"
|
||||||
return "list"
|
return "list"
|
||||||
@@ -467,11 +433,9 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
|
|||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
resetAdd()
|
resetAdd()
|
||||||
resetEdit()
|
resetEdit()
|
||||||
setStore("addWsl", "showWizard", false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const startAdd = () => {
|
const startAdd = () => {
|
||||||
setStore("addWsl", "showWizard", false)
|
|
||||||
resetEdit()
|
resetEdit()
|
||||||
setStore("addServer", {
|
setStore("addServer", {
|
||||||
showForm: true,
|
showForm: true,
|
||||||
@@ -480,11 +444,11 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
|
|||||||
username: DEFAULT_USERNAME,
|
username: DEFAULT_USERNAME,
|
||||||
password: "",
|
password: "",
|
||||||
error: "",
|
error: "",
|
||||||
|
status: undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const startEdit = (conn: ServerConnection.Http) => {
|
const startEdit = (conn: ServerConnection.Http) => {
|
||||||
setStore("addWsl", "showWizard", false)
|
|
||||||
resetAdd()
|
resetAdd()
|
||||||
setStore("editServer", {
|
setStore("editServer", {
|
||||||
id: conn.http.url,
|
id: conn.http.url,
|
||||||
@@ -493,22 +457,10 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
|
|||||||
username: conn.http.username ?? "",
|
username: conn.http.username ?? "",
|
||||||
password: conn.http.password ?? "",
|
password: conn.http.password ?? "",
|
||||||
error: "",
|
error: "",
|
||||||
|
status: store.status[ServerConnection.key(conn)]?.healthy,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const startAddWsl = () => {
|
|
||||||
resetAdd()
|
|
||||||
resetEdit()
|
|
||||||
setStore("addWsl", "showWizard", true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAddedWsl = async (distro: string) => {
|
|
||||||
const key = ServerConnection.Key.make(`wsl:${distro}`)
|
|
||||||
setStore("addWsl", "showWizard", false)
|
|
||||||
const conn = items().find((item) => ServerConnection.key(item) === key)
|
|
||||||
if (conn) await select(conn)
|
|
||||||
}
|
|
||||||
|
|
||||||
const submitForm = () => {
|
const submitForm = () => {
|
||||||
if (mode() === "add") {
|
if (mode() === "add") {
|
||||||
if (addMutation.isPending) return
|
if (addMutation.isPending) return
|
||||||
@@ -525,22 +477,14 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
|
|||||||
|
|
||||||
const isFormMode = createMemo(() => mode() !== "list")
|
const isFormMode = createMemo(() => mode() !== "list")
|
||||||
const isAddMode = createMemo(() => mode() === "add")
|
const isAddMode = createMemo(() => mode() === "add")
|
||||||
const isAddWslMode = createMemo(() => mode() === "add-wsl")
|
|
||||||
const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending))
|
const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending))
|
||||||
const canAddWsl = createMemo(() => !!platform.wslServers && platform.os === "windows")
|
|
||||||
|
|
||||||
const formTitle = createMemo(() => {
|
const formTitle = createMemo(() => {
|
||||||
if (!isFormMode()) return language.t("dialog.server.title")
|
if (!isFormMode()) return language.t("dialog.server.title")
|
||||||
return (
|
return (
|
||||||
<div class="flex items-center gap-2 -ml-2">
|
<div class="flex items-center gap-2 -ml-2">
|
||||||
<IconButton icon="arrow-left" variant="ghost" onClick={resetForm} aria-label={language.t("common.goBack")} />
|
<IconButton icon="arrow-left" variant="ghost" onClick={resetForm} aria-label={language.t("common.goBack")} />
|
||||||
<span>
|
<span>{isAddMode() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")}</span>
|
||||||
{isAddWslMode()
|
|
||||||
? "Add WSL server"
|
|
||||||
: isAddMode()
|
|
||||||
? language.t("dialog.server.add.title")
|
|
||||||
: language.t("dialog.server.edit.title")}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -551,126 +495,37 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
|
|||||||
resetEdit()
|
resetEdit()
|
||||||
})
|
})
|
||||||
|
|
||||||
async function handleRemove(key: ServerConnection.Key) {
|
async function handleRemove(url: ServerConnection.Key) {
|
||||||
server.remove(key)
|
server.remove(url)
|
||||||
if (defaultServer.defaultKey() === key) await defaultServer.setDefault(null)
|
if ((await platform.getDefaultServer?.()) === url) {
|
||||||
|
void platform.setDefaultServer?.(null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog title={formTitle()}>
|
||||||
title={formTitle()}
|
<div class="flex flex-1 min-h-0 flex-col gap-2">
|
||||||
fit={isAddWslMode()}
|
|
||||||
class={isAddWslMode() ? "[&_[data-slot=dialog-body]]:flex-none [&_[data-slot=dialog-body]]:overflow-visible" : undefined}
|
|
||||||
>
|
|
||||||
<div class={isAddWslMode() ? "flex flex-col gap-2" : "flex flex-1 min-h-0 flex-col gap-2"}>
|
|
||||||
<Show
|
<Show
|
||||||
when={!isFormMode()}
|
when={!isFormMode()}
|
||||||
fallback={
|
fallback={
|
||||||
<Show
|
<ServerForm
|
||||||
when={isAddWslMode()}
|
value={isAddMode() ? store.addServer.url : store.editServer.value}
|
||||||
fallback={
|
name={isAddMode() ? store.addServer.name : store.editServer.name}
|
||||||
<ServerForm
|
username={isAddMode() ? store.addServer.username : store.editServer.username}
|
||||||
value={isAddMode() ? store.addServer.url : store.editServer.value}
|
password={isAddMode() ? store.addServer.password : store.editServer.password}
|
||||||
name={isAddMode() ? store.addServer.name : store.editServer.name}
|
placeholder={language.t("dialog.server.add.placeholder")}
|
||||||
username={isAddMode() ? store.addServer.username : store.editServer.username}
|
busy={formBusy()}
|
||||||
password={isAddMode() ? store.addServer.password : store.editServer.password}
|
error={isAddMode() ? store.addServer.error : store.editServer.error}
|
||||||
placeholder={language.t("dialog.server.add.placeholder")}
|
status={isAddMode() ? store.addServer.status : store.editServer.status}
|
||||||
busy={formBusy()}
|
onChange={isAddMode() ? handleAddChange : handleEditChange}
|
||||||
error={isAddMode() ? store.addServer.error : store.editServer.error}
|
onNameChange={isAddMode() ? handleAddNameChange : handleEditNameChange}
|
||||||
onChange={isAddMode() ? handleAddChange : handleEditChange}
|
onUsernameChange={isAddMode() ? handleAddUsernameChange : handleEditUsernameChange}
|
||||||
onNameChange={isAddMode() ? handleAddNameChange : handleEditNameChange}
|
onPasswordChange={isAddMode() ? handleAddPasswordChange : handleEditPasswordChange}
|
||||||
onUsernameChange={isAddMode() ? handleAddUsernameChange : handleEditUsernameChange}
|
onSubmit={submitForm}
|
||||||
onPasswordChange={isAddMode() ? handleAddPasswordChange : handleEditPasswordChange}
|
onBack={resetForm}
|
||||||
onSubmit={submitForm}
|
/>
|
||||||
onBack={resetForm}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DialogWslServer onAdded={handleAddedWsl} />
|
|
||||||
</Show>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Show when={nonReadyWslServers().length > 0}>
|
|
||||||
<div class="px-5">
|
|
||||||
<div class="bg-surface-base rounded-md overflow-hidden">
|
|
||||||
<For each={nonReadyWslServers()}>
|
|
||||||
{(item) => {
|
|
||||||
const key = ServerConnection.Key.make(item.config.id)
|
|
||||||
const retryable = () => canRetryWslRuntime(item.runtime.kind)
|
|
||||||
return (
|
|
||||||
<div class="min-h-14 p-3 flex items-center gap-3 border-b border-border-weak-base last:border-b-0">
|
|
||||||
<div
|
|
||||||
classList={{
|
|
||||||
"size-1.5 rounded-full shrink-0": true,
|
|
||||||
"bg-icon-critical-base": item.runtime.kind === "failed",
|
|
||||||
"bg-border-weak-base": item.runtime.kind !== "failed",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
|
||||||
<span class="text-14-medium text-text-base truncate">{item.config.distro}</span>
|
|
||||||
<span class="text-11-regular text-text-weak border border-border-weak-base bg-surface-base px-1.5 py-0.5 rounded-md shrink-0">
|
|
||||||
WSL
|
|
||||||
</span>
|
|
||||||
<Show when={defaultServer.defaultKey() === key}>
|
|
||||||
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs shrink-0">
|
|
||||||
{language.t("dialog.server.status.default")}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
<span class="text-12-regular text-text-weak truncate">
|
|
||||||
{wslRuntimeLabel(item.runtime.kind)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenu.Trigger
|
|
||||||
as={IconButton}
|
|
||||||
icon="dot-grid"
|
|
||||||
variant="ghost"
|
|
||||||
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
|
|
||||||
onClick={(e: MouseEvent) => e.stopPropagation()}
|
|
||||||
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
<DropdownMenu.Portal>
|
|
||||||
<DropdownMenu.Content class="mt-1">
|
|
||||||
<Show when={retryable()}>
|
|
||||||
<DropdownMenu.Item onSelect={() => retryWslMutation.mutate(key)}>
|
|
||||||
<DropdownMenu.ItemLabel>Retry start</DropdownMenu.ItemLabel>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
</Show>
|
|
||||||
<Show when={defaultServer.canDefault() && 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={defaultServer.canDefault() && defaultServer.defaultKey() === key}>
|
|
||||||
<DropdownMenu.Item onSelect={() => void defaultServer.setDefault(null)}>
|
|
||||||
<DropdownMenu.ItemLabel>
|
|
||||||
{language.t("dialog.server.menu.defaultRemove")}
|
|
||||||
</DropdownMenu.ItemLabel>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
</Show>
|
|
||||||
<Show when={retryable() || defaultServer.canDefault()}>
|
|
||||||
<DropdownMenu.Separator />
|
|
||||||
</Show>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
onSelect={() => removeWslMutation.mutate(key)}
|
|
||||||
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemLabel>
|
|
||||||
{language.t("dialog.server.menu.delete")}
|
|
||||||
</DropdownMenu.ItemLabel>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Portal>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<List
|
<List
|
||||||
search={{
|
search={{
|
||||||
placeholder: language.t("dialog.server.search.placeholder"),
|
placeholder: language.t("dialog.server.search.placeholder"),
|
||||||
@@ -679,7 +534,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
|
|||||||
noInitialSelection
|
noInitialSelection
|
||||||
emptyMessage={language.t("dialog.server.empty")}
|
emptyMessage={language.t("dialog.server.empty")}
|
||||||
items={sortedItems}
|
items={sortedItems}
|
||||||
key={(x) => ServerConnection.key(x)}
|
key={(x) => x.http.url}
|
||||||
onSelect={(x) => {
|
onSelect={(x) => {
|
||||||
if (x) void select(x)
|
if (x) void select(x)
|
||||||
}}
|
}}
|
||||||
@@ -688,35 +543,18 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
|
|||||||
>
|
>
|
||||||
{(i) => {
|
{(i) => {
|
||||||
const key = ServerConnection.key(i)
|
const key = ServerConnection.key(i)
|
||||||
const wsl = isWslSidecar(i)
|
|
||||||
const wslDistro = wsl ? i.distro : undefined
|
|
||||||
const blocked = () => health(key)?.healthy === false
|
|
||||||
const canChangeDefault = () => defaultServer.canDefault() && (i.type === "http" || wsl)
|
|
||||||
const canRemove = () => i.type === "http" || wsl
|
|
||||||
const opencodeAction = () => {
|
|
||||||
const check = wslCheck(i)
|
|
||||||
if (!check) return null
|
|
||||||
if (!check.resolvedPath) return "Install OpenCode"
|
|
||||||
if (check.matchesDesktop === false) return "Update OpenCode"
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
const updating = () => {
|
|
||||||
const job = wslState()?.job
|
|
||||||
return job?.kind === "install-opencode" && job.distro === wslDistro
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item">
|
<div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item">
|
||||||
<div class="flex flex-col h-full items-start w-5">
|
<div class="flex flex-col h-full items-start w-5">
|
||||||
<ServerHealthIndicator health={health(key)} />
|
<ServerHealthIndicator health={store.status[key]} />
|
||||||
</div>
|
</div>
|
||||||
<ServerRow
|
<ServerRow
|
||||||
conn={i}
|
conn={i}
|
||||||
dimmed={blocked()}
|
dimmed={store.status[key]?.healthy === false}
|
||||||
status={health(key)}
|
status={store.status[key]}
|
||||||
version={wslCheck(i)?.version ?? undefined}
|
|
||||||
class="flex items-center gap-3 min-w-0 flex-1"
|
class="flex items-center gap-3 min-w-0 flex-1"
|
||||||
badge={
|
badge={
|
||||||
<Show when={defaultServer.defaultKey() === ServerConnection.key(i)}>
|
<Show when={defaultKey() === ServerConnection.key(i)}>
|
||||||
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
|
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
|
||||||
{language.t("dialog.server.status.default")}
|
{language.t("dialog.server.status.default")}
|
||||||
</span>
|
</span>
|
||||||
@@ -724,32 +562,12 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
|
|||||||
}
|
}
|
||||||
showCredentials
|
showCredentials
|
||||||
/>
|
/>
|
||||||
<div class="flex items-center justify-center gap-3 pl-4">
|
<div class="flex items-center justify-center gap-4 pl-4">
|
||||||
<Show when={wsl && opencodeAction()}>
|
|
||||||
{(label) => (
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="small"
|
|
||||||
disabled={!!wslState()?.job}
|
|
||||||
class="shrink-0"
|
|
||||||
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
|
|
||||||
onClick={(e: MouseEvent) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
if (wslDistro) updateWslMutation.mutate(wslDistro)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Show when={updating()}>
|
|
||||||
<Spinner class="size-3.5 shrink-0" />
|
|
||||||
</Show>
|
|
||||||
{label()}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
<Show when={ServerConnection.key(current()) === key}>
|
<Show when={ServerConnection.key(current()) === key}>
|
||||||
<Icon name="check" class="h-6" />
|
<Icon name="check" class="h-6" />
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={i.type === "http" || i.type === "sidecar"}>
|
<Show when={i.type === "http"}>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenu.Trigger
|
<DropdownMenu.Trigger
|
||||||
as={IconButton}
|
as={IconButton}
|
||||||
@@ -761,54 +579,35 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
|
|||||||
/>
|
/>
|
||||||
<DropdownMenu.Portal>
|
<DropdownMenu.Portal>
|
||||||
<DropdownMenu.Content class="mt-1">
|
<DropdownMenu.Content class="mt-1">
|
||||||
<Show when={i.type === "http"}>
|
<DropdownMenu.Item
|
||||||
<DropdownMenu.Item
|
onSelect={() => {
|
||||||
onSelect={() => {
|
if (i.type !== "http") return
|
||||||
if (i.type !== "http") return
|
startEdit(i)
|
||||||
startEdit(i)
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
||||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
</DropdownMenu.Item>
|
||||||
</DropdownMenu.Item>
|
<Show when={canDefault() && defaultKey() !== key}>
|
||||||
</Show>
|
<DropdownMenu.Item onSelect={() => setDefault(key)}>
|
||||||
<Show when={wsl && canRetryWsl(i)}>
|
|
||||||
<DropdownMenu.Item onSelect={() => retryWslMutation.mutate(key)}>
|
|
||||||
<DropdownMenu.ItemLabel>Retry start</DropdownMenu.ItemLabel>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
</Show>
|
|
||||||
<Show when={canChangeDefault() && defaultServer.defaultKey() !== key}>
|
|
||||||
<DropdownMenu.Item onSelect={() => void defaultServer.setDefault(key)}>
|
|
||||||
<DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>
|
||||||
{language.t("dialog.server.menu.default")}
|
{language.t("dialog.server.menu.default")}
|
||||||
</DropdownMenu.ItemLabel>
|
</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={canChangeDefault() && defaultServer.defaultKey() === key}>
|
<Show when={canDefault() && defaultKey() === key}>
|
||||||
<DropdownMenu.Item onSelect={() => void defaultServer.setDefault(null)}>
|
<DropdownMenu.Item onSelect={() => setDefault(null)}>
|
||||||
<DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>
|
||||||
{language.t("dialog.server.menu.defaultRemove")}
|
{language.t("dialog.server.menu.defaultRemove")}
|
||||||
</DropdownMenu.ItemLabel>
|
</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={canRemove() && (i.type === "http" || canChangeDefault() || canRetryWsl(i))}>
|
<DropdownMenu.Separator />
|
||||||
<DropdownMenu.Separator />
|
<DropdownMenu.Item
|
||||||
</Show>
|
onSelect={() => handleRemove(ServerConnection.key(i))}
|
||||||
<Show when={canRemove()}>
|
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
||||||
<DropdownMenu.Item
|
>
|
||||||
onSelect={() => {
|
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
|
||||||
if (wsl) {
|
</DropdownMenu.Item>
|
||||||
removeWslMutation.mutate(key)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
void handleRemove(key)
|
|
||||||
}}
|
|
||||||
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemLabel>
|
|
||||||
{language.t("dialog.server.menu.delete")}
|
|
||||||
</DropdownMenu.ItemLabel>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
</Show>
|
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Portal>
|
</DropdownMenu.Portal>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@@ -822,32 +621,17 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
|
|||||||
|
|
||||||
<div class="shrink-0 px-5 pb-5">
|
<div class="shrink-0 px-5 pb-5">
|
||||||
<Show
|
<Show
|
||||||
when={!isAddWslMode() && isFormMode()}
|
when={isFormMode()}
|
||||||
fallback={
|
fallback={
|
||||||
<Show when={!isAddWslMode()}>
|
<Button
|
||||||
<div class="flex items-center gap-2">
|
variant="secondary"
|
||||||
<Button
|
icon="plus-small"
|
||||||
variant="secondary"
|
size="large"
|
||||||
icon="plus-small"
|
onClick={startAdd}
|
||||||
size="large"
|
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
|
||||||
onClick={startAdd}
|
>
|
||||||
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
|
{language.t("dialog.server.add.button")}
|
||||||
>
|
</Button>
|
||||||
{language.t("dialog.server.add.button")}
|
|
||||||
</Button>
|
|
||||||
<Show when={canAddWsl()}>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
icon="plus-small"
|
|
||||||
size="large"
|
|
||||||
onClick={startAddWsl}
|
|
||||||
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
|
|
||||||
>
|
|
||||||
Add WSL
|
|
||||||
</Button>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Button variant="primary" size="large" onClick={submitForm} disabled={formBusy()} class="px-3 py-1.5">
|
<Button variant="primary" size="large" onClick={submitForm} disabled={formBusy()} class="px-3 py-1.5">
|
||||||
|
|||||||
@@ -1,582 +0,0 @@
|
|||||||
import { Button } from "@opencode-ai/ui/button"
|
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
||||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
|
||||||
import { createEffect, createMemo, For, Match, onCleanup, Show, Switch } from "solid-js"
|
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { usePlatform } from "@/context/platform"
|
|
||||||
import { useWslServers } from "@/context/wsl-servers"
|
|
||||||
|
|
||||||
type WslServerStep = "wsl" | "distro" | "opencode"
|
|
||||||
|
|
||||||
const STEPS: WslServerStep[] = ["wsl", "distro", "opencode"]
|
|
||||||
|
|
||||||
function isHiddenDistro(name: string) {
|
|
||||||
return /^docker-desktop(?:-data)?$/i.test(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DialogWslServerProps {
|
|
||||||
onAdded?: (distro: string) => void | Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DialogWslServer(props: DialogWslServerProps = {}) {
|
|
||||||
const language = useLanguage()
|
|
||||||
const platform = usePlatform()
|
|
||||||
const dialog = useDialog()
|
|
||||||
const wslServers = useWslServers()
|
|
||||||
const api = platform.wslServers!
|
|
||||||
const [store, setStore] = createStore({
|
|
||||||
step: undefined as WslServerStep | undefined,
|
|
||||||
selectedDistro: null as string | null,
|
|
||||||
installTarget: undefined as string | undefined,
|
|
||||||
adding: false,
|
|
||||||
})
|
|
||||||
const current = () => wslServers.data
|
|
||||||
let disposed = false
|
|
||||||
onCleanup(() => {
|
|
||||||
disposed = true
|
|
||||||
})
|
|
||||||
const busy = createMemo(() => !!current()?.job || store.adding)
|
|
||||||
const selectedProbe = createMemo(() => {
|
|
||||||
const distro = store.selectedDistro
|
|
||||||
if (!distro) return null
|
|
||||||
return current()?.distroProbes[distro] ?? null
|
|
||||||
})
|
|
||||||
const selectedInstalled = createMemo(() => {
|
|
||||||
const distro = store.selectedDistro
|
|
||||||
if (!distro) return null
|
|
||||||
return (current()?.installed ?? []).find((item) => item.name === distro) ?? null
|
|
||||||
})
|
|
||||||
const visibleInstalledDistros = createMemo(() =>
|
|
||||||
(current()?.installed ?? []).filter((item) => !isHiddenDistro(item.name)),
|
|
||||||
)
|
|
||||||
const visibleOnlineDistros = createMemo(() => (current()?.online ?? []).filter((item) => !isHiddenDistro(item.name)))
|
|
||||||
const defaultInstalledDistro = createMemo(() => visibleInstalledDistros().find((item) => item.isDefault) ?? null)
|
|
||||||
const opencodeCheck = createMemo(() => {
|
|
||||||
const distro = store.selectedDistro
|
|
||||||
if (!distro) return null
|
|
||||||
return current()?.opencodeChecks[distro] ?? null
|
|
||||||
})
|
|
||||||
const distroWarningProbe = createMemo(() => {
|
|
||||||
const probe = selectedProbe()
|
|
||||||
if (!probe) return null
|
|
||||||
if (distroReady()) return null
|
|
||||||
return probe
|
|
||||||
})
|
|
||||||
const distroUnavailableMessage = createMemo(() => {
|
|
||||||
const probe = distroWarningProbe()
|
|
||||||
const distro = store.selectedDistro
|
|
||||||
if (!probe || probe.canExecute || !distro) return null
|
|
||||||
if (!selectedInstalled()) return `${distro} is not installed yet.`
|
|
||||||
return `Open ${distro} once to finish setup.`
|
|
||||||
})
|
|
||||||
const distroMissingTools = createMemo(() => {
|
|
||||||
const probe = distroWarningProbe()
|
|
||||||
if (!probe?.canExecute) return null
|
|
||||||
if (probe.hasBash && probe.hasCurl) return null
|
|
||||||
return probe
|
|
||||||
})
|
|
||||||
const existingServerDistros = createMemo(() => new Set((current()?.servers ?? []).map((item) => item.config.distro)))
|
|
||||||
const addableInstalledDistros = createMemo(() => {
|
|
||||||
return visibleInstalledDistros().filter((item) => !existingServerDistros().has(item.name))
|
|
||||||
})
|
|
||||||
const installableDistros = createMemo(() => {
|
|
||||||
const online = visibleOnlineDistros()
|
|
||||||
const installed = new Set(visibleInstalledDistros().map((item) => item.name))
|
|
||||||
const hasVersionedUbuntu = online.some((item) => /^Ubuntu-\d/.test(item.name))
|
|
||||||
return online
|
|
||||||
.filter((item) => !installed.has(item.name))
|
|
||||||
.filter((item) => !(item.name === "Ubuntu" && hasVersionedUbuntu))
|
|
||||||
})
|
|
||||||
const installTarget = createMemo(() => installableDistros().find((item) => item.name === store.installTarget) ?? null)
|
|
||||||
const installingDistro = createMemo(() => current()?.job?.kind === "install-distro")
|
|
||||||
const installingOpencode = createMemo(() => {
|
|
||||||
const job = current()?.job
|
|
||||||
return job?.kind === "install-opencode" && job.distro === store.selectedDistro
|
|
||||||
})
|
|
||||||
const wslReady = createMemo(() => !!current()?.runtime?.available && !current()?.pendingRestart)
|
|
||||||
const distroReady = createMemo(() => {
|
|
||||||
const probe = selectedProbe()
|
|
||||||
if (!probe || !store.selectedDistro) return false
|
|
||||||
if (selectedInstalled()?.version === 1) return false
|
|
||||||
return probe.canExecute && probe.hasBash && probe.hasCurl
|
|
||||||
})
|
|
||||||
const opencodeReady = createMemo(() => {
|
|
||||||
const check = opencodeCheck()
|
|
||||||
return !!check?.resolvedPath && !check.error
|
|
||||||
})
|
|
||||||
const allReady = createMemo(() => wslReady() && distroReady() && opencodeReady())
|
|
||||||
const addDisabled = createMemo(() => {
|
|
||||||
const job = current()?.job
|
|
||||||
if (!job) return store.adding
|
|
||||||
return store.adding || job.kind !== "probe-opencode"
|
|
||||||
})
|
|
||||||
const recommendedStep = createMemo<WslServerStep>(() => {
|
|
||||||
if (!wslReady()) return "wsl"
|
|
||||||
if (!distroReady()) return "distro"
|
|
||||||
return "opencode"
|
|
||||||
})
|
|
||||||
// activeStep falls back to recommendedStep when the user hasn't picked one.
|
|
||||||
// Once the user clicks a step tab we respect their choice rather than snapping
|
|
||||||
// them back when a probe result updates recommendedStep.
|
|
||||||
const activeStep = createMemo(() => store.step ?? recommendedStep())
|
|
||||||
|
|
||||||
const autoProbe = createMemo(() => {
|
|
||||||
const state = current()
|
|
||||||
if (!state || busy()) return null
|
|
||||||
if (state.pendingRestart) return null
|
|
||||||
if (!state.runtime) return { key: "runtime", run: () => api.probeRuntime() }
|
|
||||||
if (!wslReady()) return null
|
|
||||||
if (!state.installed.length && !state.online.length) {
|
|
||||||
return { key: "distros", run: () => api.refreshDistros() }
|
|
||||||
}
|
|
||||||
const distro = store.selectedDistro
|
|
||||||
if (distro && !state.distroProbes[distro]) {
|
|
||||||
return { key: `probe-distro:${distro}`, run: () => api.probeDistro(distro) }
|
|
||||||
}
|
|
||||||
if (!distro || !distroReady()) return null
|
|
||||||
if (!state.opencodeChecks[distro]) {
|
|
||||||
return { key: `probe-opencode:${distro}`, run: () => api.probeOpencode(distro) }
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
})
|
|
||||||
|
|
||||||
let lastAutoProbe: string | null = null
|
|
||||||
createEffect(() => {
|
|
||||||
const probe = autoProbe()
|
|
||||||
if (!probe || probe.key === lastAutoProbe) return
|
|
||||||
const key = probe.key
|
|
||||||
lastAutoProbe = key
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
await probe.run()
|
|
||||||
} catch (err) {
|
|
||||||
if (disposed) return
|
|
||||||
// Allow the same probe to run again when reactive inputs next change
|
|
||||||
// (e.g. user reselects a distro). Without this the user would be stuck
|
|
||||||
// on a transient wsl.exe failure until they pick a different distro.
|
|
||||||
if (lastAutoProbe === key) lastAutoProbe = null
|
|
||||||
requestError(language, err)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const state = current()
|
|
||||||
const distro = defaultInstalledDistro()
|
|
||||||
if (!state || !distro || busy()) return
|
|
||||||
if (store.selectedDistro) return
|
|
||||||
if (existingServerDistros().has(distro.name)) return
|
|
||||||
setStore("selectedDistro", distro.name)
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const distros = installableDistros()
|
|
||||||
if (!distros.length) {
|
|
||||||
if (store.installTarget) setStore("installTarget", undefined)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (store.installTarget && distros.some((item) => item.name === store.installTarget)) return
|
|
||||||
setStore("installTarget", distros[0]!.name)
|
|
||||||
})
|
|
||||||
|
|
||||||
const wslMessage = createMemo(() => {
|
|
||||||
const state = current()
|
|
||||||
if (!state || state.job?.kind === "runtime") return "Checking WSL..."
|
|
||||||
if (state.pendingRestart) return "Windows needs a restart to finish installing WSL."
|
|
||||||
if (state.runtime?.available) return state.runtime.version ?? "WSL is ready."
|
|
||||||
return state.runtime?.error ?? "WSL is required to continue."
|
|
||||||
})
|
|
||||||
|
|
||||||
const distroMessage = createMemo(() => {
|
|
||||||
const state = current()
|
|
||||||
if (!state) return "Checking distros..."
|
|
||||||
const distro = store.selectedDistro
|
|
||||||
if (state.job?.kind === "install-distro") return `Installing ${state.job.distro}...`
|
|
||||||
if (state.job?.kind === "probe-distro") return `Checking ${state.job.distro}...`
|
|
||||||
if (state.job?.kind === "distros") return "Listing distros..."
|
|
||||||
if (distroUnavailableMessage()) return distroUnavailableMessage()!
|
|
||||||
if (selectedProbe() && distroReady()) return `${selectedProbe()!.name} is ready.`
|
|
||||||
if (distro) return `Finishing setup for ${distro}.`
|
|
||||||
return "Pick a distro or install one below."
|
|
||||||
})
|
|
||||||
|
|
||||||
const opencodeMessage = createMemo(() => {
|
|
||||||
const state = current()
|
|
||||||
if (!state) return "Checking OpenCode..."
|
|
||||||
const distro = store.selectedDistro
|
|
||||||
if (state.job?.kind === "probe-opencode" || state.job?.kind === "install-opencode") {
|
|
||||||
return distro ? `Checking OpenCode in ${distro}...` : "Checking OpenCode..."
|
|
||||||
}
|
|
||||||
if (opencodeCheck()?.error) return opencodeCheck()!.error
|
|
||||||
if (opencodeCheck()?.matchesDesktop === false) {
|
|
||||||
return distro ? `Update OpenCode in ${distro}.` : "Update OpenCode."
|
|
||||||
}
|
|
||||||
if (opencodeReady()) return distro ? `OpenCode is ready in ${distro}.` : "OpenCode is ready."
|
|
||||||
return distro ? `Install OpenCode in ${distro}.` : "Choose a distro first."
|
|
||||||
})
|
|
||||||
|
|
||||||
const run = async (action: () => Promise<unknown>) => {
|
|
||||||
try {
|
|
||||||
await action()
|
|
||||||
} catch (err) {
|
|
||||||
requestError(language, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const runSelectedDistro = (action: (distro: string) => Promise<unknown>) => {
|
|
||||||
const distro = store.selectedDistro
|
|
||||||
if (!distro) return
|
|
||||||
void run(() => action(distro))
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectDistro = (name: string) => {
|
|
||||||
setStore("selectedDistro", name)
|
|
||||||
setStore("step", undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
const finish = async () => {
|
|
||||||
const distro = store.selectedDistro
|
|
||||||
if (!distro) return
|
|
||||||
setStore("adding", true)
|
|
||||||
try {
|
|
||||||
await api.addServer(distro)
|
|
||||||
if (props.onAdded) {
|
|
||||||
await props.onAdded(distro)
|
|
||||||
} else {
|
|
||||||
dialog.close()
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
requestError(language, err)
|
|
||||||
} finally {
|
|
||||||
setStore("adding", false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const steps = createMemo(() => {
|
|
||||||
const active = activeStep()
|
|
||||||
const activeIndex = STEPS.indexOf(active)
|
|
||||||
const recommendedIndex = STEPS.indexOf(recommendedStep())
|
|
||||||
return STEPS.map((step) => {
|
|
||||||
const index = STEPS.indexOf(step)
|
|
||||||
return {
|
|
||||||
step,
|
|
||||||
title: step === "wsl" ? "WSL" : step === "distro" ? "Choose distro" : "OpenCode",
|
|
||||||
state:
|
|
||||||
active === step
|
|
||||||
? "current"
|
|
||||||
: step === "wsl"
|
|
||||||
? wslReady()
|
|
||||||
? "done"
|
|
||||||
: "warning"
|
|
||||||
: step === "distro"
|
|
||||||
? distroReady()
|
|
||||||
? "done"
|
|
||||||
: index > activeIndex
|
|
||||||
? "locked"
|
|
||||||
: "warning"
|
|
||||||
: opencodeCheck()?.matchesDesktop === false
|
|
||||||
? "warning"
|
|
||||||
: opencodeReady()
|
|
||||||
? "done"
|
|
||||||
: index > activeIndex
|
|
||||||
? "locked"
|
|
||||||
: "warning",
|
|
||||||
locked: index > recommendedIndex,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
const loadError = createMemo(() => {
|
|
||||||
const error = wslServers.error
|
|
||||||
if (!error) return "Failed to load WSL state."
|
|
||||||
return error instanceof Error ? error.message : String(error)
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="px-5 pb-5 flex flex-col gap-4">
|
|
||||||
<Show when={!wslServers.isPending} fallback={<div class="px-1 py-6 text-14-regular text-text-weak">Loading...</div>}>
|
|
||||||
<Show when={!wslServers.isError} fallback={<div class="px-1 py-6 text-14-regular text-text-weak">{loadError()}</div>}>
|
|
||||||
<div class="flex gap-2 pb-1">
|
|
||||||
<For each={steps()}>
|
|
||||||
{(item) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="basis-0 flex-1 min-w-0 rounded-md border px-3 py-2 text-left transition-colors"
|
|
||||||
classList={{
|
|
||||||
"border-border-strong-base bg-surface-base-hover": item.state === "current",
|
|
||||||
"border-icon-success-base/40 bg-surface-base": item.state === "done",
|
|
||||||
"border-border-weak-base bg-background-base opacity-60": item.state === "locked",
|
|
||||||
"border-icon-warning-base/40 bg-surface-base": item.state === "warning",
|
|
||||||
}}
|
|
||||||
disabled={item.locked}
|
|
||||||
onClick={() => setStore("step", item.step)}
|
|
||||||
>
|
|
||||||
<div class="text-13-medium text-text-strong">{item.title}</div>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Switch>
|
|
||||||
<Match when={activeStep() === "wsl"}>
|
|
||||||
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
|
|
||||||
<div class="flex items-center justify-between gap-3">
|
|
||||||
<div class="text-14-medium text-text-strong">WSL</div>
|
|
||||||
<Show when={current()?.runtime && !wslReady() && !current()?.pendingRestart}>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="large"
|
|
||||||
disabled={busy()}
|
|
||||||
onClick={() => void run(() => api.installWsl())}
|
|
||||||
>
|
|
||||||
Install WSL
|
|
||||||
</Button>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{wslMessage()}</div>
|
|
||||||
<Show when={current()?.pendingRestart}>
|
|
||||||
<div class="rounded-md border border-border-weak-base px-3 py-3 flex items-center justify-between gap-3">
|
|
||||||
<div class="text-12-regular text-text-warning-base">Windows restart required.</div>
|
|
||||||
<Button variant="secondary" size="large" onClick={() => void platform.restart()}>
|
|
||||||
Relaunch OpenCode
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<div class="flex items-center justify-end">
|
|
||||||
<Button variant="secondary" size="large" disabled={busy() || !wslReady()} onClick={() => setStore("step", "distro")}>
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
|
|
||||||
<Match when={activeStep() === "distro"}>
|
|
||||||
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
|
|
||||||
<div class="flex items-center justify-between gap-3">
|
|
||||||
<div class="text-14-medium text-text-strong">Choose a distro</div>
|
|
||||||
<Show when={store.selectedDistro}>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="small"
|
|
||||||
disabled={busy()}
|
|
||||||
onClick={() => runSelectedDistro((distro) => api.probeDistro(distro))}
|
|
||||||
>
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{distroMessage()}</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<Show
|
|
||||||
when={addableInstalledDistros().length > 0}
|
|
||||||
fallback={
|
|
||||||
<div class="text-12-regular text-text-weak">
|
|
||||||
{visibleInstalledDistros().length
|
|
||||||
? "All installed distros are already added."
|
|
||||||
: current()?.runtime?.available
|
|
||||||
? "No distros detected yet."
|
|
||||||
: "Checking distros..."}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<For each={addableInstalledDistros()}>
|
|
||||||
{(item) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-md border border-border-weak-base px-3 py-2 text-left transition-colors"
|
|
||||||
classList={{ "bg-surface-raised-base": store.selectedDistro === item.name }}
|
|
||||||
onClick={() => selectDistro(item.name)}
|
|
||||||
>
|
|
||||||
<div class="text-13-medium text-text-strong">{item.name}</div>
|
|
||||||
<Show when={item.isDefault}>
|
|
||||||
<div class="text-12-regular text-text-weak">Default</div>
|
|
||||||
</Show>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when={installableDistros().length > 0}>
|
|
||||||
<div class="rounded-md border border-border-weak-base p-2 flex flex-col gap-2">
|
|
||||||
<div class="px-1 flex items-center justify-between gap-3">
|
|
||||||
<div class="text-12-medium text-text-weak">Install</div>
|
|
||||||
<div class="flex items-center gap-2 shrink-0">
|
|
||||||
<Show when={installingDistro()}>
|
|
||||||
<Spinner class="h-4 w-4 text-icon-info-base shrink-0" />
|
|
||||||
</Show>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="small"
|
|
||||||
disabled={busy() || !installTarget()}
|
|
||||||
onClick={() => void run(() => api.installDistro(installTarget()!.name))}
|
|
||||||
>
|
|
||||||
{installingDistro() ? "Installing..." : "Install"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
role="radiogroup"
|
|
||||||
aria-label="Install distro"
|
|
||||||
class="max-h-52 overflow-y-auto rounded-md bg-background-base"
|
|
||||||
>
|
|
||||||
<For each={installableDistros()}>
|
|
||||||
{(item) => {
|
|
||||||
const selected = () => store.installTarget === item.name
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="radio"
|
|
||||||
aria-checked={selected()}
|
|
||||||
disabled={busy()}
|
|
||||||
class="w-full px-3 py-2 flex items-center gap-3 text-left border-b border-border-weak-base last:border-b-0 transition-colors"
|
|
||||||
classList={{
|
|
||||||
"bg-surface-raised-base": selected(),
|
|
||||||
"hover:bg-surface-base": !selected(),
|
|
||||||
}}
|
|
||||||
onClick={() => setStore("installTarget", item.name)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="mt-0.5 h-4 w-4 rounded-full border border-border-strong-base flex items-center justify-center shrink-0"
|
|
||||||
classList={{ "border-text-strong": selected() }}
|
|
||||||
>
|
|
||||||
<div class="h-2 w-2 rounded-full bg-text-strong" classList={{ hidden: !selected() }} />
|
|
||||||
</div>
|
|
||||||
<div class="min-w-0 flex-1 text-13-medium text-text-strong truncate">{item.label}</div>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show
|
|
||||||
when={
|
|
||||||
selectedInstalled()?.version === 1 ||
|
|
||||||
distroUnavailableMessage() ||
|
|
||||||
distroMissingTools()
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div class="rounded-md border border-border-weak-base px-3 py-3 flex flex-col gap-1">
|
|
||||||
<Show when={selectedInstalled()?.version === 1}>
|
|
||||||
<div class="text-12-regular text-text-warning-base">WSL 2 is required.</div>
|
|
||||||
</Show>
|
|
||||||
<Show when={distroUnavailableMessage()}>
|
|
||||||
{(message) => <div class="text-12-regular text-text-warning-base">{message()}</div>}
|
|
||||||
</Show>
|
|
||||||
<Show when={distroMissingTools()}>
|
|
||||||
<div class="text-12-regular text-text-warning-base">This distro needs bash and curl.</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="large"
|
|
||||||
disabled={busy() || !selectedInstalled()}
|
|
||||||
onClick={() => runSelectedDistro((distro) => api.openTerminal(distro))}
|
|
||||||
>
|
|
||||||
Open terminal
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="large"
|
|
||||||
disabled={busy() || !store.selectedDistro}
|
|
||||||
onClick={() => runSelectedDistro((distro) => api.probeDistro(distro))}
|
|
||||||
>
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-end">
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="large"
|
|
||||||
disabled={busy() || !store.selectedDistro || !distroReady()}
|
|
||||||
onClick={() => setStore("step", "opencode")}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
|
|
||||||
<Match when={activeStep() === "opencode"}>
|
|
||||||
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
|
|
||||||
<div class="flex items-center justify-between gap-3">
|
|
||||||
<div class="text-14-medium text-text-strong">OpenCode</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Show when={store.selectedDistro}>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="large"
|
|
||||||
disabled={busy()}
|
|
||||||
onClick={() => runSelectedDistro((distro) => api.probeOpencode(distro))}
|
|
||||||
>
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
</Show>
|
|
||||||
<Show when={!opencodeReady() || opencodeCheck()?.matchesDesktop === false}>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="large"
|
|
||||||
disabled={busy()}
|
|
||||||
onClick={() => runSelectedDistro((distro) => api.installOpencode(distro))}
|
|
||||||
>
|
|
||||||
<Show when={installingOpencode()}>
|
|
||||||
<Spinner class="size-4 shrink-0" />
|
|
||||||
</Show>
|
|
||||||
{opencodeCheck()?.resolvedPath ? "Update OpenCode" : "Install OpenCode"}
|
|
||||||
</Button>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{opencodeMessage()}</div>
|
|
||||||
<Show when={opencodeCheck()?.matchesDesktop === false ? opencodeCheck() : null}>
|
|
||||||
{(check) => (
|
|
||||||
<div class="rounded-md border border-border-weak-base px-3 py-3 flex flex-col gap-1">
|
|
||||||
<div class="text-12-regular text-text-weak">Path: {check().resolvedPath ?? "not found"}</div>
|
|
||||||
<div class="text-12-regular text-text-weak">
|
|
||||||
Version: {check().version ?? "unknown"}
|
|
||||||
<Show when={check().expectedVersion}>
|
|
||||||
{(expected) => <span>{` · desktop ${expected()}`}</span>}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<div class="text-12-regular text-text-warning-base">
|
|
||||||
Installed version does not match the desktop app version.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
|
|
||||||
<Show when={activeStep() === "opencode" && allReady() && store.selectedDistro}>
|
|
||||||
<div class="flex items-center justify-end gap-2">
|
|
||||||
<Button variant="ghost" size="large" disabled={store.adding} onClick={() => dialog.close()}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button variant="primary" size="large" disabled={addDisabled()} onClick={() => void finish()}>
|
|
||||||
{store.adding ? "Adding..." : "Add WSL server"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</Show>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function requestError(language: ReturnType<typeof useLanguage>, err: unknown) {
|
|
||||||
console.error("WSL servers request failed", err instanceof Error ? (err.stack ?? err.message) : String(err))
|
|
||||||
showToast({
|
|
||||||
variant: "error",
|
|
||||||
title: language.t("common.requestFailed"),
|
|
||||||
description: err instanceof Error ? err.message : String(err),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -17,7 +17,6 @@ import type { ServerHealth } from "@/utils/server-health"
|
|||||||
interface ServerRowProps extends ParentProps {
|
interface ServerRowProps extends ParentProps {
|
||||||
conn: ServerConnection.Any
|
conn: ServerConnection.Any
|
||||||
status?: ServerHealth
|
status?: ServerHealth
|
||||||
version?: string
|
|
||||||
class?: string
|
class?: string
|
||||||
nameClass?: string
|
nameClass?: string
|
||||||
versionClass?: string
|
versionClass?: string
|
||||||
@@ -32,8 +31,6 @@ export function ServerRow(props: ServerRowProps) {
|
|||||||
let nameRef: HTMLSpanElement | undefined
|
let nameRef: HTMLSpanElement | undefined
|
||||||
let versionRef: HTMLSpanElement | undefined
|
let versionRef: HTMLSpanElement | undefined
|
||||||
const name = createMemo(() => serverName(props.conn))
|
const name = createMemo(() => serverName(props.conn))
|
||||||
const isWsl = createMemo(() => props.conn.type === "sidecar" && props.conn.variant === "wsl")
|
|
||||||
const version = createMemo(() => props.version ?? props.status?.version)
|
|
||||||
|
|
||||||
const check = () => {
|
const check = () => {
|
||||||
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
|
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
|
||||||
@@ -44,7 +41,7 @@ export function ServerRow(props: ServerRowProps) {
|
|||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
name()
|
name()
|
||||||
props.conn.http.url
|
props.conn.http.url
|
||||||
version()
|
props.status?.version
|
||||||
queueMicrotask(check)
|
queueMicrotask(check)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -57,11 +54,8 @@ export function ServerRow(props: ServerRowProps) {
|
|||||||
const tooltipValue = () => (
|
const tooltipValue = () => (
|
||||||
<span class="flex items-center gap-2">
|
<span class="flex items-center gap-2">
|
||||||
<span>{serverName(props.conn, true)}</span>
|
<span>{serverName(props.conn, true)}</span>
|
||||||
<Show when={isWsl()}>
|
<Show when={props.status?.version}>
|
||||||
<span class="text-text-invert-weak">WSL</span>
|
<span class="text-text-invert-weak">v{props.status?.version}</span>
|
||||||
</Show>
|
|
||||||
<Show when={version()}>
|
|
||||||
<span class="text-text-invert-weak">v{version()}</span>
|
|
||||||
</Show>
|
</Show>
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
@@ -82,20 +76,15 @@ export function ServerRow(props: ServerRowProps) {
|
|||||||
<span ref={nameRef} class={`${props.nameClass ?? "truncate"} min-w-0`}>
|
<span ref={nameRef} class={`${props.nameClass ?? "truncate"} min-w-0`}>
|
||||||
{name()}
|
{name()}
|
||||||
</span>
|
</span>
|
||||||
<Show when={isWsl()}>
|
|
||||||
<span class="text-11-regular text-text-weak border border-border-weak-base bg-surface-base px-1.5 py-0.5 rounded-md shrink-0">
|
|
||||||
WSL
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
<Show
|
<Show
|
||||||
when={badge()}
|
when={badge()}
|
||||||
fallback={
|
fallback={
|
||||||
<Show when={version()}>
|
<Show when={props.status?.version}>
|
||||||
<span
|
<span
|
||||||
ref={versionRef}
|
ref={versionRef}
|
||||||
class={`${props.versionClass ?? "text-text-weak text-14-regular truncate"} min-w-0`}
|
class={`${props.versionClass ?? "text-text-weak text-14-regular truncate"} min-w-0`}
|
||||||
>
|
>
|
||||||
v{version()}
|
v{props.status?.version}
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Switch } from "@opencode-ai/ui/switch"
|
|||||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||||
import { useMutation, useQueryClient } from "@tanstack/solid-query"
|
import { useMutation, useQueryClient } from "@tanstack/solid-query"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { useLocation, useNavigate } from "@solidjs/router"
|
import { useNavigate } from "@solidjs/router"
|
||||||
import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js"
|
import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
||||||
@@ -286,7 +286,7 @@ function ServerStatusList(props: { state: ServerStatusState }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatusPopoverBody(props: { shown: Accessor<boolean>; close?: () => void }) {
|
export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const servers = useServers()
|
const servers = useServers()
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
@@ -294,7 +294,6 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean>; close?: ()
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
|
||||||
|
|
||||||
const fail = (err: unknown) => {
|
const fail = (err: unknown) => {
|
||||||
showToast({
|
showToast({
|
||||||
@@ -375,16 +374,8 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean>; close?: ()
|
|||||||
aria-disabled={blocked()}
|
aria-disabled={blocked()}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (blocked()) return
|
if (blocked()) return
|
||||||
props.close?.()
|
|
||||||
navigate("/")
|
navigate("/")
|
||||||
const activate = () => {
|
queueMicrotask(() => server.setActive(key))
|
||||||
if (location.pathname !== "/") {
|
|
||||||
setTimeout(activate, 16)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setTimeout(() => server.setActive(key), 0)
|
|
||||||
}
|
|
||||||
setTimeout(activate, 0)
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ServerHealthIndicator health={servers.health[key]} />
|
<ServerHealthIndicator health={servers.health[key]} />
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export function StatusPopover() {
|
|||||||
<div class="w-[360px] h-14 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]" />
|
<div class="w-[360px] h-14 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]" />
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Body shown={shown} close={() => setShown(false)} />
|
<Body shown={shown} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Show>
|
</Show>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ export const createDirSyncContext = (directory: string, serverSync: ReturnType<t
|
|||||||
type Child = ReturnType<(typeof serverSync)["child"]>
|
type Child = ReturnType<(typeof serverSync)["child"]>
|
||||||
type Setter = Child[1]
|
type Setter = Child[1]
|
||||||
|
|
||||||
const current = createMemo(() => serverSync.child(directory, { mcp: true }))
|
const current = createMemo(() => serverSync.child(directory))
|
||||||
const target = (directory?: string) => {
|
const target = (directory?: string) => {
|
||||||
if (!directory || directory === directory) return current()
|
if (!directory || directory === directory) return current()
|
||||||
return serverSync.child(directory)
|
return serverSync.child(directory)
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ const provider = { all: new Map(), connected: [], default: {} } satisfies Normal
|
|||||||
|
|
||||||
describe("bootstrapDirectory", () => {
|
describe("bootstrapDirectory", () => {
|
||||||
test("marks a loading directory partial during bootstrap and complete after success", async () => {
|
test("marks a loading directory partial during bootstrap and complete after success", async () => {
|
||||||
const mcpReads: string[] = []
|
|
||||||
const [store, setStore] = createStore<State>({
|
const [store, setStore] = createStore<State>({
|
||||||
status: "loading",
|
status: "loading",
|
||||||
agent: [],
|
agent: [],
|
||||||
@@ -45,7 +44,6 @@ describe("bootstrapDirectory", () => {
|
|||||||
|
|
||||||
await bootstrapDirectory({
|
await bootstrapDirectory({
|
||||||
directory: "/project",
|
directory: "/project",
|
||||||
mcp: false,
|
|
||||||
global: {
|
global: {
|
||||||
config: {} satisfies Config,
|
config: {} satisfies Config,
|
||||||
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
|
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
|
||||||
@@ -57,20 +55,10 @@ describe("bootstrapDirectory", () => {
|
|||||||
config: { get: async () => ({ data: {} }) },
|
config: { get: async () => ({ data: {} }) },
|
||||||
session: { status: async () => ({ data: {} }) },
|
session: { status: async () => ({ data: {} }) },
|
||||||
vcs: { get: async () => ({ data: undefined }) },
|
vcs: { get: async () => ({ data: undefined }) },
|
||||||
command: {
|
command: { list: async () => ({ data: [] }) },
|
||||||
list: async () => {
|
|
||||||
mcpReads.push("command")
|
|
||||||
return { data: [] }
|
|
||||||
},
|
|
||||||
},
|
|
||||||
permission: { list: async () => ({ data: [] }) },
|
permission: { list: async () => ({ data: [] }) },
|
||||||
question: { list: async () => ({ data: [] }) },
|
question: { list: async () => ({ data: [] }) },
|
||||||
mcp: {
|
mcp: { status: async () => ({ data: {} }) },
|
||||||
status: async () => {
|
|
||||||
mcpReads.push("status")
|
|
||||||
return { data: {} }
|
|
||||||
},
|
|
||||||
},
|
|
||||||
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
|
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
|
||||||
} as unknown as OpencodeClient,
|
} as unknown as OpencodeClient,
|
||||||
store,
|
store,
|
||||||
@@ -86,6 +74,5 @@ describe("bootstrapDirectory", () => {
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 80))
|
await new Promise((resolve) => setTimeout(resolve, 80))
|
||||||
|
|
||||||
expect(store.status).toBe("complete")
|
expect(store.status).toBe("complete")
|
||||||
expect(mcpReads).toEqual([])
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -198,7 +198,6 @@ export const loadPathQuery = (directory: string | null, sdk: OpencodeClient) =>
|
|||||||
|
|
||||||
export async function bootstrapDirectory(input: {
|
export async function bootstrapDirectory(input: {
|
||||||
directory: string
|
directory: string
|
||||||
mcp: boolean
|
|
||||||
sdk: OpencodeClient
|
sdk: OpencodeClient
|
||||||
store: Store<State>
|
store: Store<State>
|
||||||
setStore: SetStoreFunction<State>
|
setStore: SetStoreFunction<State>
|
||||||
@@ -251,7 +250,7 @@ export async function bootstrapDirectory(input: {
|
|||||||
if (next) input.vcsCache.setStore("value", next)
|
if (next) input.vcsCache.setStore("value", next)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
input.mcp && (() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])))),
|
() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))),
|
||||||
() =>
|
() =>
|
||||||
retry(() =>
|
retry(() =>
|
||||||
input.sdk.permission.list().then((x) => {
|
input.sdk.permission.list().then((x) => {
|
||||||
@@ -305,7 +304,7 @@ export async function bootstrapDirectory(input: {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||||
input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.directory, input.sdk))),
|
() => input.queryClient.fetchQuery(loadMcpQuery(input.directory, input.sdk)),
|
||||||
() =>
|
() =>
|
||||||
input.queryClient.fetchQuery(loadProvidersQuery(input.directory, input.sdk)).catch((err) => {
|
input.queryClient.fetchQuery(loadProvidersQuery(input.directory, input.sdk)).catch((err) => {
|
||||||
const project = getFilename(input.directory)
|
const project = getFilename(input.directory)
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import type { State } from "./types"
|
|||||||
import type { QueryOptionsApi } from "../server-sync"
|
import type { QueryOptionsApi } from "../server-sync"
|
||||||
|
|
||||||
let createChildStoreManager: typeof import("./child-store").createChildStoreManager
|
let createChildStoreManager: typeof import("./child-store").createChildStoreManager
|
||||||
const queryGroups: Array<() => { queries: Array<{ enabled?: boolean }> }> = []
|
|
||||||
|
|
||||||
const child = () => createStore({} as State)
|
const child = () => createStore({} as State)
|
||||||
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
|
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
|
||||||
@@ -49,15 +48,12 @@ beforeAll(async () => {
|
|||||||
persisted: (_target: string, store: unknown[]) => [store[0], store[1], null, () => true],
|
persisted: (_target: string, store: unknown[]) => [store[0], store[1], null, () => true],
|
||||||
}))
|
}))
|
||||||
mock.module("@tanstack/solid-query", () => ({
|
mock.module("@tanstack/solid-query", () => ({
|
||||||
useQueries: (options: () => { queries: Array<{ enabled?: boolean }> }) => {
|
useQueries: () => [
|
||||||
queryGroups.push(options)
|
{ isLoading: false, data: { state: "", config: "", worktree: "", directory: "", home: "" } },
|
||||||
return [
|
{ isLoading: false, data: {} },
|
||||||
{ isLoading: false, data: { state: "", config: "", worktree: "", directory: "", home: "" } },
|
{ isLoading: false, data: [] },
|
||||||
{ isLoading: false, data: {} },
|
{ isLoading: false, data: provider },
|
||||||
{ isLoading: false, data: [] },
|
],
|
||||||
{ isLoading: false, data: provider },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
createChildStoreManager = (await import("./child-store")).createChildStoreManager
|
createChildStoreManager = (await import("./child-store")).createChildStoreManager
|
||||||
@@ -77,7 +73,6 @@ describe("createChildStoreManager", () => {
|
|||||||
isBooting: () => false,
|
isBooting: () => false,
|
||||||
isLoadingSessions: () => false,
|
isLoadingSessions: () => false,
|
||||||
onBootstrap() {},
|
onBootstrap() {},
|
||||||
onMcp() {},
|
|
||||||
onDispose() {},
|
onDispose() {},
|
||||||
translate: (key) => key,
|
translate: (key) => key,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
@@ -108,7 +103,6 @@ describe("createChildStoreManager", () => {
|
|||||||
onBootstrap(directory) {
|
onBootstrap(directory) {
|
||||||
bootstraps.push(directory)
|
bootstraps.push(directory)
|
||||||
},
|
},
|
||||||
onMcp() {},
|
|
||||||
onDispose() {},
|
onDispose() {},
|
||||||
translate: (key) => key,
|
translate: (key) => key,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
@@ -127,45 +121,4 @@ describe("createChildStoreManager", () => {
|
|||||||
dispose()
|
dispose()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("enables MCP only when requested for the directory", () => {
|
|
||||||
let manager: ReturnType<typeof createChildStoreManager> | undefined
|
|
||||||
const offset = queryGroups.length
|
|
||||||
const mcpLoads: string[] = []
|
|
||||||
|
|
||||||
const dispose = createOwner((owner) => {
|
|
||||||
manager = createChildStoreManager({
|
|
||||||
owner,
|
|
||||||
isBooting: () => false,
|
|
||||||
isLoadingSessions: () => false,
|
|
||||||
onBootstrap() {},
|
|
||||||
onMcp(directory) {
|
|
||||||
mcpLoads.push(directory)
|
|
||||||
},
|
|
||||||
onDispose() {},
|
|
||||||
translate: (key) => key,
|
|
||||||
queryOptions: queryOptionsApi,
|
|
||||||
global: { provider },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!manager) throw new Error("manager required")
|
|
||||||
const [, setStore] = manager.child("/project", { bootstrap: false })
|
|
||||||
const queries = queryGroups[offset]
|
|
||||||
if (!queries) throw new Error("queries required")
|
|
||||||
expect(queries().queries[1]?.enabled).toBe(false)
|
|
||||||
|
|
||||||
setStore("status", "complete")
|
|
||||||
manager.child("/project", { bootstrap: false, mcp: true })
|
|
||||||
expect(queries().queries[1]?.enabled).toBe(true)
|
|
||||||
expect(mcpLoads).toEqual(["/project"])
|
|
||||||
|
|
||||||
manager.disableMcp("/project")
|
|
||||||
expect(queries().queries[1]?.enabled).toBe(false)
|
|
||||||
expect(manager.mcp("/project")).toBe(false)
|
|
||||||
} finally {
|
|
||||||
dispose()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
|
import { createRoot, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
|
||||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import type { VcsInfo } from "@opencode-ai/sdk/v2/client"
|
import type { VcsInfo } from "@opencode-ai/sdk/v2/client"
|
||||||
@@ -24,7 +24,6 @@ export function createChildStoreManager(input: {
|
|||||||
isBooting: (directory: string) => boolean
|
isBooting: (directory: string) => boolean
|
||||||
isLoadingSessions: (directory: string) => boolean
|
isLoadingSessions: (directory: string) => boolean
|
||||||
onBootstrap: (directory: string) => void
|
onBootstrap: (directory: string) => void
|
||||||
onMcp: (directory: string, setStore: SetStoreFunction<State>) => void
|
|
||||||
onDispose: (directory: string) => void
|
onDispose: (directory: string) => void
|
||||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||||
queryOptions: QueryOptionsApi
|
queryOptions: QueryOptionsApi
|
||||||
@@ -40,8 +39,6 @@ export function createChildStoreManager(input: {
|
|||||||
const pins = new Map<string, number>()
|
const pins = new Map<string, number>()
|
||||||
const ownerPins = new WeakMap<object, Set<string>>()
|
const ownerPins = new WeakMap<object, Set<string>>()
|
||||||
const disposers = new Map<string, () => void>()
|
const disposers = new Map<string, () => void>()
|
||||||
const mcpDirectories = new Set<string>()
|
|
||||||
const mcpToggles = new Map<string, (enabled: boolean) => void>()
|
|
||||||
|
|
||||||
const markKey = (key: DirectoryKey) => {
|
const markKey = (key: DirectoryKey) => {
|
||||||
if (!key) return
|
if (!key) return
|
||||||
@@ -95,24 +92,6 @@ export function createChildStoreManager(input: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function disposeChild(key: DirectoryKey) {
|
|
||||||
const dispose = disposers.get(key)
|
|
||||||
if (!key || !children[key]) return false
|
|
||||||
vcsCache.delete(key)
|
|
||||||
metaCache.delete(key)
|
|
||||||
iconCache.delete(key)
|
|
||||||
lifecycle.delete(key)
|
|
||||||
mcpDirectories.delete(key)
|
|
||||||
mcpToggles.delete(key)
|
|
||||||
disposers.delete(key)
|
|
||||||
delete children[key]
|
|
||||||
input.onDispose(key)
|
|
||||||
if (dispose) {
|
|
||||||
dispose()
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
function disposeDirectory(directory: DirectoryKey) {
|
function disposeDirectory(directory: DirectoryKey) {
|
||||||
const key = directory
|
const key = directory
|
||||||
if (
|
if (
|
||||||
@@ -126,13 +105,19 @@ export function createChildStoreManager(input: {
|
|||||||
) {
|
) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return disposeChild(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
function disposeAll() {
|
vcsCache.delete(key)
|
||||||
for (const directory of Object.keys(children)) {
|
metaCache.delete(key)
|
||||||
disposeChild(directoryKey(directory))
|
iconCache.delete(key)
|
||||||
|
lifecycle.delete(key)
|
||||||
|
const dispose = disposers.get(key)
|
||||||
|
if (dispose) {
|
||||||
|
dispose()
|
||||||
|
disposers.delete(key)
|
||||||
}
|
}
|
||||||
|
delete children[key]
|
||||||
|
input.onDispose(key)
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
function runEviction(skip?: string) {
|
function runEviction(skip?: string) {
|
||||||
@@ -188,12 +173,11 @@ export function createChildStoreManager(input: {
|
|||||||
createRoot((dispose) => {
|
createRoot((dispose) => {
|
||||||
const initialMeta = meta[0].value
|
const initialMeta = meta[0].value
|
||||||
const initialIcon = icon[0].value
|
const initialIcon = icon[0].value
|
||||||
const [mcpEnabled, setMcpEnabled] = createSignal(false)
|
|
||||||
|
|
||||||
const [pathQuery, mcpQuery, lspQuery, providerQuery] = useQueries(() => ({
|
const [pathQuery, mcpQuery, lspQuery, providerQuery] = useQueries(() => ({
|
||||||
queries: [
|
queries: [
|
||||||
input.queryOptions.path(key),
|
input.queryOptions.path(key),
|
||||||
{ ...input.queryOptions.mcp(key), enabled: mcpEnabled() },
|
input.queryOptions.mcp(key),
|
||||||
input.queryOptions.lsp(key),
|
input.queryOptions.lsp(key),
|
||||||
input.queryOptions.providers(key),
|
input.queryOptions.providers(key),
|
||||||
],
|
],
|
||||||
@@ -252,7 +236,6 @@ export function createChildStoreManager(input: {
|
|||||||
})
|
})
|
||||||
children[key] = child
|
children[key] = child
|
||||||
disposers.set(key, dispose)
|
disposers.set(key, dispose)
|
||||||
mcpToggles.set(key, setMcpEnabled)
|
|
||||||
|
|
||||||
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
|
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
|
||||||
if (!(init instanceof Promise)) return
|
if (!(init instanceof Promise)) return
|
||||||
@@ -291,7 +274,6 @@ export function createChildStoreManager(input: {
|
|||||||
const key = directoryKey(directory)
|
const key = directoryKey(directory)
|
||||||
const childStore = ensureChild(directory)
|
const childStore = ensureChild(directory)
|
||||||
pinForOwner(key)
|
pinForOwner(key)
|
||||||
if (options.mcp) enableMcp(directory, key, childStore)
|
|
||||||
const shouldBootstrap = options.bootstrap ?? true
|
const shouldBootstrap = options.bootstrap ?? true
|
||||||
if (shouldBootstrap && childStore[0].status === "loading") {
|
if (shouldBootstrap && childStore[0].status === "loading") {
|
||||||
input.onBootstrap(directory)
|
input.onBootstrap(directory)
|
||||||
@@ -302,7 +284,6 @@ export function createChildStoreManager(input: {
|
|||||||
function peek(directory: string, options: ChildOptions = {}) {
|
function peek(directory: string, options: ChildOptions = {}) {
|
||||||
const key = directoryKey(directory)
|
const key = directoryKey(directory)
|
||||||
const childStore = ensureChild(directory)
|
const childStore = ensureChild(directory)
|
||||||
if (options.mcp) enableMcp(directory, key, childStore)
|
|
||||||
const shouldBootstrap = options.bootstrap ?? true
|
const shouldBootstrap = options.bootstrap ?? true
|
||||||
if (shouldBootstrap && childStore[0].status === "loading") {
|
if (shouldBootstrap && childStore[0].status === "loading") {
|
||||||
input.onBootstrap(directory)
|
input.onBootstrap(directory)
|
||||||
@@ -310,19 +291,6 @@ export function createChildStoreManager(input: {
|
|||||||
return childStore
|
return childStore
|
||||||
}
|
}
|
||||||
|
|
||||||
function enableMcp(directory: string, key: DirectoryKey, childStore: [Store<State>, SetStoreFunction<State>]) {
|
|
||||||
if (mcpDirectories.has(key)) return
|
|
||||||
mcpDirectories.add(key)
|
|
||||||
mcpToggles.get(key)?.(true)
|
|
||||||
if (childStore[0].status !== "loading") input.onMcp(directory, childStore[1])
|
|
||||||
}
|
|
||||||
|
|
||||||
function disableMcp(directory: string) {
|
|
||||||
const key = directoryKey(directory)
|
|
||||||
if (!mcpDirectories.delete(key)) return
|
|
||||||
mcpToggles.get(key)?.(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
function projectMeta(directory: string, patch: ProjectMeta) {
|
function projectMeta(directory: string, patch: ProjectMeta) {
|
||||||
const key = directoryKey(directory)
|
const key = directoryKey(directory)
|
||||||
const [store, setStore] = ensureChild(directory)
|
const [store, setStore] = ensureChild(directory)
|
||||||
@@ -362,10 +330,7 @@ export function createChildStoreManager(input: {
|
|||||||
pin,
|
pin,
|
||||||
unpin,
|
unpin,
|
||||||
pinned,
|
pinned,
|
||||||
mcp: (directory: string) => mcpDirectories.has(directoryKey(directory)),
|
|
||||||
disableMcp,
|
|
||||||
disposeDirectory,
|
disposeDirectory,
|
||||||
disposeAll,
|
|
||||||
runEviction,
|
runEviction,
|
||||||
vcsCache,
|
vcsCache,
|
||||||
metaCache,
|
metaCache,
|
||||||
|
|||||||
@@ -98,7 +98,6 @@ export type IconCache = {
|
|||||||
|
|
||||||
export type ChildOptions = {
|
export type ChildOptions = {
|
||||||
bootstrap?: boolean
|
bootstrap?: boolean
|
||||||
mcp?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DirState = {
|
export type DirState = {
|
||||||
|
|||||||
@@ -20,88 +20,6 @@ export type FatalRendererErrorLog = {
|
|||||||
os?: DesktopOS
|
os?: DesktopOS
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WslRuntimeCheck = {
|
|
||||||
available: boolean
|
|
||||||
version: string | null
|
|
||||||
error: string | null
|
|
||||||
}
|
|
||||||
export type WslInstalledDistro = {
|
|
||||||
name: string
|
|
||||||
version: number | null
|
|
||||||
isDefault: boolean
|
|
||||||
}
|
|
||||||
export type WslOnlineDistro = {
|
|
||||||
name: string
|
|
||||||
label: string
|
|
||||||
}
|
|
||||||
export type WslDistroProbe = {
|
|
||||||
name: string
|
|
||||||
canExecute: boolean
|
|
||||||
hasBash: boolean
|
|
||||||
hasCurl: boolean
|
|
||||||
error: string | null
|
|
||||||
}
|
|
||||||
export type WslOpencodeCheck = {
|
|
||||||
distro: string
|
|
||||||
resolvedPath: string | null
|
|
||||||
version: string | null
|
|
||||||
expectedVersion: string | null
|
|
||||||
matchesDesktop: boolean | null
|
|
||||||
error: string | null
|
|
||||||
}
|
|
||||||
export type WslServerConfig = {
|
|
||||||
id: string
|
|
||||||
distro: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WslServerRuntime =
|
|
||||||
| { kind: "starting" }
|
|
||||||
| { kind: "ready"; url: string; username: string | null; password: string | null }
|
|
||||||
| { kind: "failed"; message: string }
|
|
||||||
| { kind: "stopped" }
|
|
||||||
|
|
||||||
export type WslServerItem = {
|
|
||||||
config: WslServerConfig
|
|
||||||
runtime: WslServerRuntime
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WslJob =
|
|
||||||
| { kind: "runtime"; startedAt: number }
|
|
||||||
| { kind: "distros"; startedAt: number }
|
|
||||||
| { kind: "install-wsl"; startedAt: number }
|
|
||||||
| { kind: "install-distro"; distro: string; startedAt: number }
|
|
||||||
| { kind: "probe-distro"; distro: string; startedAt: number }
|
|
||||||
| { kind: "probe-opencode"; distro: string; startedAt: number }
|
|
||||||
| { kind: "install-opencode"; distro: string; startedAt: number }
|
|
||||||
|
|
||||||
export type WslServersState = {
|
|
||||||
runtime: WslRuntimeCheck | null
|
|
||||||
installed: WslInstalledDistro[]
|
|
||||||
online: WslOnlineDistro[]
|
|
||||||
distroProbes: Record<string, WslDistroProbe>
|
|
||||||
opencodeChecks: Record<string, WslOpencodeCheck>
|
|
||||||
pendingRestart: boolean
|
|
||||||
servers: WslServerItem[]
|
|
||||||
job: WslJob | null
|
|
||||||
}
|
|
||||||
export type WslServersEvent = { type: "state"; state: WslServersState }
|
|
||||||
|
|
||||||
export type WslServersPlatform = {
|
|
||||||
getState(): Promise<WslServersState>
|
|
||||||
subscribe(cb: (event: WslServersEvent) => void): () => void
|
|
||||||
probeRuntime(): Promise<void>
|
|
||||||
refreshDistros(): Promise<void>
|
|
||||||
installWsl(): Promise<void>
|
|
||||||
installDistro(name: string): Promise<void>
|
|
||||||
probeDistro(name: string): Promise<void>
|
|
||||||
probeOpencode(name: string): Promise<void>
|
|
||||||
installOpencode(name: string): Promise<void>
|
|
||||||
openTerminal(name: string): Promise<void>
|
|
||||||
addServer(distro: string): Promise<WslServerConfig>
|
|
||||||
removeServer(id: string): Promise<void>
|
|
||||||
startServer(id: string): Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Platform = {
|
export type Platform = {
|
||||||
/** Platform discriminator */
|
/** Platform discriminator */
|
||||||
platform: PlatformName
|
platform: PlatformName
|
||||||
@@ -157,8 +75,11 @@ export type Platform = {
|
|||||||
/** Set the default server URL to use on app startup (platform-specific) */
|
/** Set the default server URL to use on app startup (platform-specific) */
|
||||||
setDefaultServer?(url: ServerConnection.Key | null): Promise<void> | void
|
setDefaultServer?(url: ServerConnection.Key | null): Promise<void> | void
|
||||||
|
|
||||||
/** Manage WSL sidecar servers (Electron on Windows only) */
|
/** Get the configured WSL integration (desktop only) */
|
||||||
wslServers?: WslServersPlatform
|
getWslEnabled?(): Promise<boolean>
|
||||||
|
|
||||||
|
/** Set the configured WSL integration (desktop only) */
|
||||||
|
setWslEnabled?(config: boolean): Promise<void> | void
|
||||||
|
|
||||||
/** Get the preferred display backend (desktop only) */
|
/** Get the preferred display backend (desktop only) */
|
||||||
getDisplayBackend?(): Promise<DisplayBackend | null> | DisplayBackend | null
|
getDisplayBackend?(): Promise<DisplayBackend | null> | DisplayBackend | null
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import { PathKey } from "@/utils/path-key"
|
|||||||
import { createDirSyncContext } from "./directory-sync"
|
import { createDirSyncContext } from "./directory-sync"
|
||||||
import { createSimpleContext, NormalizedProviderListResponse } from "@opencode-ai/ui/context"
|
import { createSimpleContext, NormalizedProviderListResponse } from "@opencode-ai/ui/context"
|
||||||
import { createRefCountMap } from "@/utils/refcount"
|
import { createRefCountMap } from "@/utils/refcount"
|
||||||
import { retry } from "@opencode-ai/core/util/retry"
|
|
||||||
|
|
||||||
type GlobalStore = {
|
type GlobalStore = {
|
||||||
ready: boolean
|
ready: boolean
|
||||||
@@ -206,15 +205,6 @@ export function createServerSyncContext() {
|
|||||||
onBootstrap: (directory) => {
|
onBootstrap: (directory) => {
|
||||||
void bootstrapInstance(directory)
|
void bootstrapInstance(directory)
|
||||||
},
|
},
|
||||||
onMcp: (directory, setStore) => {
|
|
||||||
void retry(() => sdkFor(directory).command.list().then((x) => setStore("command", x.data ?? []))).catch((err) => {
|
|
||||||
showToast({
|
|
||||||
variant: "error",
|
|
||||||
title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }),
|
|
||||||
description: formatServerError(err, language.t),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
},
|
|
||||||
onDispose: (directory) => {
|
onDispose: (directory) => {
|
||||||
const key = directoryKey(directory)
|
const key = directoryKey(directory)
|
||||||
queue.clear(key)
|
queue.clear(key)
|
||||||
@@ -321,7 +311,6 @@ export function createServerSyncContext() {
|
|||||||
const sdk = sdkFor(directory)
|
const sdk = sdkFor(directory)
|
||||||
await bootstrapDirectory({
|
await bootstrapDirectory({
|
||||||
directory,
|
directory,
|
||||||
mcp: children.mcp(key),
|
|
||||||
global: {
|
global: {
|
||||||
config: globalStore.config,
|
config: globalStore.config,
|
||||||
path: globalStore.path,
|
path: globalStore.path,
|
||||||
@@ -393,7 +382,11 @@ export function createServerSyncContext() {
|
|||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
queue.dispose()
|
queue.dispose()
|
||||||
})
|
})
|
||||||
onCleanup(children.disposeAll)
|
onCleanup(() => {
|
||||||
|
for (const directory of Object.keys(children.children)) {
|
||||||
|
children.disposeDirectory(directoryKey(directory))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (typeof requestAnimationFrame === "function") {
|
if (typeof requestAnimationFrame === "function") {
|
||||||
@@ -444,7 +437,6 @@ export function createServerSyncContext() {
|
|||||||
},
|
},
|
||||||
child: children.child,
|
child: children.child,
|
||||||
peek: children.peek,
|
peek: children.peek,
|
||||||
disableMcp: children.disableMcp,
|
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
// bootstrap,
|
// bootstrap,
|
||||||
updateConfig: updateConfigMutation.mutateAsync,
|
updateConfig: updateConfigMutation.mutateAsync,
|
||||||
@@ -462,11 +454,7 @@ export const { use: useServerSync, provider: ServerSyncProvider } = createSimple
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...sync,
|
...sync,
|
||||||
createDirSyncContext: createRefCountMap(
|
createDirSyncContext: createRefCountMap((dir) => createDirSyncContext(dir, sync)),
|
||||||
(dir) => createDirSyncContext(dir, sync),
|
|
||||||
(dir) => sync.disableMcp(dir),
|
|
||||||
directoryKey,
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { type Accessor, batch, createEffect, createMemo } from "solid-js"
|
import { type Accessor, batch, createMemo } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
|
|
||||||
@@ -151,12 +151,6 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
|||||||
if (state.active !== input) setState("active", input)
|
if (state.active !== input) setState("active", input)
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (typeof window === "undefined") return
|
|
||||||
window.__OPENCODE__ ??= {}
|
|
||||||
window.__OPENCODE__.activeServer = state.active
|
|
||||||
})
|
|
||||||
|
|
||||||
function add(input: ServerConnection.Http) {
|
function add(input: ServerConnection.Http) {
|
||||||
const url_ = normalizeServerUrl(input.http.url)
|
const url_ = normalizeServerUrl(input.http.url)
|
||||||
if (!url_) return
|
if (!url_) return
|
||||||
@@ -193,7 +187,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
|||||||
)
|
)
|
||||||
const isLocal = createMemo(() => {
|
const isLocal = createMemo(() => {
|
||||||
const c = current()
|
const c = current()
|
||||||
return c?.type === "sidecar" || (c?.type === "http" && isLocalHost(c.http.url))
|
return (c?.type === "sidecar" && c.variant === "base") || (c?.type === "http" && isLocalHost(c.http.url))
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
|
||||||
import { queryOptions, skipToken, useQuery, useQueryClient } from "@tanstack/solid-query"
|
|
||||||
import { createEffect, onCleanup } from "solid-js"
|
|
||||||
import type { WslServersPlatform, WslServersState } from "./platform"
|
|
||||||
import { usePlatform } from "./platform"
|
|
||||||
|
|
||||||
const wslServersQueryKey = ["platform", "wslServers"] as const
|
|
||||||
|
|
||||||
export const { use: useWslServers, provider: WslServersProvider } = createSimpleContext({
|
|
||||||
name: "WslServers",
|
|
||||||
init: () => {
|
|
||||||
const platform = usePlatform()
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
const query = useQuery(() => {
|
|
||||||
const api = platform.wslServers
|
|
||||||
return queryOptions<WslServersState>({
|
|
||||||
queryKey: wslServersQueryKey,
|
|
||||||
queryFn: api ? () => api.getState() : skipToken,
|
|
||||||
staleTime: Number.POSITIVE_INFINITY,
|
|
||||||
gcTime: Number.POSITIVE_INFINITY,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const api = platform.wslServers
|
|
||||||
if (!api) return
|
|
||||||
const off = api.subscribe((event) => {
|
|
||||||
queryClient.setQueryData(wslServersQueryKey, event.state)
|
|
||||||
})
|
|
||||||
onCleanup(off)
|
|
||||||
})
|
|
||||||
|
|
||||||
return query
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -2,24 +2,6 @@ export { AppBaseProviders, AppInterface } from "./app"
|
|||||||
export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
|
export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
|
||||||
export { useCommand } from "./context/command"
|
export { useCommand } from "./context/command"
|
||||||
export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language"
|
export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language"
|
||||||
export { useWslServers } from "./context/wsl-servers"
|
export { type DisplayBackend, type FatalRendererErrorLog, type Platform, PlatformProvider } from "./context/platform"
|
||||||
export {
|
|
||||||
type DisplayBackend,
|
|
||||||
type FatalRendererErrorLog,
|
|
||||||
type Platform,
|
|
||||||
PlatformProvider,
|
|
||||||
type WslDistroProbe,
|
|
||||||
type WslInstalledDistro,
|
|
||||||
type WslJob,
|
|
||||||
type WslOnlineDistro,
|
|
||||||
type WslOpencodeCheck,
|
|
||||||
type WslRuntimeCheck,
|
|
||||||
type WslServerConfig,
|
|
||||||
type WslServerItem,
|
|
||||||
type WslServerRuntime,
|
|
||||||
type WslServersEvent,
|
|
||||||
type WslServersPlatform,
|
|
||||||
type WslServersState,
|
|
||||||
} from "./context/platform"
|
|
||||||
export { ServerConnection } from "./context/server"
|
export { ServerConnection } from "./context/server"
|
||||||
export { handleNotificationClick } from "./utils/notification-click"
|
export { handleNotificationClick } from "./utils/notification-click"
|
||||||
|
|||||||
@@ -701,7 +701,6 @@ function LegacyHome() {
|
|||||||
if (healthy === false) return "bg-icon-critical-base"
|
if (healthy === false) return "bg-icon-critical-base"
|
||||||
return "bg-border-weak-base"
|
return "bg-border-weak-base"
|
||||||
})
|
})
|
||||||
const useWebDirectoryPicker = createMemo(() => server.current?.type === "sidecar" && server.current.variant === "wsl")
|
|
||||||
|
|
||||||
function openProject(directory: string) {
|
function openProject(directory: string) {
|
||||||
layout.projects.open(directory)
|
layout.projects.open(directory)
|
||||||
@@ -720,7 +719,7 @@ function LegacyHome() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (platform.openDirectoryPickerDialog && server.isLocal() && !useWebDirectoryPicker()) {
|
if (platform.openDirectoryPickerDialog && server.isLocal()) {
|
||||||
const result = await platform.openDirectoryPickerDialog?.({
|
const result = await platform.openDirectoryPickerDialog?.({
|
||||||
title: language.t("command.project.open"),
|
title: language.t("command.project.open"),
|
||||||
multiple: true,
|
multiple: true,
|
||||||
@@ -741,7 +740,7 @@ function LegacyHome() {
|
|||||||
size="large"
|
size="large"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="mt-4 mx-auto text-14-regular text-text-weak"
|
class="mt-4 mx-auto text-14-regular text-text-weak"
|
||||||
onClick={() => dialog.show(() => <DialogSelectServer onNavigateHome={() => navigate("/")} />)}
|
onClick={() => dialog.show(() => <DialogSelectServer />)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
|
|||||||
@@ -151,7 +151,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
}
|
}
|
||||||
const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme])
|
const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme])
|
||||||
const currentDir = createMemo(() => route().dir)
|
const currentDir = createMemo(() => route().dir)
|
||||||
const useWebDirectoryPicker = createMemo(() => server.current?.type === "sidecar" && server.current.variant === "wsl")
|
|
||||||
|
|
||||||
const [state, setState] = createStore({
|
const [state, setState] = createStore({
|
||||||
autoselect: !initialDirectory && !newDesign(),
|
autoselect: !initialDirectory && !newDesign(),
|
||||||
@@ -1222,7 +1221,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
const run = ++dialogRun
|
const run = ++dialogRun
|
||||||
void import("@/components/dialog-select-server").then((x) => {
|
void import("@/components/dialog-select-server").then((x) => {
|
||||||
if (dialogDead || dialogRun !== run) return
|
if (dialogDead || dialogRun !== run) return
|
||||||
dialog.show(() => <x.DialogSelectServer onNavigateHome={() => navigate("/")} />)
|
dialog.show(() => <x.DialogSelectServer />)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1475,7 +1474,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (platform.openDirectoryPickerDialog && server.isLocal() && !useWebDirectoryPicker()) {
|
if (platform.openDirectoryPickerDialog && server.isLocal()) {
|
||||||
const result = await platform.openDirectoryPickerDialog?.({
|
const result = await platform.openDirectoryPickerDialog?.({
|
||||||
title: language.t("command.project.open"),
|
title: language.t("command.project.open"),
|
||||||
multiple: true,
|
multiple: true,
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
import { createRoot } from "solid-js"
|
|
||||||
import { createRefCountMap } from "./refcount"
|
|
||||||
import { pathKey } from "./path-key"
|
|
||||||
|
|
||||||
describe("createRefCountMap", () => {
|
|
||||||
test("removes an item after its last owner is disposed", () => {
|
|
||||||
const removed: string[] = []
|
|
||||||
const map = createRefCountMap(
|
|
||||||
(key) => key,
|
|
||||||
(key) => removed.push(key),
|
|
||||||
)
|
|
||||||
const first = createRoot((dispose) => {
|
|
||||||
map("/project")
|
|
||||||
return dispose
|
|
||||||
})
|
|
||||||
const second = createRoot((dispose) => {
|
|
||||||
map("/project")
|
|
||||||
return dispose
|
|
||||||
})
|
|
||||||
|
|
||||||
first()
|
|
||||||
expect(removed).toEqual([])
|
|
||||||
second()
|
|
||||||
expect(removed).toEqual(["/project"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("keeps equivalent path consumers until the last owner is disposed", () => {
|
|
||||||
const removed: string[] = []
|
|
||||||
const map = createRefCountMap(
|
|
||||||
(key) => key,
|
|
||||||
(key) => removed.push(key),
|
|
||||||
pathKey,
|
|
||||||
)
|
|
||||||
const first = createRoot((dispose) => {
|
|
||||||
map("C:\\repo")
|
|
||||||
return dispose
|
|
||||||
})
|
|
||||||
const second = createRoot((dispose) => {
|
|
||||||
map("C:/repo/")
|
|
||||||
return dispose
|
|
||||||
})
|
|
||||||
|
|
||||||
first()
|
|
||||||
expect(removed).toEqual([])
|
|
||||||
second()
|
|
||||||
expect(removed).toEqual(["C:/repo"])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,32 +1,26 @@
|
|||||||
import { onCleanup } from "solid-js"
|
import { onCleanup } from "solid-js"
|
||||||
|
|
||||||
export function createRefCountMap<T>(
|
export function createRefCountMap<T>(create: (key: string) => T) {
|
||||||
create: (key: string) => T,
|
|
||||||
remove?: (key: string) => void,
|
|
||||||
identity: (key: string) => string = (key) => key,
|
|
||||||
) {
|
|
||||||
const items = new Map<string, T>()
|
const items = new Map<string, T>()
|
||||||
const refCounts = new Map<string, number>()
|
const refCounts = new Map<string, number>()
|
||||||
|
|
||||||
return (key: string) => {
|
return (key: string) => {
|
||||||
const id = identity(key)
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
refCounts.set(id, (refCounts.get(id) ?? 0) - 1)
|
refCounts.set(key, (refCounts.get(key) ?? 0) - 1)
|
||||||
if (refCounts.get(id) === 0) {
|
if (refCounts.get(key) === 0) {
|
||||||
remove?.(id)
|
items.delete(key)
|
||||||
items.delete(id)
|
refCounts.delete(key)
|
||||||
refCounts.delete(id)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const cached = items.get(id)
|
const cached = items.get(key)
|
||||||
if (cached) {
|
if (cached) {
|
||||||
refCounts.set(id, (refCounts.get(id) ?? 0) + 1)
|
refCounts.set(key, (refCounts.get(key) ?? 0) + 1)
|
||||||
return cached
|
return cached
|
||||||
}
|
}
|
||||||
const item = create(key)
|
const item = create(key)
|
||||||
items.set(id, item)
|
items.set(key, item)
|
||||||
refCounts.set(id, 1)
|
refCounts.set(key, 1)
|
||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useDragDropContext } from "@thisbeyond/solid-dnd"
|
import { useDragDropContext } from "@thisbeyond/solid-dnd"
|
||||||
import type { Transformer } from "@thisbeyond/solid-dnd"
|
import type { Transformer } from "@thisbeyond/solid-dnd"
|
||||||
import type { JSXElement } from "solid-js"
|
import { createRoot, onCleanup, type JSXElement } from "solid-js"
|
||||||
|
|
||||||
type DragEvent = { draggable?: { id?: unknown } }
|
type DragEvent = { draggable?: { id?: unknown } }
|
||||||
|
|
||||||
@@ -27,16 +27,20 @@ const createAxisConstraint = (axis: "x" | "y", transformerId: string) => (): JSX
|
|||||||
if (!context) return null
|
if (!context) return null
|
||||||
const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
|
const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
|
||||||
const transformer = createTransformer(transformerId, axis)
|
const transformer = createTransformer(transformerId, axis)
|
||||||
onDragStart((event) => {
|
const dispose = createRoot((dispose) => {
|
||||||
const id = getDraggableId(event)
|
onDragStart((event) => {
|
||||||
if (!id) return
|
const id = getDraggableId(event)
|
||||||
addTransformer("draggables", id, transformer)
|
if (!id) return
|
||||||
})
|
addTransformer("draggables", id, transformer)
|
||||||
onDragEnd((event) => {
|
})
|
||||||
const id = getDraggableId(event)
|
onDragEnd((event) => {
|
||||||
if (!id) return
|
const id = getDraggableId(event)
|
||||||
removeTransformer("draggables", id, transformer.id)
|
if (!id) return
|
||||||
|
removeTransformer("draggables", id, transformer.id)
|
||||||
|
})
|
||||||
|
return dispose
|
||||||
})
|
})
|
||||||
|
onCleanup(dispose)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,242 +0,0 @@
|
|||||||
export * as AuthWellKnown from "./auth-well-known"
|
|
||||||
|
|
||||||
import path from "path"
|
|
||||||
import { Context, Effect, Layer, Option, Schema, SynchronizedRef } from "effect"
|
|
||||||
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
|
|
||||||
import { AppFileSystem } from "./filesystem"
|
|
||||||
import { Global } from "./global"
|
|
||||||
import { Substitution } from "./substitution"
|
|
||||||
|
|
||||||
export class Entry extends Schema.Class<Entry>("AuthWellKnown.Entry")({
|
|
||||||
key: Schema.String,
|
|
||||||
token: Schema.String,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export class FileWriteError extends Schema.TaggedErrorClass<FileWriteError>()("AuthWellKnown.FileWriteError", {
|
|
||||||
operation: Schema.Union([Schema.Literal("migrate"), Schema.Literal("write")]),
|
|
||||||
cause: Schema.Defect,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export class RemoteConfigError extends Schema.TaggedErrorClass<RemoteConfigError>()("AuthWellKnown.RemoteConfigError", {
|
|
||||||
url: Schema.String,
|
|
||||||
status: Schema.Number.pipe(Schema.optional),
|
|
||||||
cause: Schema.Defect.pipe(Schema.optional),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export type Error = FileWriteError | RemoteConfigError
|
|
||||||
|
|
||||||
const RemoteConfig = Schema.Struct({
|
|
||||||
url: Schema.String,
|
|
||||||
headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
|
|
||||||
})
|
|
||||||
|
|
||||||
export class Metadata extends Schema.Class<Metadata>("AuthWellKnown.Metadata")({
|
|
||||||
auth: Schema.Struct({
|
|
||||||
command: Schema.Array(Schema.String),
|
|
||||||
env: Schema.String,
|
|
||||||
}).pipe(Schema.optional),
|
|
||||||
config: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
|
||||||
remote_config: RemoteConfig.pipe(Schema.optional),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export type ConfigDocument = {
|
|
||||||
url: string
|
|
||||||
source: string
|
|
||||||
dir: string
|
|
||||||
content: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly all: () => Effect.Effect<Record<string, Entry>, Error>
|
|
||||||
readonly get: (url: string) => Effect.Effect<Entry | undefined, Error>
|
|
||||||
readonly set: (url: string, entry: Entry) => Effect.Effect<void, Error>
|
|
||||||
readonly remove: (url: string) => Effect.Effect<void, Error>
|
|
||||||
readonly metadata: (url: string) => Effect.Effect<Metadata, Error>
|
|
||||||
readonly configs: () => Effect.Effect<ConfigDocument[], Error>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/AuthWellKnown") {}
|
|
||||||
const decodeMetadata = Schema.decodeUnknownEffect(Metadata)
|
|
||||||
const decodeRemoteConfig = Schema.decodeUnknownEffect(RemoteConfig)
|
|
||||||
|
|
||||||
function loadLegacyAuth(input: {
|
|
||||||
fsys: AppFileSystem.Interface
|
|
||||||
dataDir: string
|
|
||||||
write: (data: Record<string, Entry>) => Effect.Effect<void, Error>
|
|
||||||
}) {
|
|
||||||
return Effect.gen(function* () {
|
|
||||||
const decodeLegacy = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Unknown))
|
|
||||||
const decodeLegacyCredential = Schema.decodeUnknownOption(
|
|
||||||
Schema.Struct({
|
|
||||||
type: Schema.Literal("wellknown"),
|
|
||||||
key: Schema.String,
|
|
||||||
token: Schema.String,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const legacy = Object.fromEntries(
|
|
||||||
Object.entries(
|
|
||||||
Option.getOrElse(
|
|
||||||
decodeLegacy(
|
|
||||||
yield* input.fsys.readJson(path.join(input.dataDir, "auth.json")).pipe(Effect.orElseSucceed(() => null)),
|
|
||||||
),
|
|
||||||
() => ({}),
|
|
||||||
),
|
|
||||||
).flatMap(([url, value]) => {
|
|
||||||
const decoded = Option.getOrUndefined(decodeLegacyCredential(value))
|
|
||||||
return decoded ? [[url.replace(/\/+$/, ""), new Entry({ key: decoded.key, token: decoded.token })]] : []
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
if (Object.keys(legacy).length > 0) yield* input.write(legacy).pipe(Effect.ignore)
|
|
||||||
return legacy
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const fsys = yield* AppFileSystem.Service
|
|
||||||
const global = yield* Global.Service
|
|
||||||
const http = yield* HttpClient.HttpClient
|
|
||||||
const substitution = yield* Substitution.Service
|
|
||||||
const file = path.join(global.data, "well-known.json")
|
|
||||||
const decodeEntries = Schema.decodeUnknownOption(Schema.Record(Schema.String, Entry))
|
|
||||||
const normalizeUrl = (url: string) => url.replace(/\/+$/, "")
|
|
||||||
|
|
||||||
const write = (operation: "migrate" | "write", data: Record<string, Entry>) =>
|
|
||||||
fsys.writeJson(file, data, 0o600).pipe(Effect.mapError((cause) => new FileWriteError({ operation, cause })))
|
|
||||||
|
|
||||||
const load: () => Effect.Effect<Record<string, Entry>> = Effect.fnUntraced(function* () {
|
|
||||||
const current = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null))
|
|
||||||
if (current && typeof current === "object")
|
|
||||||
return Option.getOrElse(decodeEntries(current), () => ({}) as Record<string, Entry>)
|
|
||||||
return yield* loadLegacyAuth({ fsys, dataDir: global.data, write: (data) => write("migrate", data) })
|
|
||||||
})
|
|
||||||
|
|
||||||
const state = SynchronizedRef.makeUnsafe<Record<string, Entry>>(yield* load())
|
|
||||||
|
|
||||||
const metadata = Effect.fn("AuthWellKnown.metadata")(function* (url: string) {
|
|
||||||
const normalized = normalizeUrl(url)
|
|
||||||
const source = `${normalized}/.well-known/opencode`
|
|
||||||
const response = yield* HttpClientRequest.get(source).pipe(
|
|
||||||
HttpClientRequest.acceptJson,
|
|
||||||
http.execute,
|
|
||||||
Effect.mapError((cause) => new RemoteConfigError({ url: source, cause })),
|
|
||||||
)
|
|
||||||
if (response.status < 200 || response.status >= 300) {
|
|
||||||
return yield* new RemoteConfigError({ url: source, status: response.status })
|
|
||||||
}
|
|
||||||
const metadata = yield* response.json.pipe(
|
|
||||||
Effect.flatMap(decodeMetadata),
|
|
||||||
Effect.mapError((cause) => new RemoteConfigError({ url: source, cause })),
|
|
||||||
)
|
|
||||||
return { url: normalized, source, dir: path.dirname(source), metadata }
|
|
||||||
})
|
|
||||||
|
|
||||||
const remote = Effect.fn("AuthWellKnown.remote")(function* (input: { url: string; headers?: Record<string, string> }) {
|
|
||||||
const response = yield* HttpClientRequest.get(input.url).pipe(
|
|
||||||
HttpClientRequest.acceptJson,
|
|
||||||
input.headers ? HttpClientRequest.setHeaders(input.headers) : (request) => request,
|
|
||||||
http.execute,
|
|
||||||
Effect.mapError((cause) => new RemoteConfigError({ url: input.url, cause })),
|
|
||||||
)
|
|
||||||
if (response.status < 200 || response.status >= 300) {
|
|
||||||
return yield* new RemoteConfigError({ url: input.url, status: response.status })
|
|
||||||
}
|
|
||||||
return yield* response.json.pipe(Effect.mapError((cause) => new RemoteConfigError({ url: input.url, cause })))
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({
|
|
||||||
all: Effect.fn("AuthWellKnown.all")(function* () {
|
|
||||||
return yield* SynchronizedRef.get(state)
|
|
||||||
}),
|
|
||||||
|
|
||||||
get: Effect.fn("AuthWellKnown.get")(function* (url) {
|
|
||||||
return (yield* SynchronizedRef.get(state))[normalizeUrl(url)]
|
|
||||||
}),
|
|
||||||
|
|
||||||
set: Effect.fn("AuthWellKnown.set")(function* (url, entry) {
|
|
||||||
yield* SynchronizedRef.updateEffect(
|
|
||||||
state,
|
|
||||||
Effect.fnUntraced(function* (data) {
|
|
||||||
const next = { ...data, [normalizeUrl(url)]: entry }
|
|
||||||
yield* write("write", next)
|
|
||||||
return next
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
|
|
||||||
remove: Effect.fn("AuthWellKnown.remove")(function* (url) {
|
|
||||||
yield* SynchronizedRef.updateEffect(
|
|
||||||
state,
|
|
||||||
Effect.fnUntraced(function* (data) {
|
|
||||||
const next = { ...data }
|
|
||||||
delete next[url]
|
|
||||||
delete next[normalizeUrl(url)]
|
|
||||||
yield* write("write", next)
|
|
||||||
return next
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
|
|
||||||
metadata: Effect.fn("AuthWellKnown.metadata.public")(function* (url) {
|
|
||||||
return (yield* metadata(url)).metadata
|
|
||||||
}),
|
|
||||||
|
|
||||||
configs: Effect.fn("AuthWellKnown.configs")(function* () {
|
|
||||||
const documents = yield* Effect.all(
|
|
||||||
Object.entries(yield* SynchronizedRef.get(state)).map(([url, entry]) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const configs: ConfigDocument[] = []
|
|
||||||
const response = yield* metadata(url)
|
|
||||||
const env = { [entry.key]: entry.token }
|
|
||||||
if (response.metadata.config) {
|
|
||||||
configs.push({
|
|
||||||
url: response.url,
|
|
||||||
source: response.source,
|
|
||||||
dir: response.dir,
|
|
||||||
content: response.metadata.config,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (response.metadata.remote_config) {
|
|
||||||
const remoteConfig = yield* substitution
|
|
||||||
.substitute({
|
|
||||||
text: JSON.stringify(response.metadata.remote_config),
|
|
||||||
type: "virtual",
|
|
||||||
dir: response.url,
|
|
||||||
source: response.source,
|
|
||||||
env,
|
|
||||||
})
|
|
||||||
.pipe(
|
|
||||||
Effect.flatMap((text) =>
|
|
||||||
Effect.try({
|
|
||||||
try: () => JSON.parse(text) as unknown,
|
|
||||||
catch: (cause) => new RemoteConfigError({ url: response.source, cause }),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
Effect.flatMap(decodeRemoteConfig),
|
|
||||||
Effect.mapError((cause) => new RemoteConfigError({ url: response.source, cause })),
|
|
||||||
)
|
|
||||||
configs.push({
|
|
||||||
url: remoteConfig.url,
|
|
||||||
source: remoteConfig.url,
|
|
||||||
dir: path.dirname(remoteConfig.url),
|
|
||||||
content: yield* remote({ url: remoteConfig.url, headers: remoteConfig.headers }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return configs
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
{ concurrency: "unbounded" },
|
|
||||||
)
|
|
||||||
return documents.flat()
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(
|
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
|
||||||
Layer.provide(Global.defaultLayer),
|
|
||||||
Layer.provide(FetchHttpClient.layer),
|
|
||||||
Layer.provide(Substitution.defaultLayer),
|
|
||||||
)
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
export * as Substitution from "./substitution"
|
|
||||||
|
|
||||||
import os from "os"
|
|
||||||
import path from "path"
|
|
||||||
import { Context, Effect, Layer, Schema } from "effect"
|
|
||||||
import { AppFileSystem } from "./filesystem"
|
|
||||||
|
|
||||||
type Source =
|
|
||||||
| {
|
|
||||||
type: "path"
|
|
||||||
path: string
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: "virtual"
|
|
||||||
source: string
|
|
||||||
dir: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Input = Source & {
|
|
||||||
text: string
|
|
||||||
missing?: "error" | "empty"
|
|
||||||
env?: Record<string, string | undefined>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class FileReferenceError extends Schema.TaggedErrorClass<FileReferenceError>()("Substitution.FileReferenceError", {
|
|
||||||
source: Schema.String,
|
|
||||||
token: Schema.String,
|
|
||||||
resolved: Schema.String,
|
|
||||||
cause: Schema.Defect,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export type Error = FileReferenceError
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly substitute: (input: Input) => Effect.Effect<string, Error>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Substitution") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const fs = yield* AppFileSystem.Service
|
|
||||||
|
|
||||||
return Service.of({
|
|
||||||
substitute: Effect.fn("Substitution.substitute")(function* (input) {
|
|
||||||
const missing = input.missing ?? "error"
|
|
||||||
const text = input.text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
|
|
||||||
return input.env?.[varName] ?? process.env[varName] ?? ""
|
|
||||||
})
|
|
||||||
|
|
||||||
const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g))
|
|
||||||
if (!fileMatches.length) return text
|
|
||||||
|
|
||||||
const configDir = input.type === "path" ? path.dirname(input.path) : input.dir
|
|
||||||
const configSource = input.type === "path" ? input.path : input.source
|
|
||||||
let out = ""
|
|
||||||
let cursor = 0
|
|
||||||
|
|
||||||
for (const match of fileMatches) {
|
|
||||||
const token = match[0]
|
|
||||||
const index = match.index!
|
|
||||||
out += text.slice(cursor, index)
|
|
||||||
|
|
||||||
const lineStart = text.lastIndexOf("\n", index - 1) + 1
|
|
||||||
const prefix = text.slice(lineStart, index).trimStart()
|
|
||||||
if (prefix.startsWith("//")) {
|
|
||||||
out += token
|
|
||||||
cursor = index + token.length
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const reference = token.replace(/^\{file:/, "").replace(/\}$/, "")
|
|
||||||
const filepath = reference.startsWith("~/") ? path.join(os.homedir(), reference.slice(2)) : reference
|
|
||||||
const resolved = path.isAbsolute(filepath) ? filepath : path.resolve(configDir, filepath)
|
|
||||||
const content = yield* fs.readFileString(resolved).pipe(
|
|
||||||
Effect.catch((cause) => {
|
|
||||||
if (missing === "empty") return Effect.succeed("")
|
|
||||||
return Effect.fail(new FileReferenceError({ source: configSource, token, resolved, cause }))
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
out += JSON.stringify(content.trim()).slice(1, -1)
|
|
||||||
cursor = index + token.length
|
|
||||||
}
|
|
||||||
|
|
||||||
out += text.slice(cursor)
|
|
||||||
return out
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
import { describe, expect } from "bun:test"
|
|
||||||
import path from "path"
|
|
||||||
import { Effect, Layer } from "effect"
|
|
||||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
|
||||||
import { Global } from "@opencode-ai/core/global"
|
|
||||||
import { Substitution } from "@opencode-ai/core/substitution"
|
|
||||||
import { AuthWellKnown } from "@opencode-ai/core/auth-well-known"
|
|
||||||
import { tmpdir } from "./fixture/tmpdir"
|
|
||||||
import { testEffect } from "./lib/effect"
|
|
||||||
|
|
||||||
const it = testEffect(Layer.empty)
|
|
||||||
|
|
||||||
const unexpectedHttpClient = HttpClient.make((request) => Effect.die(`unexpected http request: ${request.url}`))
|
|
||||||
|
|
||||||
const withAuthWellKnown = <A, E, R>(
|
|
||||||
dir: string,
|
|
||||||
effect: Effect.Effect<A, E, R | AuthWellKnown.Service>,
|
|
||||||
client = unexpectedHttpClient,
|
|
||||||
) =>
|
|
||||||
effect.pipe(
|
|
||||||
Effect.provide(AuthWellKnown.layer),
|
|
||||||
Effect.provide(AppFileSystem.defaultLayer),
|
|
||||||
Effect.provide(Global.layerWith({ data: dir })),
|
|
||||||
Effect.provide(Layer.succeed(HttpClient.HttpClient, client)),
|
|
||||||
Effect.provide(Substitution.defaultLayer),
|
|
||||||
)
|
|
||||||
|
|
||||||
const wellKnownConfigClient = HttpClient.make((request) => {
|
|
||||||
if (request.url === "https://example.com/.well-known/opencode") {
|
|
||||||
return Effect.succeed(
|
|
||||||
HttpClientResponse.fromWeb(
|
|
||||||
request,
|
|
||||||
Response.json({
|
|
||||||
config: { instructions: ["local"] },
|
|
||||||
remote_config: {
|
|
||||||
url: "https://remote.example.com/config",
|
|
||||||
headers: {
|
|
||||||
authorization: "Bearer {env:TEST_TOKEN}",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (request.url === "https://remote.example.com/config") {
|
|
||||||
expect(request.headers.authorization).toBe("Bearer secret")
|
|
||||||
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ model: "remote/model" })))
|
|
||||||
}
|
|
||||||
return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 404 })))
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("AuthWellKnown", () => {
|
|
||||||
it.live("stores well-known credentials", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const tmp = yield* Effect.acquireRelease(
|
|
||||||
Effect.promise(() => tmpdir()),
|
|
||||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
|
||||||
)
|
|
||||||
|
|
||||||
yield* withAuthWellKnown(
|
|
||||||
tmp.path,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const auth = yield* AuthWellKnown.Service
|
|
||||||
yield* auth.set("https://example.com/", new AuthWellKnown.Entry({ key: "TEST_TOKEN", token: "secret" }))
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, "well-known.json")).json())).toEqual({
|
|
||||||
"https://example.com": {
|
|
||||||
key: "TEST_TOKEN",
|
|
||||||
token: "secret",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("migrates legacy well-known auth records", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const tmp = yield* Effect.acquireRelease(
|
|
||||||
Effect.promise(() => tmpdir()),
|
|
||||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
|
||||||
)
|
|
||||||
yield* Effect.promise(() =>
|
|
||||||
Bun.write(
|
|
||||||
path.join(tmp.path, "auth.json"),
|
|
||||||
JSON.stringify({
|
|
||||||
"https://example.com": {
|
|
||||||
type: "wellknown",
|
|
||||||
key: "TEST_TOKEN",
|
|
||||||
token: "secret",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const entry = yield* withAuthWellKnown(
|
|
||||||
tmp.path,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const auth = yield* AuthWellKnown.Service
|
|
||||||
return yield* auth.get("https://example.com/")
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(entry).toEqual({
|
|
||||||
key: "TEST_TOKEN",
|
|
||||||
token: "secret",
|
|
||||||
})
|
|
||||||
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, "well-known.json")).json())).toEqual({
|
|
||||||
"https://example.com": {
|
|
||||||
key: "TEST_TOKEN",
|
|
||||||
token: "secret",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("loads config documents", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const tmp = yield* Effect.acquireRelease(
|
|
||||||
Effect.promise(() => tmpdir()),
|
|
||||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
|
||||||
)
|
|
||||||
yield* Effect.promise(() =>
|
|
||||||
Bun.write(
|
|
||||||
path.join(tmp.path, "well-known.json"),
|
|
||||||
JSON.stringify({
|
|
||||||
"https://example.com": {
|
|
||||||
key: "TEST_TOKEN",
|
|
||||||
token: "secret",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const result = yield* withAuthWellKnown(
|
|
||||||
tmp.path,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const auth = yield* AuthWellKnown.Service
|
|
||||||
return yield* auth.configs()
|
|
||||||
}),
|
|
||||||
wellKnownConfigClient,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(result).toEqual([
|
|
||||||
{
|
|
||||||
url: "https://example.com",
|
|
||||||
source: "https://example.com/.well-known/opencode",
|
|
||||||
dir: "https://example.com/.well-known",
|
|
||||||
content: { instructions: ["local"] },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: "https://remote.example.com/config",
|
|
||||||
source: "https://remote.example.com/config",
|
|
||||||
dir: "https://remote.example.com",
|
|
||||||
content: { model: "remote/model" },
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import { execFile } from "node:child_process"
|
import { execFile, execFileSync } from "node:child_process"
|
||||||
import { access, readFile, readdir } from "node:fs/promises"
|
import { access, readFile, readdir } from "node:fs/promises"
|
||||||
import { dirname, extname, join } from "node:path"
|
import { dirname, extname, join } from "node:path"
|
||||||
import util from "node:util"
|
import util from "node:util"
|
||||||
import { resolveWslHome, runWslInDistro } from "./wsl"
|
|
||||||
|
|
||||||
const execFilePromise = util.promisify(execFile)
|
const execFilePromise = util.promisify(execFile)
|
||||||
|
|
||||||
@@ -22,44 +21,20 @@ export function resolveAppPath(appName: string) {
|
|||||||
return resolveWindowsAppPath(appName)
|
return resolveWindowsAppPath(appName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parses `\\wsl$\<distro>\...` and `\\wsl.localhost\<distro>\...` UNC paths that
|
export function wslPath(path: string, mode: "windows" | "linux" | null): string {
|
||||||
// point *into* a WSL distro's rootfs. `wslpath -u` cannot handle these reliably:
|
|
||||||
// backslashes get shell-collapsed when passed through `wsl.exe`, turning
|
|
||||||
// `\\wsl.localhost\Debian\home\luke` into `/mnt/c/wsl.localhostDebianhomeluke`,
|
|
||||||
// which is a valid-looking path that wedges opencode on DrvFs stat calls.
|
|
||||||
function parseWslUncPath(value: string): { distro: string; subpath: string } | null {
|
|
||||||
// Normalise separators; both `\\` and `//` prefixes mean UNC.
|
|
||||||
const normalised = value.replace(/\\/g, "/").replace(/^\/+/, "//")
|
|
||||||
const match = /^\/\/(wsl\$|wsl\.localhost)\/([^/]+)(?:\/(.*))?$/i.exec(normalised)
|
|
||||||
if (!match) return null
|
|
||||||
const distro = match[2]
|
|
||||||
const subpath = match[3] ?? ""
|
|
||||||
return { distro, subpath }
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function wslPath(path: string, mode: "windows" | "linux" | null, distro?: string | null): Promise<string> {
|
|
||||||
if (process.platform !== "win32") return path
|
if (process.platform !== "win32") return path
|
||||||
|
|
||||||
// `\\wsl$\<distro>\...` / `\\wsl.localhost\<distro>\...` -> `/<subpath>` in
|
|
||||||
// the target distro. Do the conversion in-process rather than shelling out
|
|
||||||
// to `wslpath -u`, which mangles backslashes via wsl.exe's command-line
|
|
||||||
// joiner. If the requested distro differs from the UNC distro, we still
|
|
||||||
// translate literally — callers are responsible for only picking paths
|
|
||||||
// inside the active distro.
|
|
||||||
if (mode === "linux") {
|
|
||||||
const unc = parseWslUncPath(path)
|
|
||||||
if (unc) return `/${unc.subpath}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const flag = mode === "windows" ? "-w" : "-u"
|
const flag = mode === "windows" ? "-w" : "-u"
|
||||||
try {
|
try {
|
||||||
const resolved = path.startsWith("~") ? `${await resolveWslHome(distro)}${path.slice(1)}` : path
|
if (path.startsWith("~")) {
|
||||||
const input = mode === "linux" ? resolved.replace(/\\/g, "/") : resolved
|
const suffix = path.slice(1)
|
||||||
const output = await runWslInDistro(["wslpath", flag, input], distro)
|
const cmd = `wslpath ${flag} "$HOME${suffix.replace(/"/g, '\\"')}"`
|
||||||
if (output.code !== 0) {
|
const output = execFileSync("wsl", ["-e", "sh", "-lc", cmd])
|
||||||
throw new Error(output.stderr || output.stdout || `wslpath exited with code ${output.code}`)
|
return output.toString().trim()
|
||||||
}
|
}
|
||||||
return output.stdout.trim()
|
|
||||||
|
const output = execFileSync("wsl", ["-e", "wslpath", flag, path])
|
||||||
|
return output.toString().trim()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`Failed to run wslpath: ${String(error)}`, { cause: error })
|
throw new Error(`Failed to run wslpath: ${String(error)}`, { cause: error })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ export const CHANNEL: Channel = raw === "dev" || raw === "beta" || raw === "prod
|
|||||||
|
|
||||||
export const SETTINGS_STORE = "opencode.settings"
|
export const SETTINGS_STORE = "opencode.settings"
|
||||||
export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
|
export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
|
||||||
export const WSL_SERVERS_KEY = "wslServers"
|
|
||||||
export const WSL_ENABLED_KEY = "wslEnabled"
|
export const WSL_ENABLED_KEY = "wslEnabled"
|
||||||
export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled"
|
export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled"
|
||||||
export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev"
|
export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev"
|
||||||
|
|||||||
@@ -9,10 +9,9 @@ import { getCACertificates, setDefaultCACertificates } from "node:tls"
|
|||||||
import type { Event } from "electron"
|
import type { Event } from "electron"
|
||||||
import { app, BrowserWindow } from "electron"
|
import { app, BrowserWindow } from "electron"
|
||||||
|
|
||||||
import { Deferred, Effect, Fiber } from "effect"
|
|
||||||
import contextMenu from "electron-context-menu"
|
import contextMenu from "electron-context-menu"
|
||||||
|
|
||||||
import type { InitStep, ServerReadyData, SqliteMigrationProgress } from "../preload/types"
|
import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types"
|
||||||
import { checkAppExists, resolveAppPath, wslPath } from "./apps"
|
import { checkAppExists, resolveAppPath, wslPath } from "./apps"
|
||||||
import { CHANNEL, UPDATER_ENABLED } from "./constants"
|
import { CHANNEL, UPDATER_ENABLED } from "./constants"
|
||||||
import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc"
|
import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc"
|
||||||
@@ -21,13 +20,13 @@ import { parseMarkdown } from "./markdown"
|
|||||||
import { createMenu } from "./menu"
|
import { createMenu } from "./menu"
|
||||||
import {
|
import {
|
||||||
getDefaultServerUrl,
|
getDefaultServerUrl,
|
||||||
|
getWslConfig,
|
||||||
preferAppEnv,
|
preferAppEnv,
|
||||||
setDefaultServerUrl,
|
setDefaultServerUrl,
|
||||||
|
setWslConfig,
|
||||||
spawnLocalServer,
|
spawnLocalServer,
|
||||||
spawnWslSidecar,
|
|
||||||
type SidecarListener,
|
type SidecarListener,
|
||||||
} from "./server"
|
} from "./server"
|
||||||
import { checkUpdate, checkForUpdates, installUpdate, setupAutoUpdater } from "./updater"
|
|
||||||
import {
|
import {
|
||||||
createLoadingWindow,
|
createLoadingWindow,
|
||||||
createMainWindow,
|
createMainWindow,
|
||||||
@@ -36,8 +35,9 @@ import {
|
|||||||
setBackgroundColor,
|
setBackgroundColor,
|
||||||
setDockIcon,
|
setDockIcon,
|
||||||
} from "./windows"
|
} from "./windows"
|
||||||
import { createWslServersController } from "./wsl-servers"
|
|
||||||
import { migrate } from "./migrate"
|
import { migrate } from "./migrate"
|
||||||
|
import { checkUpdate, checkForUpdates, installUpdate, setupAutoUpdater } from "./updater"
|
||||||
|
import { Deferred, Effect, Fiber } from "effect"
|
||||||
|
|
||||||
const APP_NAMES: Record<string, string> = {
|
const APP_NAMES: Record<string, string> = {
|
||||||
dev: "OpenCode Dev",
|
dev: "OpenCode Dev",
|
||||||
@@ -145,30 +145,6 @@ const main = Effect.gen(function* () {
|
|||||||
logger = initLogging()
|
logger = initLogging()
|
||||||
initCrashReporter()
|
initCrashReporter()
|
||||||
|
|
||||||
const wslServers = createWslServersController(
|
|
||||||
app.getVersion(),
|
|
||||||
async (distro) => {
|
|
||||||
logger.log("spawning wsl sidecar", { distro })
|
|
||||||
return spawnWslSidecar(distro, {
|
|
||||||
onLine: (line) => logger.log("wsl sidecar", { distro, stream: line.stream, text: line.text }),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
{
|
|
||||||
log: (message, meta) => logger.log(message, meta),
|
|
||||||
error: (message, meta) => logger.error(message, meta),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
const stopSidecars = async () => {
|
|
||||||
await killSidecar()
|
|
||||||
wslServers.stopAll()
|
|
||||||
}
|
|
||||||
const relaunch = () => {
|
|
||||||
void stopSidecars().finally(() => {
|
|
||||||
app.relaunch()
|
|
||||||
app.exit(0)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setDefaultCACertificates([...new Set([...getCACertificates("default"), ...getCACertificates("system")])])
|
setDefaultCACertificates([...new Set([...getCACertificates("default"), ...getCACertificates("system")])])
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -214,11 +190,11 @@ const main = Effect.gen(function* () {
|
|||||||
})
|
})
|
||||||
|
|
||||||
app.on("before-quit", () => {
|
app.on("before-quit", () => {
|
||||||
void stopSidecars()
|
void killSidecar()
|
||||||
})
|
})
|
||||||
|
|
||||||
app.on("will-quit", () => {
|
app.on("will-quit", () => {
|
||||||
void stopSidecars()
|
void killSidecar()
|
||||||
})
|
})
|
||||||
|
|
||||||
app.on("child-process-gone", (_event, details) => {
|
app.on("child-process-gone", (_event, details) => {
|
||||||
@@ -238,7 +214,7 @@ const main = Effect.gen(function* () {
|
|||||||
|
|
||||||
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
||||||
process.on(signal, () => {
|
process.on(signal, () => {
|
||||||
void stopSidecars().finally(() => app.exit(0))
|
void killSidecar().finally(() => app.exit(0))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,7 +223,6 @@ const main = Effect.gen(function* () {
|
|||||||
|
|
||||||
registerIpcHandlers({
|
registerIpcHandlers({
|
||||||
killSidecar: () => killSidecar(),
|
killSidecar: () => killSidecar(),
|
||||||
relaunch,
|
|
||||||
awaitInitialization: Effect.fnUntraced(
|
awaitInitialization: Effect.fnUntraced(
|
||||||
function* (sendStep) {
|
function* (sendStep) {
|
||||||
sendStep(initStep)
|
sendStep(initStep)
|
||||||
@@ -264,33 +239,22 @@ const main = Effect.gen(function* () {
|
|||||||
},
|
},
|
||||||
(e) => Effect.runPromise(e),
|
(e) => Effect.runPromise(e),
|
||||||
),
|
),
|
||||||
getWslServersState: () => wslServers.getState(),
|
|
||||||
onWslServersEvent: (listener) => wslServers.subscribe(listener),
|
|
||||||
wslServersProbeRuntime: () => wslServers.probeRuntime(),
|
|
||||||
wslServersRefreshDistros: () => wslServers.refreshDistros(),
|
|
||||||
wslServersInstallWsl: () => wslServers.installWsl(),
|
|
||||||
wslServersInstallDistro: (name) => wslServers.installDistro(name),
|
|
||||||
wslServersProbeDistro: (name) => wslServers.probeDistro(name),
|
|
||||||
wslServersProbeOpencode: (name) => wslServers.probeOpencode(name),
|
|
||||||
wslServersInstallOpencode: (name) => wslServers.installOpencode(name),
|
|
||||||
wslServersOpenTerminal: (name) => wslServers.openTerminal(name),
|
|
||||||
wslServersAddServer: (distro) => wslServers.addServer(distro),
|
|
||||||
wslServersRemoveServer: (id) => wslServers.removeServer(id),
|
|
||||||
wslServersStartServer: (id) => wslServers.startServer(id),
|
|
||||||
getWindowConfig: () => ({ updaterEnabled: UPDATER_ENABLED }),
|
getWindowConfig: () => ({ updaterEnabled: UPDATER_ENABLED }),
|
||||||
consumeInitialDeepLinks: () => pendingDeepLinks.splice(0),
|
consumeInitialDeepLinks: () => pendingDeepLinks.splice(0),
|
||||||
getDefaultServerUrl: () => getDefaultServerUrl(),
|
getDefaultServerUrl: () => getDefaultServerUrl(),
|
||||||
setDefaultServerUrl: (url) => setDefaultServerUrl(url),
|
setDefaultServerUrl: (url) => setDefaultServerUrl(url),
|
||||||
|
getWslConfig: () => Promise.resolve(getWslConfig()),
|
||||||
|
setWslConfig: (config: WslConfig) => setWslConfig(config),
|
||||||
getDisplayBackend: async () => null,
|
getDisplayBackend: async () => null,
|
||||||
setDisplayBackend: async () => undefined,
|
setDisplayBackend: async () => undefined,
|
||||||
parseMarkdown: async (markdown) => parseMarkdown(markdown),
|
parseMarkdown: async (markdown) => parseMarkdown(markdown),
|
||||||
checkAppExists: (appName) => checkAppExists(appName),
|
checkAppExists: (appName) => checkAppExists(appName),
|
||||||
wslPath: async (path, mode, distro) => wslPath(path, mode, distro),
|
wslPath: async (path, mode) => wslPath(path, mode),
|
||||||
resolveAppPath: async (appName) => resolveAppPath(appName),
|
resolveAppPath: async (appName) => resolveAppPath(appName),
|
||||||
loadingWindowComplete: () => Deferred.doneUnsafe(loadingComplete, Effect.void),
|
loadingWindowComplete: () => Deferred.doneUnsafe(loadingComplete, Effect.void),
|
||||||
runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail, stopSidecars),
|
runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail, killSidecar),
|
||||||
checkUpdate: async () => checkUpdate(),
|
checkUpdate: async () => checkUpdate(),
|
||||||
installUpdate: async () => installUpdate(stopSidecars),
|
installUpdate: async () => installUpdate(killSidecar),
|
||||||
setBackgroundColor: (color) => setBackgroundColor(color),
|
setBackgroundColor: (color) => setBackgroundColor(color),
|
||||||
exportDebugLogs: () => exportDebugLogs(),
|
exportDebugLogs: () => exportDebugLogs(),
|
||||||
recordFatalRendererError: (error) => writeLog("renderer", "fatal renderer error", { ...error }, "error"),
|
recordFatalRendererError: (error) => writeLog("renderer", "fatal renderer error", { ...error }, "error"),
|
||||||
@@ -377,10 +341,6 @@ const main = Effect.gen(function* () {
|
|||||||
password,
|
password,
|
||||||
})
|
})
|
||||||
|
|
||||||
void wslServers
|
|
||||||
.initialize({ defaultServer: getDefaultServerUrl() })
|
|
||||||
.catch((error) => logger.error("wsl server initialization failed", error))
|
|
||||||
|
|
||||||
yield* Effect.promise(() => health.wait).pipe(
|
yield* Effect.promise(() => health.wait).pipe(
|
||||||
Effect.timeout("30 seconds"),
|
Effect.timeout("30 seconds"),
|
||||||
Effect.catch((e) =>
|
Effect.catch((e) =>
|
||||||
@@ -419,7 +379,7 @@ const main = Effect.gen(function* () {
|
|||||||
if (win) sendMenuCommand(win, id)
|
if (win) sendMenuCommand(win, id)
|
||||||
},
|
},
|
||||||
checkForUpdates: () => {
|
checkForUpdates: () => {
|
||||||
void checkForUpdates(true, stopSidecars)
|
void checkForUpdates(true, killSidecar)
|
||||||
},
|
},
|
||||||
relaunch: () => {
|
relaunch: () => {
|
||||||
void killSidecar().finally(() => {
|
void killSidecar().finally(() => {
|
||||||
|
|||||||
@@ -10,9 +10,7 @@ import type {
|
|||||||
SqliteMigrationProgress,
|
SqliteMigrationProgress,
|
||||||
TitlebarTheme,
|
TitlebarTheme,
|
||||||
WindowConfig,
|
WindowConfig,
|
||||||
WslServerConfig,
|
WslConfig,
|
||||||
WslServersEvent,
|
|
||||||
WslServersState,
|
|
||||||
} from "../preload/types"
|
} from "../preload/types"
|
||||||
import { runDesktopMenuAction } from "./desktop-menu-actions"
|
import { runDesktopMenuAction } from "./desktop-menu-actions"
|
||||||
import { getStore } from "./store"
|
import { getStore } from "./store"
|
||||||
@@ -25,30 +23,18 @@ const pickerFilters = (ext?: string[]) => {
|
|||||||
|
|
||||||
type Deps = {
|
type Deps = {
|
||||||
killSidecar: () => Promise<void> | void
|
killSidecar: () => Promise<void> | void
|
||||||
relaunch: () => void
|
|
||||||
awaitInitialization: (sendStep: (step: InitStep) => void) => Promise<ServerReadyData>
|
awaitInitialization: (sendStep: (step: InitStep) => void) => Promise<ServerReadyData>
|
||||||
getWslServersState: () => Promise<WslServersState> | WslServersState
|
|
||||||
onWslServersEvent: (listener: (event: WslServersEvent) => void) => () => void
|
|
||||||
wslServersProbeRuntime: () => Promise<void> | void
|
|
||||||
wslServersRefreshDistros: () => Promise<void> | void
|
|
||||||
wslServersInstallWsl: () => Promise<void> | void
|
|
||||||
wslServersInstallDistro: (name: string) => Promise<void> | void
|
|
||||||
wslServersProbeDistro: (name: string) => Promise<void> | void
|
|
||||||
wslServersProbeOpencode: (name: string) => Promise<void> | void
|
|
||||||
wslServersInstallOpencode: (name: string) => Promise<void> | void
|
|
||||||
wslServersOpenTerminal: (name: string) => Promise<void> | void
|
|
||||||
wslServersAddServer: (distro: string) => Promise<WslServerConfig> | WslServerConfig
|
|
||||||
wslServersRemoveServer: (id: string) => Promise<void> | void
|
|
||||||
wslServersStartServer: (id: string) => Promise<void> | void
|
|
||||||
getWindowConfig: () => Promise<WindowConfig> | WindowConfig
|
getWindowConfig: () => Promise<WindowConfig> | WindowConfig
|
||||||
consumeInitialDeepLinks: () => Promise<string[]> | string[]
|
consumeInitialDeepLinks: () => Promise<string[]> | string[]
|
||||||
getDefaultServerUrl: () => Promise<string | null> | string | null
|
getDefaultServerUrl: () => Promise<string | null> | string | null
|
||||||
setDefaultServerUrl: (url: string | null) => Promise<void> | void
|
setDefaultServerUrl: (url: string | null) => Promise<void> | void
|
||||||
|
getWslConfig: () => Promise<WslConfig>
|
||||||
|
setWslConfig: (config: WslConfig) => Promise<void> | void
|
||||||
getDisplayBackend: () => Promise<string | null>
|
getDisplayBackend: () => Promise<string | null>
|
||||||
setDisplayBackend: (backend: string | null) => Promise<void> | void
|
setDisplayBackend: (backend: string | null) => Promise<void> | void
|
||||||
parseMarkdown: (markdown: string) => Promise<string> | string
|
parseMarkdown: (markdown: string) => Promise<string> | string
|
||||||
checkAppExists: (appName: string) => Promise<boolean> | boolean
|
checkAppExists: (appName: string) => Promise<boolean> | boolean
|
||||||
wslPath: (path: string, mode: "windows" | "linux" | null, distro?: string | null) => Promise<string>
|
wslPath: (path: string, mode: "windows" | "linux" | null) => Promise<string>
|
||||||
resolveAppPath: (appName: string) => Promise<string | null>
|
resolveAppPath: (appName: string) => Promise<string | null>
|
||||||
loadingWindowComplete: () => void
|
loadingWindowComplete: () => void
|
||||||
runUpdater: (alertOnFail: boolean) => Promise<void> | void
|
runUpdater: (alertOnFail: boolean) => Promise<void> | void
|
||||||
@@ -60,89 +46,27 @@ type Deps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function registerIpcHandlers(deps: Deps) {
|
export function registerIpcHandlers(deps: Deps) {
|
||||||
const requireString = (name: string, value: unknown) => {
|
|
||||||
if (typeof value === "string" && value.length > 0) return value
|
|
||||||
throw new Error(`Invalid ${name}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const wslSubscriptions = new Map<number, () => void>()
|
|
||||||
const unsubscribeWsl = (id: number) => {
|
|
||||||
const off = wslSubscriptions.get(id)
|
|
||||||
if (!off) return
|
|
||||||
off()
|
|
||||||
wslSubscriptions.delete(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
app.once("will-quit", () => {
|
|
||||||
for (const off of wslSubscriptions.values()) off()
|
|
||||||
wslSubscriptions.clear()
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle("kill-sidecar", () => deps.killSidecar())
|
ipcMain.handle("kill-sidecar", () => deps.killSidecar())
|
||||||
ipcMain.handle("await-initialization", (event: IpcMainInvokeEvent) => {
|
ipcMain.handle("await-initialization", (event: IpcMainInvokeEvent) => {
|
||||||
const send = (step: InitStep) => event.sender.send("init-step", step)
|
const send = (step: InitStep) => event.sender.send("init-step", step)
|
||||||
return deps.awaitInitialization(send)
|
return deps.awaitInitialization(send)
|
||||||
})
|
})
|
||||||
ipcMain.handle("wsl-servers-subscribe", (event) => {
|
|
||||||
const id = event.sender.id
|
|
||||||
if (wslSubscriptions.has(id)) return
|
|
||||||
wslSubscriptions.set(
|
|
||||||
id,
|
|
||||||
deps.onWslServersEvent((payload) => {
|
|
||||||
if (event.sender.isDestroyed()) {
|
|
||||||
unsubscribeWsl(id)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
event.sender.send("wsl-servers-event", payload)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
event.sender.once("destroyed", () => unsubscribeWsl(id))
|
|
||||||
})
|
|
||||||
ipcMain.handle("wsl-servers-unsubscribe", (event) => unsubscribeWsl(event.sender.id))
|
|
||||||
ipcMain.handle("wsl-servers-get-state", () => deps.getWslServersState())
|
|
||||||
ipcMain.handle("wsl-servers-probe-runtime", () => deps.wslServersProbeRuntime())
|
|
||||||
ipcMain.handle("wsl-servers-refresh-distros", () => deps.wslServersRefreshDistros())
|
|
||||||
ipcMain.handle("wsl-servers-install-wsl", () => deps.wslServersInstallWsl())
|
|
||||||
ipcMain.handle("wsl-servers-install-distro", (_event: IpcMainInvokeEvent, name: string) =>
|
|
||||||
deps.wslServersInstallDistro(requireString("distro", name)),
|
|
||||||
)
|
|
||||||
ipcMain.handle("wsl-servers-probe-distro", (_event: IpcMainInvokeEvent, name: string) =>
|
|
||||||
deps.wslServersProbeDistro(requireString("distro", name)),
|
|
||||||
)
|
|
||||||
ipcMain.handle("wsl-servers-probe-opencode", (_event: IpcMainInvokeEvent, name: string) =>
|
|
||||||
deps.wslServersProbeOpencode(requireString("distro", name)),
|
|
||||||
)
|
|
||||||
ipcMain.handle("wsl-servers-install-opencode", (_event: IpcMainInvokeEvent, name: string) =>
|
|
||||||
deps.wslServersInstallOpencode(requireString("distro", name)),
|
|
||||||
)
|
|
||||||
ipcMain.handle("wsl-servers-open-terminal", (_event: IpcMainInvokeEvent, name: string) =>
|
|
||||||
deps.wslServersOpenTerminal(requireString("distro", name)),
|
|
||||||
)
|
|
||||||
ipcMain.handle("wsl-servers-add", (_event: IpcMainInvokeEvent, distro: string) =>
|
|
||||||
deps.wslServersAddServer(requireString("distro", distro)),
|
|
||||||
)
|
|
||||||
ipcMain.handle("wsl-servers-remove", (_event: IpcMainInvokeEvent, id: string) =>
|
|
||||||
deps.wslServersRemoveServer(requireString("server id", id)),
|
|
||||||
)
|
|
||||||
ipcMain.handle("wsl-servers-start", (_event: IpcMainInvokeEvent, id: string) =>
|
|
||||||
deps.wslServersStartServer(requireString("server id", id)),
|
|
||||||
)
|
|
||||||
ipcMain.handle("get-window-config", () => deps.getWindowConfig())
|
ipcMain.handle("get-window-config", () => deps.getWindowConfig())
|
||||||
ipcMain.handle("consume-initial-deep-links", () => deps.consumeInitialDeepLinks())
|
ipcMain.handle("consume-initial-deep-links", () => deps.consumeInitialDeepLinks())
|
||||||
ipcMain.handle("get-default-server-url", () => deps.getDefaultServerUrl())
|
ipcMain.handle("get-default-server-url", () => deps.getDefaultServerUrl())
|
||||||
ipcMain.handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) =>
|
ipcMain.handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) =>
|
||||||
deps.setDefaultServerUrl(url),
|
deps.setDefaultServerUrl(url),
|
||||||
)
|
)
|
||||||
|
ipcMain.handle("get-wsl-config", () => deps.getWslConfig())
|
||||||
|
ipcMain.handle("set-wsl-config", (_event: IpcMainInvokeEvent, config: WslConfig) => deps.setWslConfig(config))
|
||||||
ipcMain.handle("get-display-backend", () => deps.getDisplayBackend())
|
ipcMain.handle("get-display-backend", () => deps.getDisplayBackend())
|
||||||
ipcMain.handle("set-display-backend", (_event: IpcMainInvokeEvent, backend: string | null) =>
|
ipcMain.handle("set-display-backend", (_event: IpcMainInvokeEvent, backend: string | null) =>
|
||||||
deps.setDisplayBackend(backend),
|
deps.setDisplayBackend(backend),
|
||||||
)
|
)
|
||||||
ipcMain.handle("parse-markdown", (_event: IpcMainInvokeEvent, markdown: string) => deps.parseMarkdown(markdown))
|
ipcMain.handle("parse-markdown", (_event: IpcMainInvokeEvent, markdown: string) => deps.parseMarkdown(markdown))
|
||||||
ipcMain.handle("check-app-exists", (_event: IpcMainInvokeEvent, appName: string) => deps.checkAppExists(appName))
|
ipcMain.handle("check-app-exists", (_event: IpcMainInvokeEvent, appName: string) => deps.checkAppExists(appName))
|
||||||
ipcMain.handle(
|
ipcMain.handle("wsl-path", (_event: IpcMainInvokeEvent, path: string, mode: "windows" | "linux" | null) =>
|
||||||
"wsl-path",
|
deps.wslPath(path, mode),
|
||||||
(_event: IpcMainInvokeEvent, path: string, mode: "windows" | "linux" | null, distro?: string | null) =>
|
|
||||||
deps.wslPath(path, mode, distro),
|
|
||||||
)
|
)
|
||||||
ipcMain.handle("resolve-app-path", (_event: IpcMainInvokeEvent, appName: string) => deps.resolveAppPath(appName))
|
ipcMain.handle("resolve-app-path", (_event: IpcMainInvokeEvent, appName: string) => deps.resolveAppPath(appName))
|
||||||
ipcMain.on("loading-window-complete", () => deps.loadingWindowComplete())
|
ipcMain.on("loading-window-complete", () => deps.loadingWindowComplete())
|
||||||
@@ -267,7 +191,8 @@ export function registerIpcHandlers(deps: Deps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.on("relaunch", () => {
|
ipcMain.on("relaunch", () => {
|
||||||
deps.relaunch()
|
app.relaunch()
|
||||||
|
app.exit(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle("get-zoom-factor", (event: IpcMainInvokeEvent) => event.sender.getZoomFactor())
|
ipcMain.handle("get-zoom-factor", (event: IpcMainInvokeEvent) => event.sender.getZoomFactor())
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import { spawn } from "node:child_process"
|
|
||||||
import { randomUUID } from "node:crypto"
|
|
||||||
import { createServer } from "node:net"
|
|
||||||
import { dirname, join } from "node:path"
|
import { dirname, join } from "node:path"
|
||||||
import { fileURLToPath } from "node:url"
|
import { fileURLToPath } from "node:url"
|
||||||
import { app, utilityProcess } from "electron"
|
import { app, utilityProcess } from "electron"
|
||||||
import type { Details } from "electron"
|
import type { Details } from "electron"
|
||||||
import type { SqliteMigrationProgress } from "../preload/types"
|
import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
|
||||||
import { DEFAULT_SERVER_URL_KEY } from "./constants"
|
|
||||||
import { getUserShell, loadShellEnv } from "./shell-env"
|
import { getUserShell, loadShellEnv } from "./shell-env"
|
||||||
import { getStore } from "./store"
|
import { getStore } from "./store"
|
||||||
import { type WslCommandLine, resolveWslOpencode, shellEscape, wslArgs } from "./wsl"
|
import type { SqliteMigrationProgress } from "../preload/types"
|
||||||
|
|
||||||
|
export type WslConfig = { enabled: boolean }
|
||||||
|
|
||||||
export type HealthCheck = { wait: Promise<void> }
|
export type HealthCheck = { wait: Promise<void> }
|
||||||
|
|
||||||
@@ -48,6 +46,15 @@ export function setDefaultServerUrl(url: string | null) {
|
|||||||
getStore().delete(DEFAULT_SERVER_URL_KEY)
|
getStore().delete(DEFAULT_SERVER_URL_KEY)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getWslConfig(): WslConfig {
|
||||||
|
const value = getStore().get(WSL_ENABLED_KEY)
|
||||||
|
return { enabled: typeof value === "boolean" ? value : false }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setWslConfig(config: WslConfig) {
|
||||||
|
getStore().set(WSL_ENABLED_KEY, config.enabled)
|
||||||
|
}
|
||||||
|
|
||||||
export function preferAppEnv(userDataPath: string) {
|
export function preferAppEnv(userDataPath: string) {
|
||||||
const shell = process.platform === "win32" ? null : getUserShell()
|
const shell = process.platform === "win32" ? null : getUserShell()
|
||||||
Object.assign(process.env, {
|
Object.assign(process.env, {
|
||||||
@@ -194,133 +201,6 @@ export async function spawnLocalServer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WslSidecar = {
|
|
||||||
listener: { stop: () => void; onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void }
|
|
||||||
url: string
|
|
||||||
username: string | null
|
|
||||||
password: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function spawnWslSidecar(
|
|
||||||
distro: string,
|
|
||||||
opts: { onLine?: (line: WslCommandLine) => void; healthTimeoutMs?: number } = {},
|
|
||||||
): Promise<WslSidecar> {
|
|
||||||
// Do not pass --user here: the sidecar should inherit the distro's
|
|
||||||
// default user so config, auth, git, ssh, and file ownership match the
|
|
||||||
// user's normal WSL environment. If that default user is root, WSL will
|
|
||||||
// choose root itself.
|
|
||||||
const opencode = await resolveWslOpencode(distro)
|
|
||||||
if (!opencode) throw new Error(`OpenCode is not installed in ${distro}`)
|
|
||||||
|
|
||||||
const port = await allocatePort()
|
|
||||||
const password = randomUUID()
|
|
||||||
const username = "opencode"
|
|
||||||
const logLevel = app.isPackaged ? "WARN" : "INFO"
|
|
||||||
|
|
||||||
const script = [
|
|
||||||
"set -euo pipefail",
|
|
||||||
// wsl.exe inherits the Windows-side cwd (e.g. C:\Users\Lukem) and maps it
|
|
||||||
// to the distro as /mnt/c/Users/Lukem — a DrvFs/9p path. opencode's
|
|
||||||
// instance middleware falls back to `process.cwd()` when a request
|
|
||||||
// arrives without a `directory=` query or `x-opencode-directory` header
|
|
||||||
// (see opencode server.ts InstanceMiddleware), and then calls
|
|
||||||
// `realpathSync(process.cwd())` synchronously on the main thread. A
|
|
||||||
// statx against a 9p path can wedge the whole event loop in kernel
|
|
||||||
// uninterruptible sleep, freezing the accept loop. Move cwd to the
|
|
||||||
// user's native Linux home so the fallback can't land on DrvFs.
|
|
||||||
'cd "$HOME" || cd /',
|
|
||||||
// wsl.exe by default splices the Windows %PATH% into the distro's $PATH
|
|
||||||
// via the interop layer (every `/mnt/c/Program Files/...` entry). Anything
|
|
||||||
// the sidecar spawns — PTY login shells, plugin helpers, etc. — then
|
|
||||||
// inherits it, which means `which pwsh.exe` resolves to the Windows
|
|
||||||
// PowerShell binary and bash-l profiles that end with
|
|
||||||
// eval "$(oh-my-posh init bash)" (or similar)
|
|
||||||
// silently run Windows pwsh for prompt rendering, whose banner
|
|
||||||
// ("Loading personal and system profiles took Xms.") then shows up in
|
|
||||||
// opencode's terminal pane. We want a clean, Linux-only environment in
|
|
||||||
// the sidecar, so filter every /mnt/* segment out of PATH and clear
|
|
||||||
// WSLENV so no further Windows vars leak in. Users who really need
|
|
||||||
// Windows binaries in the sidecar can invoke them by absolute path.
|
|
||||||
'PATH=$(awk -v RS=: -v ORS=: \'$0 !~ /^\\/mnt\\//\' <<<"$PATH" | sed "s/:$//")',
|
|
||||||
"export PATH",
|
|
||||||
"export WSLENV=",
|
|
||||||
// WSL sidecars often target /mnt/* worktrees. Keep the desktop-only
|
|
||||||
// watcher/discovery features off there because DrvFs/9p stalls can wedge
|
|
||||||
// the server process after it starts listening.
|
|
||||||
"export OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER=true",
|
|
||||||
"export OPENCODE_CLIENT=desktop",
|
|
||||||
`export OPENCODE_SERVER_USERNAME=${shellEscape(username)}`,
|
|
||||||
`export OPENCODE_SERVER_PASSWORD=${shellEscape(password)}`,
|
|
||||||
'export XDG_STATE_HOME="$HOME/.local/state"',
|
|
||||||
`exec ${shellEscape(opencode)} --print-logs --log-level ${logLevel} serve --hostname 0.0.0.0 --port ${port}`,
|
|
||||||
].join("\n")
|
|
||||||
|
|
||||||
const child = spawn("wsl", wslArgs(["bash", "-se"], distro), {
|
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
|
||||||
windowsHide: true,
|
|
||||||
})
|
|
||||||
child.stdin.end(script)
|
|
||||||
|
|
||||||
let settled = false
|
|
||||||
const recentOutput: string[] = []
|
|
||||||
const emit = (line: WslCommandLine) => {
|
|
||||||
if (settled || !line.text.trim()) return
|
|
||||||
recentOutput.push(`[${line.stream}] ${line.text}`)
|
|
||||||
if (recentOutput.length > 12) recentOutput.shift()
|
|
||||||
opts.onLine?.(line)
|
|
||||||
}
|
|
||||||
|
|
||||||
forwardLines(child.stdout, "stdout", emit)
|
|
||||||
forwardLines(child.stderr, "stderr", emit)
|
|
||||||
|
|
||||||
const exit = new Promise<never>((_, reject) => {
|
|
||||||
child.once("error", reject)
|
|
||||||
child.once("exit", (code, signal) => {
|
|
||||||
reject(new Error(startupFailure(code, signal, recentOutput)))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const url = `http://127.0.0.1:${port}`
|
|
||||||
const healthPromise = (async () => {
|
|
||||||
while (true) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
||||||
if (await checkHealth(url, password)) return
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
const timeoutMs = opts.healthTimeoutMs ?? 30_000
|
|
||||||
const timeout = new Promise<never>((_, reject) => {
|
|
||||||
const id = setTimeout(
|
|
||||||
() => reject(new Error(`Sidecar for ${distro} health check timed out after ${timeoutMs}ms`)),
|
|
||||||
timeoutMs,
|
|
||||||
)
|
|
||||||
void healthPromise.finally(() => clearTimeout(id))
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
await Promise.race([healthPromise, exit, timeout])
|
|
||||||
} catch (error) {
|
|
||||||
child.kill()
|
|
||||||
throw error
|
|
||||||
} finally {
|
|
||||||
settled = true
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
listener: {
|
|
||||||
stop() {
|
|
||||||
child.kill()
|
|
||||||
},
|
|
||||||
onExit(cb) {
|
|
||||||
child.once("exit", cb)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
url,
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
|
export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
|
||||||
let healthUrl: URL
|
let healthUrl: URL
|
||||||
try {
|
try {
|
||||||
@@ -347,46 +227,6 @@ export async function checkHealth(url: string, password?: string | null): Promis
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function allocatePort() {
|
|
||||||
return new Promise<number>((resolve, reject) => {
|
|
||||||
const server = createServer()
|
|
||||||
server.on("error", reject)
|
|
||||||
server.listen(0, "127.0.0.1", () => {
|
|
||||||
const address = server.address()
|
|
||||||
if (typeof address !== "object" || !address) {
|
|
||||||
server.close()
|
|
||||||
reject(new Error("Failed to get port"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const port = address.port
|
|
||||||
server.close(() => resolve(port))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function forwardLines(
|
|
||||||
stream: NodeJS.ReadableStream,
|
|
||||||
source: WslCommandLine["stream"],
|
|
||||||
onLine: (line: WslCommandLine) => void,
|
|
||||||
) {
|
|
||||||
let pending = ""
|
|
||||||
stream.setEncoding("utf8")
|
|
||||||
stream.on("data", (chunk: string) => {
|
|
||||||
pending += chunk
|
|
||||||
const lines = pending.split(/\r?\n/g)
|
|
||||||
pending = lines.pop() ?? ""
|
|
||||||
for (const line of lines) onLine({ stream: source, text: line })
|
|
||||||
})
|
|
||||||
stream.on("end", () => {
|
|
||||||
if (pending) onLine({ stream: source, text: pending })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function startupFailure(code: number | null, signal: NodeJS.Signals | null, recentOutput: string[]) {
|
|
||||||
const suffix = recentOutput.length ? `\n${recentOutput.join("\n")}` : ""
|
|
||||||
return `WSL server exited before becoming healthy (code=${code ?? "null"} signal=${signal ?? "null"})${suffix}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function createSidecarEnv(): Record<string, string> {
|
function createSidecarEnv(): Record<string, string> {
|
||||||
const env = Object.fromEntries(
|
const env = Object.fromEntries(
|
||||||
Object.entries(process.env).flatMap(([key, value]) => (value === undefined ? [] : [[key, String(value)]])),
|
Object.entries(process.env).flatMap(([key, value]) => (value === undefined ? [] : [[key, String(value)]])),
|
||||||
|
|||||||
@@ -1,447 +0,0 @@
|
|||||||
import type {
|
|
||||||
WslDistroProbe,
|
|
||||||
WslInstalledDistro,
|
|
||||||
WslJob,
|
|
||||||
WslOnlineDistro,
|
|
||||||
WslOpencodeCheck,
|
|
||||||
WslRuntimeCheck,
|
|
||||||
WslServerConfig,
|
|
||||||
WslServerItem,
|
|
||||||
WslServerRuntime,
|
|
||||||
WslServersEvent,
|
|
||||||
WslServersState,
|
|
||||||
} from "../preload/types"
|
|
||||||
import { WSL_SERVERS_KEY } from "./constants"
|
|
||||||
import { getStore } from "./store"
|
|
||||||
import {
|
|
||||||
installWslDistro,
|
|
||||||
installWslOpencode,
|
|
||||||
installWslRuntimeElevated,
|
|
||||||
listInstalledWslDistros,
|
|
||||||
listOnlineWslDistros,
|
|
||||||
openWslTerminal,
|
|
||||||
probeWslDistro,
|
|
||||||
probeWslRuntime,
|
|
||||||
readWslCommandVersion,
|
|
||||||
resolveWslOpencode,
|
|
||||||
summarize,
|
|
||||||
upgradeWslOpencode,
|
|
||||||
wslNeedsRestart,
|
|
||||||
} from "./wsl"
|
|
||||||
|
|
||||||
type RunningSidecar = {
|
|
||||||
listener: { stop: () => void; onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void }
|
|
||||||
url: string
|
|
||||||
username: string | null
|
|
||||||
password: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type SpawnSidecar = (distro: string) => Promise<RunningSidecar>
|
|
||||||
|
|
||||||
type ControllerLogger = {
|
|
||||||
log: (message: string, meta?: unknown) => void
|
|
||||||
error: (message: string, meta?: unknown) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WslServersController = ReturnType<typeof createWslServersController>
|
|
||||||
|
|
||||||
export function wslServerIdForDistro(distro: string) {
|
|
||||||
return `wsl:${distro}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createWslServersController(appVersion: string, spawnSidecar: SpawnSidecar, logger?: ControllerLogger) {
|
|
||||||
let state: WslServersState = initialState()
|
|
||||||
const listeners = new Set<(event: WslServersEvent) => void>()
|
|
||||||
const sidecars = new Map<string, RunningSidecar>()
|
|
||||||
const startAttempts = new Map<string, number>()
|
|
||||||
let jobAbort: AbortController | undefined
|
|
||||||
|
|
||||||
const emit = () => {
|
|
||||||
for (const listener of listeners) listener({ type: "state", state })
|
|
||||||
}
|
|
||||||
|
|
||||||
const setState = (next: Partial<WslServersState>) => {
|
|
||||||
state = { ...state, ...next }
|
|
||||||
emit()
|
|
||||||
}
|
|
||||||
|
|
||||||
const persistServers = (servers: WslServerConfig[]) => {
|
|
||||||
getStore().set(WSL_SERVERS_KEY, { servers })
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateServer = (id: string, update: (item: WslServerItem) => WslServerItem) => {
|
|
||||||
const next = state.servers.map((item) => (item.config.id === id ? update(item) : item))
|
|
||||||
setState({ servers: next })
|
|
||||||
}
|
|
||||||
|
|
||||||
const beginJob = (job: WslJob): AbortController => {
|
|
||||||
jobAbort?.abort()
|
|
||||||
const abort = new AbortController()
|
|
||||||
jobAbort = abort
|
|
||||||
setState({ job })
|
|
||||||
return abort
|
|
||||||
}
|
|
||||||
|
|
||||||
const endJob = (abort: AbortController) => {
|
|
||||||
if (jobAbort !== abort) return
|
|
||||||
jobAbort = undefined
|
|
||||||
setState({ job: null })
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshFromStore = () => {
|
|
||||||
const persisted = readPersistedServers()
|
|
||||||
const items: WslServerItem[] = persisted.map((config) => {
|
|
||||||
const existing = state.servers.find((item) => item.config.id === config.id)
|
|
||||||
return {
|
|
||||||
config,
|
|
||||||
runtime: existing?.runtime ?? { kind: "stopped" },
|
|
||||||
}
|
|
||||||
})
|
|
||||||
setState({ servers: items })
|
|
||||||
}
|
|
||||||
|
|
||||||
const setRuntime = (id: string, runtime: WslServerRuntime) => {
|
|
||||||
updateServer(id, (item) => ({ ...item, runtime }))
|
|
||||||
}
|
|
||||||
|
|
||||||
const setOpencodeCheck = (distro: string, check: WslOpencodeCheck) => {
|
|
||||||
setState({
|
|
||||||
opencodeChecks: {
|
|
||||||
...state.opencodeChecks,
|
|
||||||
[distro]: check,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshOpencodeCheck = async (distro: string, opts?: { signal?: AbortSignal }) => {
|
|
||||||
const resolved = await resolveWslOpencode(distro, opts)
|
|
||||||
const version = resolved ? await readWslCommandVersion(resolved, distro, opts) : null
|
|
||||||
setOpencodeCheck(distro, opencodeCheck(distro, resolved, version, appVersion))
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshDistroLists = async (opts: { signal?: AbortSignal }) => {
|
|
||||||
const [installed, online] = await Promise.all([
|
|
||||||
listInstalledWslDistros(opts),
|
|
||||||
listOnlineWslDistros(opts),
|
|
||||||
])
|
|
||||||
return { installed, online }
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextStartAttempt = (id: string) => {
|
|
||||||
const next = (startAttempts.get(id) ?? 0) + 1
|
|
||||||
startAttempts.set(id, next)
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
const invalidateStartAttempt = (id: string) => {
|
|
||||||
startAttempts.set(id, (startAttempts.get(id) ?? 0) + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const isCurrentStartAttempt = (id: string, attempt: number) => {
|
|
||||||
return startAttempts.get(id) === attempt && state.servers.some((item) => item.config.id === id)
|
|
||||||
}
|
|
||||||
|
|
||||||
const startServer = async (id: string) => {
|
|
||||||
const item = state.servers.find((x) => x.config.id === id)
|
|
||||||
if (!item) return
|
|
||||||
const attempt = nextStartAttempt(id)
|
|
||||||
await stopServerInternal(id)
|
|
||||||
if (!isCurrentStartAttempt(id, attempt)) return
|
|
||||||
setRuntime(id, { kind: "starting" })
|
|
||||||
logger?.log("wsl sidecar starting", { id, distro: item.config.distro })
|
|
||||||
try {
|
|
||||||
const sidecar = await spawnSidecar(item.config.distro)
|
|
||||||
if (!isCurrentStartAttempt(id, attempt)) {
|
|
||||||
try {
|
|
||||||
sidecar.listener.stop()
|
|
||||||
} catch {
|
|
||||||
// ignore stop errors for stale sidecars
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sidecars.set(id, sidecar)
|
|
||||||
setRuntime(id, {
|
|
||||||
kind: "ready",
|
|
||||||
url: sidecar.url,
|
|
||||||
username: sidecar.username,
|
|
||||||
password: sidecar.password,
|
|
||||||
})
|
|
||||||
sidecar.listener.onExit((code, signal) => {
|
|
||||||
if (sidecars.get(id) !== sidecar) return
|
|
||||||
sidecars.delete(id)
|
|
||||||
const message = startupFailure(code, signal)
|
|
||||||
setRuntime(id, { kind: "failed", message })
|
|
||||||
logger?.error("wsl sidecar exited", { id, distro: item.config.distro, code, signal })
|
|
||||||
})
|
|
||||||
void refreshOpencodeCheck(item.config.distro).catch((error) => {
|
|
||||||
const message = error instanceof Error ? error.message : String(error)
|
|
||||||
logger?.error("wsl opencode check failed", { id, distro: item.config.distro, message })
|
|
||||||
})
|
|
||||||
logger?.log("wsl sidecar ready", { id, distro: item.config.distro, url: sidecar.url })
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error)
|
|
||||||
if (!isCurrentStartAttempt(id, attempt)) return
|
|
||||||
setRuntime(id, { kind: "failed", message })
|
|
||||||
// Without this, an Ubuntu-style silent failure leaves no trace in
|
|
||||||
// main.log — the controller captures the message in its state but
|
|
||||||
// nothing surfaces unless the user opens the WSL servers dialog.
|
|
||||||
logger?.error("wsl sidecar failed to start", { id, distro: item.config.distro, message })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const stopServerInternal = async (id: string) => {
|
|
||||||
const existing = sidecars.get(id)
|
|
||||||
if (!existing) return
|
|
||||||
sidecars.delete(id)
|
|
||||||
try {
|
|
||||||
existing.listener.stop()
|
|
||||||
} catch {
|
|
||||||
// ignore stop errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const runJob = async <T>(job: WslJob, runner: (abort: AbortController) => Promise<T>) => {
|
|
||||||
const abort = beginJob(job)
|
|
||||||
try {
|
|
||||||
const value = await runner(abort)
|
|
||||||
endJob(abort)
|
|
||||||
return value
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error && error.name === "AbortError") {
|
|
||||||
endJob(abort)
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
const err = error instanceof Error ? error : new Error(String(error))
|
|
||||||
endJob(abort)
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
getState() {
|
|
||||||
return state
|
|
||||||
},
|
|
||||||
subscribe(listener: (event: WslServersEvent) => void) {
|
|
||||||
listeners.add(listener)
|
|
||||||
return () => listeners.delete(listener)
|
|
||||||
},
|
|
||||||
|
|
||||||
async initialize(opts?: { defaultServer?: string | null }) {
|
|
||||||
refreshFromStore()
|
|
||||||
if (opts?.defaultServer?.startsWith("wsl:")) void startServer(opts.defaultServer)
|
|
||||||
},
|
|
||||||
|
|
||||||
async probeRuntime() {
|
|
||||||
await runJob({ kind: "runtime", startedAt: Date.now() }, async (abort) => {
|
|
||||||
const runtime = await probeWslRuntime({ signal: abort.signal })
|
|
||||||
setState({
|
|
||||||
runtime,
|
|
||||||
pendingRestart: state.pendingRestart && !runtime.available ? state.pendingRestart : false,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
async refreshDistros() {
|
|
||||||
await runJob({ kind: "distros", startedAt: Date.now() }, async (abort) => {
|
|
||||||
setState(await refreshDistroLists({ signal: abort.signal }))
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
async installWsl() {
|
|
||||||
await runJob({ kind: "install-wsl", startedAt: Date.now() }, async (abort) => {
|
|
||||||
const result = await installWslRuntimeElevated({ signal: abort.signal })
|
|
||||||
if (result.code !== 0) {
|
|
||||||
const message = summarize(result.stderr || result.stdout) || "WSL installation failed"
|
|
||||||
throw new Error(message)
|
|
||||||
}
|
|
||||||
const pendingRestart = wslNeedsRestart(result)
|
|
||||||
setState({ pendingRestart })
|
|
||||||
if (!pendingRestart) {
|
|
||||||
const runtime = await probeWslRuntime({ signal: abort.signal })
|
|
||||||
setState({ runtime })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
async installDistro(name: string) {
|
|
||||||
await runJob({ kind: "install-distro", distro: name, startedAt: Date.now() }, async (abort) => {
|
|
||||||
const result = await installWslDistro(name, { signal: abort.signal })
|
|
||||||
if (result.code !== 0) {
|
|
||||||
const message = summarize(result.stderr || result.stdout) || `Failed to install distro: ${name}`
|
|
||||||
throw new Error(message)
|
|
||||||
}
|
|
||||||
const distros = await refreshDistroLists({ signal: abort.signal })
|
|
||||||
const probe = await probeWslDistro(name, { signal: abort.signal })
|
|
||||||
setState({
|
|
||||||
...distros,
|
|
||||||
distroProbes: { ...state.distroProbes, [name]: probe },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
async probeDistro(name: string) {
|
|
||||||
await runJob({ kind: "probe-distro", distro: name, startedAt: Date.now() }, async (abort) => {
|
|
||||||
const probe = await probeWslDistro(name, { signal: abort.signal })
|
|
||||||
setState({ distroProbes: { ...state.distroProbes, [name]: probe } })
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
async probeOpencode(name: string) {
|
|
||||||
await runJob({ kind: "probe-opencode", distro: name, startedAt: Date.now() }, async (abort) => {
|
|
||||||
await refreshOpencodeCheck(name, { signal: abort.signal })
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
async installOpencode(name: string) {
|
|
||||||
await runJob({ kind: "install-opencode", distro: name, startedAt: Date.now() }, async (abort) => {
|
|
||||||
const resolved = await resolveWslOpencode(name, { signal: abort.signal })
|
|
||||||
const existingVersion = resolved
|
|
||||||
? await readWslCommandVersion(resolved, name, { signal: abort.signal })
|
|
||||||
: null
|
|
||||||
const result =
|
|
||||||
resolved && existingVersion
|
|
||||||
? await upgradeWslOpencode(appVersion, resolved, name, { signal: abort.signal })
|
|
||||||
: await installWslOpencode(appVersion, name, { signal: abort.signal })
|
|
||||||
if (result.code !== 0) {
|
|
||||||
throw new Error(summarize(result.stderr || result.stdout) || "OpenCode installation failed")
|
|
||||||
}
|
|
||||||
await refreshOpencodeCheck(name, { signal: abort.signal })
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
async openTerminal(name: string) {
|
|
||||||
await openWslTerminal(name)
|
|
||||||
},
|
|
||||||
|
|
||||||
async addServer(distro: string): Promise<WslServerConfig> {
|
|
||||||
const id = wslServerIdForDistro(distro)
|
|
||||||
if (state.servers.some((item) => item.config.id === id)) {
|
|
||||||
throw new Error(`${distro} is already added`)
|
|
||||||
}
|
|
||||||
const config: WslServerConfig = {
|
|
||||||
id,
|
|
||||||
distro,
|
|
||||||
}
|
|
||||||
persistServers([...readPersistedServers(), config])
|
|
||||||
setState({
|
|
||||||
servers: [...state.servers, { config, runtime: { kind: "starting" } }],
|
|
||||||
})
|
|
||||||
void startServer(id)
|
|
||||||
return config
|
|
||||||
},
|
|
||||||
|
|
||||||
async removeServer(id: string) {
|
|
||||||
invalidateStartAttempt(id)
|
|
||||||
await stopServerInternal(id)
|
|
||||||
const remaining = readPersistedServers().filter((item) => item.id !== id)
|
|
||||||
persistServers(remaining)
|
|
||||||
setState({ servers: state.servers.filter((item) => item.config.id !== id) })
|
|
||||||
},
|
|
||||||
|
|
||||||
startServer,
|
|
||||||
|
|
||||||
stopAll() {
|
|
||||||
for (const item of state.servers) invalidateStartAttempt(item.config.id)
|
|
||||||
for (const existing of sidecars.values()) {
|
|
||||||
try {
|
|
||||||
existing.listener.stop()
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sidecars.clear()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function initialState(): WslServersState {
|
|
||||||
return {
|
|
||||||
runtime: null,
|
|
||||||
installed: [],
|
|
||||||
online: [],
|
|
||||||
distroProbes: {},
|
|
||||||
opencodeChecks: {},
|
|
||||||
pendingRestart: false,
|
|
||||||
servers: [],
|
|
||||||
job: null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function readPersistedServers(): WslServerConfig[] {
|
|
||||||
const store = getStore()
|
|
||||||
const existing = store.get(WSL_SERVERS_KEY)
|
|
||||||
if (existing && typeof existing === "object") {
|
|
||||||
const record = existing as { servers?: unknown }
|
|
||||||
const list = Array.isArray(record.servers) ? record.servers : []
|
|
||||||
return list.flatMap(normalizePersistedServer)
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizePersistedServer(value: unknown): WslServerConfig[] {
|
|
||||||
if (!value || typeof value !== "object") return []
|
|
||||||
const record = value as Record<string, unknown>
|
|
||||||
const distro = typeof record.distro === "string" && record.distro.length > 0 ? record.distro : null
|
|
||||||
if (!distro) return []
|
|
||||||
const id = typeof record.id === "string" && record.id.length > 0 ? record.id : wslServerIdForDistro(distro)
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id,
|
|
||||||
distro,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
function opencodeCheck(
|
|
||||||
distro: string,
|
|
||||||
resolvedPath: string | null,
|
|
||||||
version: string | null,
|
|
||||||
expectedVersion: string,
|
|
||||||
): WslOpencodeCheck {
|
|
||||||
if (!resolvedPath) {
|
|
||||||
return {
|
|
||||||
distro,
|
|
||||||
resolvedPath: null,
|
|
||||||
version: null,
|
|
||||||
expectedVersion,
|
|
||||||
matchesDesktop: null,
|
|
||||||
error: "opencode is not installed in this distro",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!version) {
|
|
||||||
return {
|
|
||||||
distro,
|
|
||||||
resolvedPath,
|
|
||||||
version: null,
|
|
||||||
expectedVersion,
|
|
||||||
matchesDesktop: null,
|
|
||||||
error: "opencode is installed but could not run",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
distro,
|
|
||||||
resolvedPath,
|
|
||||||
version,
|
|
||||||
expectedVersion,
|
|
||||||
matchesDesktop: version === expectedVersion,
|
|
||||||
error: null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startupFailure(code: number | null, signal: NodeJS.Signals | null) {
|
|
||||||
return `WSL server exited after startup (code=${code ?? "null"} signal=${signal ?? "null"})`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-export types used by callers
|
|
||||||
export type {
|
|
||||||
WslInstalledDistro,
|
|
||||||
WslOnlineDistro,
|
|
||||||
WslRuntimeCheck,
|
|
||||||
WslDistroProbe,
|
|
||||||
WslOpencodeCheck,
|
|
||||||
WslServerConfig,
|
|
||||||
WslServerItem,
|
|
||||||
WslServerRuntime,
|
|
||||||
WslServersEvent,
|
|
||||||
WslServersState,
|
|
||||||
}
|
|
||||||
@@ -1,422 +0,0 @@
|
|||||||
import { spawn } from "node:child_process"
|
|
||||||
import { existsSync } from "node:fs"
|
|
||||||
import { join } from "node:path"
|
|
||||||
/** @ts-expect-error */
|
|
||||||
import * as pty from "@lydell/node-pty"
|
|
||||||
import type { WslDistroProbe, WslInstalledDistro, WslOnlineDistro, WslRuntimeCheck } from "../preload/types"
|
|
||||||
|
|
||||||
export type WslCommandLine = {
|
|
||||||
stream: "stdout" | "stderr"
|
|
||||||
text: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WslCommandResult = {
|
|
||||||
code: number | null
|
|
||||||
signal: NodeJS.Signals | null
|
|
||||||
stdout: string
|
|
||||||
stderr: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RunWslOptions = {
|
|
||||||
signal?: AbortSignal
|
|
||||||
/**
|
|
||||||
* Ceiling on how long we wait for the child process to exit. When the
|
|
||||||
* LXSS service or a specific distro wedges (e.g. Ubuntu-24.04 with a
|
|
||||||
* pending first-run prompt), `wsl.exe` never returns and any command
|
|
||||||
* that doesn't specify a timeout hangs the entire startup flow. Default
|
|
||||||
* is 20s — enough for slow cold-starts, short enough to fail fast on
|
|
||||||
* a wedge. Callers can override for longer-running jobs.
|
|
||||||
*/
|
|
||||||
timeoutMs?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_WSL_TIMEOUT_MS = 20_000
|
|
||||||
const DEFAULT_WSL_INSTALL_TIMEOUT_MS = 15 * 60_000
|
|
||||||
|
|
||||||
export function wslArgs(args: string[], distro?: string | null, user?: string | null) {
|
|
||||||
return [...(distro ? ["-d", distro] : []), ...(user ? ["--user", user] : []), "--", ...args]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function runWsl(args: string[], opts: RunWslOptions = {}) {
|
|
||||||
return runCommand("wsl", args, opts)
|
|
||||||
}
|
|
||||||
|
|
||||||
function runPowerShell(command: string, opts: RunWslOptions = {}) {
|
|
||||||
return runCommand(
|
|
||||||
"powershell.exe",
|
|
||||||
["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", command],
|
|
||||||
opts,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function runCommand(command: string, args: string[], opts: RunWslOptions = {}) {
|
|
||||||
return new Promise<WslCommandResult>((resolve, reject) => {
|
|
||||||
const child = spawn(command, args, {
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
|
||||||
windowsHide: true,
|
|
||||||
signal: opts.signal,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Guard every wsl.exe invocation with a timeout. When the distro or
|
|
||||||
// the LXSS service is wedged (Ubuntu first-run state, Windows update
|
|
||||||
// pending, etc.) wsl.exe produces no output and never exits; without
|
|
||||||
// this the whole sidecar spawn flow stalls the app forever.
|
|
||||||
const timeoutMs = opts.timeoutMs ?? DEFAULT_WSL_TIMEOUT_MS
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
try {
|
|
||||||
child.kill()
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
reject(new Error(`${command} ${args.join(" ")} timed out after ${timeoutMs}ms`))
|
|
||||||
}, timeoutMs)
|
|
||||||
|
|
||||||
let stdout = ""
|
|
||||||
let stderr = ""
|
|
||||||
const stdoutDecoder = createOutputDecoder()
|
|
||||||
const stderrDecoder = createOutputDecoder()
|
|
||||||
|
|
||||||
const append = (stream: WslCommandLine["stream"], chunk: string) => {
|
|
||||||
if (!chunk) return
|
|
||||||
if (stream === "stdout") {
|
|
||||||
stdout += chunk
|
|
||||||
return
|
|
||||||
}
|
|
||||||
stderr += chunk
|
|
||||||
}
|
|
||||||
|
|
||||||
child.stdout.on("data", (chunk: Buffer) => {
|
|
||||||
append("stdout", stdoutDecoder.decode(chunk))
|
|
||||||
})
|
|
||||||
child.stdout.on("end", () => {
|
|
||||||
append("stdout", stdoutDecoder.flush())
|
|
||||||
})
|
|
||||||
|
|
||||||
child.stderr.on("data", (chunk: Buffer) => {
|
|
||||||
append("stderr", stderrDecoder.decode(chunk))
|
|
||||||
})
|
|
||||||
child.stderr.on("end", () => {
|
|
||||||
append("stderr", stderrDecoder.flush())
|
|
||||||
})
|
|
||||||
|
|
||||||
child.once("error", (error) => {
|
|
||||||
clearTimeout(timeoutId)
|
|
||||||
reject(error)
|
|
||||||
})
|
|
||||||
child.once("close", (code, signal) => {
|
|
||||||
clearTimeout(timeoutId)
|
|
||||||
resolve({ code, signal, stdout, stderr })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function runInteractiveCommand(command: string, args: string[], opts: RunWslOptions = {}, defaultTimeoutMs: number) {
|
|
||||||
return new Promise<WslCommandResult>((resolve, reject) => {
|
|
||||||
const child = pty.spawn(command, args, {
|
|
||||||
name: "xterm-color",
|
|
||||||
cols: 80,
|
|
||||||
rows: 24,
|
|
||||||
cwd: process.cwd(),
|
|
||||||
env: process.env,
|
|
||||||
useConpty: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
let settled = false
|
|
||||||
let stdout = ""
|
|
||||||
|
|
||||||
const cleanup = () => {
|
|
||||||
clearTimeout(timeoutId)
|
|
||||||
abortCleanup?.()
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeoutMs = opts.timeoutMs ?? defaultTimeoutMs
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
try {
|
|
||||||
child.kill()
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
if (settled) return
|
|
||||||
settled = true
|
|
||||||
cleanup()
|
|
||||||
reject(new Error(`${command} ${args.join(" ")} timed out after ${timeoutMs}ms`))
|
|
||||||
}, timeoutMs)
|
|
||||||
|
|
||||||
const abortHandler = () => {
|
|
||||||
try {
|
|
||||||
child.kill()
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
if (settled) return
|
|
||||||
settled = true
|
|
||||||
cleanup()
|
|
||||||
reject(new DOMException("Aborted", "AbortError"))
|
|
||||||
}
|
|
||||||
const abortCleanup = opts.signal
|
|
||||||
? (() => {
|
|
||||||
opts.signal?.addEventListener("abort", abortHandler, { once: true })
|
|
||||||
return () => opts.signal?.removeEventListener("abort", abortHandler)
|
|
||||||
})()
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
child.onData((data: string) => {
|
|
||||||
stdout += data
|
|
||||||
})
|
|
||||||
child.onExit((event: { exitCode: number }) => {
|
|
||||||
if (settled) return
|
|
||||||
settled = true
|
|
||||||
cleanup()
|
|
||||||
resolve({ code: event.exitCode, signal: null, stdout, stderr: "" })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function createOutputDecoder() {
|
|
||||||
let decoder: TextDecoder | undefined
|
|
||||||
return {
|
|
||||||
decode(chunk: Buffer) {
|
|
||||||
decoder ??= new TextDecoder(detectOutputEncoding(chunk))
|
|
||||||
return decoder.decode(chunk, { stream: true })
|
|
||||||
},
|
|
||||||
flush() {
|
|
||||||
return decoder?.decode() ?? ""
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function detectOutputEncoding(chunk: Uint8Array) {
|
|
||||||
if (chunk[0] === 0xff && chunk[1] === 0xfe) return "utf-16le"
|
|
||||||
const pairs = Math.floor(chunk.length / 2)
|
|
||||||
if (pairs < 2) return "utf-8"
|
|
||||||
const oddZeroes = Array.from({ length: pairs }).filter((_, index) => chunk[index * 2 + 1] === 0).length
|
|
||||||
const evenZeroes = Array.from({ length: pairs }).filter((_, index) => chunk[index * 2] === 0).length
|
|
||||||
return oddZeroes >= Math.ceil(pairs / 3) && evenZeroes * 2 <= oddZeroes ? "utf-16le" : "utf-8"
|
|
||||||
}
|
|
||||||
|
|
||||||
export function runWslInDistro(args: string[], distro?: string | null, opts?: RunWslOptions) {
|
|
||||||
return runWsl(wslArgs(args, distro), opts)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function runWslSh(script: string, distro?: string | null, opts?: RunWslOptions) {
|
|
||||||
return runWslInDistro(["sh", "-lc", script], distro, opts)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function probeWslRuntime(opts?: RunWslOptions): Promise<WslRuntimeCheck> {
|
|
||||||
const version = await runWsl(["--version"], opts).catch((error) => ({
|
|
||||||
code: 1,
|
|
||||||
signal: null,
|
|
||||||
stdout: "",
|
|
||||||
stderr: error instanceof Error ? error.message : String(error),
|
|
||||||
}))
|
|
||||||
|
|
||||||
if (version.code !== 0) {
|
|
||||||
return {
|
|
||||||
available: false,
|
|
||||||
version: null,
|
|
||||||
error: summarize(version.stderr || version.stdout) || "WSL is unavailable",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
available: true,
|
|
||||||
version: firstLine(version.stdout),
|
|
||||||
error: null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listInstalledWslDistros(opts?: RunWslOptions) {
|
|
||||||
const result = await runWsl(["--list", "--verbose"], opts)
|
|
||||||
if (result.code !== 0) {
|
|
||||||
throw new Error(summarize(result.stderr || result.stdout) || "Failed to list installed WSL distros")
|
|
||||||
}
|
|
||||||
return parseInstalledDistros(result.stdout)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listOnlineWslDistros(opts?: RunWslOptions) {
|
|
||||||
const result = await runWsl(["--list", "--online"], opts)
|
|
||||||
if (result.code !== 0) {
|
|
||||||
throw new Error(summarize(result.stderr || result.stdout) || "Failed to list online WSL distros")
|
|
||||||
}
|
|
||||||
return parseOnlineDistros(result.stdout)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function installWslRuntimeElevated(opts?: RunWslOptions) {
|
|
||||||
const script = [
|
|
||||||
"$ErrorActionPreference = 'Stop'",
|
|
||||||
"$process = Start-Process -FilePath 'wsl.exe' -Verb RunAs -ArgumentList @('--install','--no-distribution') -Wait -PassThru",
|
|
||||||
"if ($null -ne $process.ExitCode) { exit $process.ExitCode }",
|
|
||||||
].join("; ")
|
|
||||||
return runPowerShell(script, withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function installWslDistro(name: string, opts?: RunWslOptions) {
|
|
||||||
return runInteractiveCommand(
|
|
||||||
resolveSystem32Command("wsl.exe"),
|
|
||||||
["--install", "-d", name, "--web-download", "--no-launch"],
|
|
||||||
withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS),
|
|
||||||
DEFAULT_WSL_INSTALL_TIMEOUT_MS,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function installWslOpencode(version: string, distro: string, opts?: RunWslOptions) {
|
|
||||||
return runInteractiveCommand(
|
|
||||||
resolveSystem32Command("wsl.exe"),
|
|
||||||
wslArgs(["bash", "-lc", `curl -fsSL https://opencode.ai/install | bash -s -- --version ${shellEscape(version)}`], distro),
|
|
||||||
withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS),
|
|
||||||
DEFAULT_WSL_INSTALL_TIMEOUT_MS,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function wslNeedsRestart(result: WslCommandResult) {
|
|
||||||
return /restart|reboot/i.test(`${result.stdout}\n${result.stderr}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function probeWslDistro(name: string, opts?: RunWslOptions): Promise<WslDistroProbe> {
|
|
||||||
const executable = await runWslInDistro(["/bin/true"], name, opts).catch((error) => ({
|
|
||||||
code: 1,
|
|
||||||
signal: null,
|
|
||||||
stdout: "",
|
|
||||||
stderr: error instanceof Error ? error.message : String(error),
|
|
||||||
}))
|
|
||||||
if (executable.code !== 0) {
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
canExecute: false,
|
|
||||||
hasBash: false,
|
|
||||||
hasCurl: false,
|
|
||||||
error: summarize(executable.stderr || executable.stdout) || "Cannot execute commands in distro",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [bash, curl] = await Promise.all([
|
|
||||||
runWslSh("command -v bash >/dev/null && printf yes || printf no", name, opts),
|
|
||||||
runWslSh("command -v curl >/dev/null && printf yes || printf no", name, opts),
|
|
||||||
])
|
|
||||||
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
canExecute: true,
|
|
||||||
hasBash: bash.code === 0 && summarize(bash.stdout) === "yes",
|
|
||||||
hasCurl: curl.code === 0 && summarize(curl.stdout) === "yes",
|
|
||||||
error: null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resolveWslHome(distro?: string | null, opts?: RunWslOptions) {
|
|
||||||
return firstLine((await runWslSh('printf "%s\\n" "$HOME"', distro, opts)).stdout) ?? "/"
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resolveWslOpencode(distro: string, opts?: RunWslOptions) {
|
|
||||||
const command = firstLine((await runWslSh("command -v opencode 2>/dev/null | grep -v '^/mnt/' | head -n 1 || true", distro, opts)).stdout)
|
|
||||||
if (command) return command
|
|
||||||
|
|
||||||
for (const candidate of [
|
|
||||||
'if [ -x "${XDG_BIN_DIR:-$HOME/.local/bin}/opencode" ]; then printf "%s\\n" "${XDG_BIN_DIR:-$HOME/.local/bin}/opencode"; fi',
|
|
||||||
'if [ -x "$HOME/bin/opencode" ]; then printf "%s\\n" "$HOME/bin/opencode"; fi',
|
|
||||||
'if [ -x "$HOME/.opencode/bin/opencode" ]; then printf "%s\\n" "$HOME/.opencode/bin/opencode"; fi',
|
|
||||||
'if [ -x "/usr/local/bin/opencode" ]; then printf "%s\\n" "/usr/local/bin/opencode"; fi',
|
|
||||||
]) {
|
|
||||||
const resolved = firstLine((await runWslSh(candidate, distro, opts)).stdout)
|
|
||||||
if (resolved) return resolved
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function readWslCommandVersion(command: string, distro: string, opts?: RunWslOptions) {
|
|
||||||
const result = await runWslSh(`${shellEscape(command)} --version 2>/dev/null || true`, distro, opts)
|
|
||||||
return firstLine(result.stdout)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function upgradeWslOpencode(target: string, command: string, distro: string, opts?: RunWslOptions) {
|
|
||||||
return runInteractiveCommand(
|
|
||||||
resolveSystem32Command("wsl.exe"),
|
|
||||||
wslArgs(["bash", "-lc", `${shellEscape(command)} upgrade ${shellEscape(target)}`], distro, "root"),
|
|
||||||
withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS),
|
|
||||||
DEFAULT_WSL_INSTALL_TIMEOUT_MS,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function openWslTerminal(distro?: string | null) {
|
|
||||||
if (distro && !/^[a-zA-Z0-9_.-]+$/.test(distro)) {
|
|
||||||
return Promise.reject(new Error("Invalid distro name"))
|
|
||||||
}
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
|
||||||
const child = spawn("cmd.exe", ["/c", "start", "", "wsl", ...(distro ? ["-d", distro] : [])], {
|
|
||||||
detached: true,
|
|
||||||
stdio: "ignore",
|
|
||||||
windowsHide: true,
|
|
||||||
})
|
|
||||||
child.once("error", reject)
|
|
||||||
child.once("spawn", () => {
|
|
||||||
child.unref()
|
|
||||||
resolve()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseInstalledDistros(output: string) {
|
|
||||||
return output.split(/\r?\n/g).flatMap((line) => {
|
|
||||||
const trimmed = line.trim()
|
|
||||||
if (!trimmed) return []
|
|
||||||
const match = line.match(/^\s*(\*)?\s*(.*?)\s{2,}\S+\s+(\d+)\s*$/)
|
|
||||||
if (!match) return []
|
|
||||||
const [, marker, name, version] = match
|
|
||||||
if (!name || /^name$/i.test(name)) return []
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
name: name.trim(),
|
|
||||||
version: Number.isNaN(Number.parseInt(version, 10)) ? null : Number.parseInt(version, 10),
|
|
||||||
isDefault: marker === "*",
|
|
||||||
} satisfies WslInstalledDistro,
|
|
||||||
]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseOnlineDistros(output: string) {
|
|
||||||
return output.split(/\r?\n/g).flatMap((line) => {
|
|
||||||
const trimmed = line.trim()
|
|
||||||
if (!trimmed) return []
|
|
||||||
const match = trimmed.match(/^([A-Za-z0-9._-]+)\s{2,}(.+)$/)
|
|
||||||
if (!match) return []
|
|
||||||
const [, name, label] = match
|
|
||||||
if (/^name$/i.test(name)) return []
|
|
||||||
return [{ name, label: label.trim() } satisfies WslOnlineDistro]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function firstLine(value: string) {
|
|
||||||
return (
|
|
||||||
value
|
|
||||||
.split(/\r?\n/g)
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.find(Boolean) ?? null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function summarize(value: string) {
|
|
||||||
return value
|
|
||||||
.split(/\r?\n/g)
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.join("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shellEscape(value: string) {
|
|
||||||
return `'${value.replace(/'/g, `'"'"'`)}'`
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveSystem32Command(command: string) {
|
|
||||||
const root = process.env.SystemRoot ?? process.env.windir
|
|
||||||
if (!root) return command
|
|
||||||
const resolved = join(root, "System32", command)
|
|
||||||
return existsSync(resolved) ? resolved : command
|
|
||||||
}
|
|
||||||
|
|
||||||
function withTimeout(opts: RunWslOptions | undefined, timeoutMs: number): RunWslOptions {
|
|
||||||
return {
|
|
||||||
...opts,
|
|
||||||
timeoutMs: opts?.timeoutMs ?? timeoutMs,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { contextBridge, ipcRenderer } from "electron"
|
import { contextBridge, ipcRenderer } from "electron"
|
||||||
import type { ElectronAPI, InitStep, SqliteMigrationProgress, WslServersEvent } from "./types"
|
import type { ElectronAPI, InitStep, SqliteMigrationProgress } from "./types"
|
||||||
|
|
||||||
const api: ElectronAPI = {
|
const api: ElectronAPI = {
|
||||||
killSidecar: () => ipcRenderer.invoke("kill-sidecar"),
|
killSidecar: () => ipcRenderer.invoke("kill-sidecar"),
|
||||||
@@ -11,38 +11,17 @@ const api: ElectronAPI = {
|
|||||||
ipcRenderer.removeListener("init-step", handler)
|
ipcRenderer.removeListener("init-step", handler)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
wslServers: {
|
|
||||||
getState: () => ipcRenderer.invoke("wsl-servers-get-state"),
|
|
||||||
subscribe: (cb) => {
|
|
||||||
const handler = (_: unknown, event: WslServersEvent) => cb(event)
|
|
||||||
ipcRenderer.on("wsl-servers-event", handler)
|
|
||||||
void ipcRenderer.invoke("wsl-servers-subscribe")
|
|
||||||
return () => {
|
|
||||||
ipcRenderer.removeListener("wsl-servers-event", handler)
|
|
||||||
void ipcRenderer.invoke("wsl-servers-unsubscribe")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
probeRuntime: () => ipcRenderer.invoke("wsl-servers-probe-runtime"),
|
|
||||||
refreshDistros: () => ipcRenderer.invoke("wsl-servers-refresh-distros"),
|
|
||||||
installWsl: () => ipcRenderer.invoke("wsl-servers-install-wsl"),
|
|
||||||
installDistro: (name) => ipcRenderer.invoke("wsl-servers-install-distro", name),
|
|
||||||
probeDistro: (name) => ipcRenderer.invoke("wsl-servers-probe-distro", name),
|
|
||||||
probeOpencode: (name) => ipcRenderer.invoke("wsl-servers-probe-opencode", name),
|
|
||||||
installOpencode: (name) => ipcRenderer.invoke("wsl-servers-install-opencode", name),
|
|
||||||
openTerminal: (name) => ipcRenderer.invoke("wsl-servers-open-terminal", name),
|
|
||||||
addServer: (distro) => ipcRenderer.invoke("wsl-servers-add", distro),
|
|
||||||
removeServer: (id) => ipcRenderer.invoke("wsl-servers-remove", id),
|
|
||||||
startServer: (id) => ipcRenderer.invoke("wsl-servers-start", id),
|
|
||||||
},
|
|
||||||
getWindowConfig: () => ipcRenderer.invoke("get-window-config"),
|
getWindowConfig: () => ipcRenderer.invoke("get-window-config"),
|
||||||
consumeInitialDeepLinks: () => ipcRenderer.invoke("consume-initial-deep-links"),
|
consumeInitialDeepLinks: () => ipcRenderer.invoke("consume-initial-deep-links"),
|
||||||
getDefaultServerUrl: () => ipcRenderer.invoke("get-default-server-url"),
|
getDefaultServerUrl: () => ipcRenderer.invoke("get-default-server-url"),
|
||||||
setDefaultServerUrl: (url) => ipcRenderer.invoke("set-default-server-url", url),
|
setDefaultServerUrl: (url) => ipcRenderer.invoke("set-default-server-url", url),
|
||||||
|
getWslConfig: () => ipcRenderer.invoke("get-wsl-config"),
|
||||||
|
setWslConfig: (config) => ipcRenderer.invoke("set-wsl-config", config),
|
||||||
getDisplayBackend: () => ipcRenderer.invoke("get-display-backend"),
|
getDisplayBackend: () => ipcRenderer.invoke("get-display-backend"),
|
||||||
setDisplayBackend: (backend) => ipcRenderer.invoke("set-display-backend", backend),
|
setDisplayBackend: (backend) => ipcRenderer.invoke("set-display-backend", backend),
|
||||||
parseMarkdownCommand: (markdown) => ipcRenderer.invoke("parse-markdown", markdown),
|
parseMarkdownCommand: (markdown) => ipcRenderer.invoke("parse-markdown", markdown),
|
||||||
checkAppExists: (appName) => ipcRenderer.invoke("check-app-exists", appName),
|
checkAppExists: (appName) => ipcRenderer.invoke("check-app-exists", appName),
|
||||||
wslPath: (path, mode, distro) => ipcRenderer.invoke("wsl-path", path, mode, distro),
|
wslPath: (path, mode) => ipcRenderer.invoke("wsl-path", path, mode),
|
||||||
resolveAppPath: (appName) => ipcRenderer.invoke("resolve-app-path", appName),
|
resolveAppPath: (appName) => ipcRenderer.invoke("resolve-app-path", appName),
|
||||||
storeGet: (name, key) => ipcRenderer.invoke("store-get", name, key),
|
storeGet: (name, key) => ipcRenderer.invoke("store-get", name, key),
|
||||||
storeSet: (name, key, value) => ipcRenderer.invoke("store-set", name, key, value),
|
storeSet: (name, key, value) => ipcRenderer.invoke("store-set", name, key, value),
|
||||||
|
|||||||
@@ -10,87 +10,7 @@ export type ServerReadyData = {
|
|||||||
|
|
||||||
export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" }
|
export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" }
|
||||||
|
|
||||||
export type WslRuntimeCheck = {
|
export type WslConfig = { enabled: boolean }
|
||||||
available: boolean
|
|
||||||
version: string | null
|
|
||||||
error: string | null
|
|
||||||
}
|
|
||||||
export type WslInstalledDistro = {
|
|
||||||
name: string
|
|
||||||
version: number | null
|
|
||||||
isDefault: boolean
|
|
||||||
}
|
|
||||||
export type WslOnlineDistro = {
|
|
||||||
name: string
|
|
||||||
label: string
|
|
||||||
}
|
|
||||||
export type WslDistroProbe = {
|
|
||||||
name: string
|
|
||||||
canExecute: boolean
|
|
||||||
hasBash: boolean
|
|
||||||
hasCurl: boolean
|
|
||||||
error: string | null
|
|
||||||
}
|
|
||||||
export type WslOpencodeCheck = {
|
|
||||||
distro: string
|
|
||||||
resolvedPath: string | null
|
|
||||||
version: string | null
|
|
||||||
expectedVersion: string | null
|
|
||||||
matchesDesktop: boolean | null
|
|
||||||
error: string | null
|
|
||||||
}
|
|
||||||
export type WslServerConfig = {
|
|
||||||
id: string
|
|
||||||
distro: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WslServerRuntime =
|
|
||||||
| { kind: "starting" }
|
|
||||||
| { kind: "ready"; url: string; username: string | null; password: string | null }
|
|
||||||
| { kind: "failed"; message: string }
|
|
||||||
| { kind: "stopped" }
|
|
||||||
|
|
||||||
export type WslServerItem = {
|
|
||||||
config: WslServerConfig
|
|
||||||
runtime: WslServerRuntime
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WslJob =
|
|
||||||
| { kind: "runtime"; startedAt: number }
|
|
||||||
| { kind: "distros"; startedAt: number }
|
|
||||||
| { kind: "install-wsl"; startedAt: number }
|
|
||||||
| { kind: "install-distro"; distro: string; startedAt: number }
|
|
||||||
| { kind: "probe-distro"; distro: string; startedAt: number }
|
|
||||||
| { kind: "probe-opencode"; distro: string; startedAt: number }
|
|
||||||
| { kind: "install-opencode"; distro: string; startedAt: number }
|
|
||||||
|
|
||||||
export type WslServersState = {
|
|
||||||
runtime: WslRuntimeCheck | null
|
|
||||||
installed: WslInstalledDistro[]
|
|
||||||
online: WslOnlineDistro[]
|
|
||||||
distroProbes: Record<string, WslDistroProbe>
|
|
||||||
opencodeChecks: Record<string, WslOpencodeCheck>
|
|
||||||
pendingRestart: boolean
|
|
||||||
servers: WslServerItem[]
|
|
||||||
job: WslJob | null
|
|
||||||
}
|
|
||||||
export type WslServersEvent = { type: "state"; state: WslServersState }
|
|
||||||
|
|
||||||
export type WslServersAPI = {
|
|
||||||
getState: () => Promise<WslServersState>
|
|
||||||
subscribe: (cb: (event: WslServersEvent) => void) => () => void
|
|
||||||
probeRuntime: () => Promise<void>
|
|
||||||
refreshDistros: () => Promise<void>
|
|
||||||
installWsl: () => Promise<void>
|
|
||||||
installDistro: (name: string) => Promise<void>
|
|
||||||
probeDistro: (name: string) => Promise<void>
|
|
||||||
probeOpencode: (name: string) => Promise<void>
|
|
||||||
installOpencode: (name: string) => Promise<void>
|
|
||||||
openTerminal: (name: string) => Promise<void>
|
|
||||||
addServer: (distro: string) => Promise<WslServerConfig>
|
|
||||||
removeServer: (id: string) => Promise<void>
|
|
||||||
startServer: (id: string) => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
export type LinuxDisplayBackend = "wayland" | "auto"
|
export type LinuxDisplayBackend = "wayland" | "auto"
|
||||||
export type TitlebarTheme = {
|
export type TitlebarTheme = {
|
||||||
@@ -112,16 +32,17 @@ export type ElectronAPI = {
|
|||||||
killSidecar: () => Promise<void>
|
killSidecar: () => Promise<void>
|
||||||
installCli: () => Promise<string>
|
installCli: () => Promise<string>
|
||||||
awaitInitialization: (onStep: (step: InitStep) => void) => Promise<ServerReadyData>
|
awaitInitialization: (onStep: (step: InitStep) => void) => Promise<ServerReadyData>
|
||||||
wslServers: WslServersAPI
|
|
||||||
getWindowConfig: () => Promise<WindowConfig>
|
getWindowConfig: () => Promise<WindowConfig>
|
||||||
consumeInitialDeepLinks: () => Promise<string[]>
|
consumeInitialDeepLinks: () => Promise<string[]>
|
||||||
getDefaultServerUrl: () => Promise<string | null>
|
getDefaultServerUrl: () => Promise<string | null>
|
||||||
setDefaultServerUrl: (url: string | null) => Promise<void>
|
setDefaultServerUrl: (url: string | null) => Promise<void>
|
||||||
|
getWslConfig: () => Promise<WslConfig>
|
||||||
|
setWslConfig: (config: WslConfig) => Promise<void>
|
||||||
getDisplayBackend: () => Promise<LinuxDisplayBackend | null>
|
getDisplayBackend: () => Promise<LinuxDisplayBackend | null>
|
||||||
setDisplayBackend: (backend: LinuxDisplayBackend | null) => Promise<void>
|
setDisplayBackend: (backend: LinuxDisplayBackend | null) => Promise<void>
|
||||||
parseMarkdownCommand: (markdown: string) => Promise<string>
|
parseMarkdownCommand: (markdown: string) => Promise<string>
|
||||||
checkAppExists: (appName: string) => Promise<boolean>
|
checkAppExists: (appName: string) => Promise<boolean>
|
||||||
wslPath: (path: string, mode: "windows" | "linux" | null, distro?: string | null) => Promise<string>
|
wslPath: (path: string, mode: "windows" | "linux" | null) => Promise<string>
|
||||||
resolveAppPath: (appName: string) => Promise<string | null>
|
resolveAppPath: (appName: string) => Promise<string | null>
|
||||||
storeGet: (name: string, key: string) => Promise<string | null>
|
storeGet: (name: string, key: string) => Promise<string | null>
|
||||||
storeSet: (name: string, key: string, value: string) => Promise<void>
|
storeSet: (name: string, key: string, value: string) => Promise<void>
|
||||||
|
|||||||
-1
@@ -5,7 +5,6 @@ declare global {
|
|||||||
api: ElectronAPI
|
api: ElectronAPI
|
||||||
__OPENCODE__?: {
|
__OPENCODE__?: {
|
||||||
deepLinks?: string[]
|
deepLinks?: string[]
|
||||||
activeServer?: string
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,18 +13,16 @@ import {
|
|||||||
PlatformProvider,
|
PlatformProvider,
|
||||||
ServerConnection,
|
ServerConnection,
|
||||||
useCommand,
|
useCommand,
|
||||||
useWslServers,
|
|
||||||
} from "@opencode-ai/app"
|
} from "@opencode-ai/app"
|
||||||
import * as Sentry from "@sentry/solid"
|
import * as Sentry from "@sentry/solid"
|
||||||
import type { AsyncStorage } from "@solid-primitives/storage"
|
import type { AsyncStorage } from "@solid-primitives/storage"
|
||||||
import { MemoryRouter } from "@solidjs/router"
|
import { MemoryRouter } from "@solidjs/router"
|
||||||
import { createEffect, createMemo, createResource, onCleanup, onMount, Show } from "solid-js"
|
import { createEffect, createResource, onCleanup, onMount, Show } from "solid-js"
|
||||||
import { render } from "solid-js/web"
|
import { render } from "solid-js/web"
|
||||||
import pkg from "../../package.json"
|
import pkg from "../../package.json"
|
||||||
import { initI18n, t } from "./i18n"
|
import { initI18n, t } from "./i18n"
|
||||||
import { resetZoom, setPinchZoomEnabled, webviewZoom, zoomIn, zoomOut } from "./webview-zoom"
|
import { resetZoom, setPinchZoomEnabled, webviewZoom, zoomIn, zoomOut } from "./webview-zoom"
|
||||||
import "./styles.css"
|
import "./styles.css"
|
||||||
import { Splash } from "@opencode-ai/ui/logo"
|
|
||||||
import { useTheme } from "@opencode-ai/ui/theme"
|
import { useTheme } from "@opencode-ai/ui/theme"
|
||||||
|
|
||||||
const root = document.getElementById("root")
|
const root = document.getElementById("root")
|
||||||
@@ -81,26 +79,25 @@ const createPlatform = (): Platform => {
|
|||||||
return undefined
|
return undefined
|
||||||
})()
|
})()
|
||||||
|
|
||||||
const activeWslDistro = () => {
|
const isWslEnabled = async () => {
|
||||||
const key = window.__OPENCODE__?.activeServer
|
if (os !== "windows") return false
|
||||||
if (!key || !key.startsWith("wsl:")) return undefined
|
return window.api
|
||||||
return key.slice("wsl:".length)
|
.getWslConfig()
|
||||||
|
.then((config) => config.enabled)
|
||||||
|
.catch(() => false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const wslHome = async () => {
|
const wslHome = async () => {
|
||||||
const distro = activeWslDistro()
|
if (!(await isWslEnabled())) return undefined
|
||||||
if (!distro) return undefined
|
return window.api.wslPath("~", "windows").catch(() => undefined)
|
||||||
return window.api.wslPath("~", "windows", distro)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleWslPicker = async <T extends string | string[] | null>(result: T): Promise<T> => {
|
const handleWslPicker = async <T extends string | string[]>(result: T | null): Promise<T | null> => {
|
||||||
const distro = activeWslDistro()
|
if (!result || !(await isWslEnabled())) return result
|
||||||
if (!result || !distro) return result
|
|
||||||
const convert = (path: string) => window.api.wslPath(path, "linux", distro)
|
|
||||||
if (Array.isArray(result)) {
|
if (Array.isArray(result)) {
|
||||||
return (await Promise.all(result.map(convert))) as T
|
return Promise.all(result.map((path) => window.api.wslPath(path, "linux").catch(() => path))) as any
|
||||||
}
|
}
|
||||||
return (await convert(result)) as T
|
return window.api.wslPath(result, "linux").catch(() => result) as any
|
||||||
}
|
}
|
||||||
|
|
||||||
const runDesktopMenuAction: Platform["runDesktopMenuAction"] = (action) => {
|
const runDesktopMenuAction: Platform["runDesktopMenuAction"] = (action) => {
|
||||||
@@ -146,8 +143,6 @@ const createPlatform = (): Platform => {
|
|||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
const wslServersApi = os === "windows" ? window.api.wslServers : undefined
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
platform: "desktop",
|
platform: "desktop",
|
||||||
os,
|
os,
|
||||||
@@ -188,8 +183,10 @@ const createPlatform = (): Platform => {
|
|||||||
if (os === "windows") {
|
if (os === "windows") {
|
||||||
const resolvedApp = app ? await window.api.resolveAppPath(app).catch(() => null) : null
|
const resolvedApp = app ? await window.api.resolveAppPath(app).catch(() => null) : null
|
||||||
const resolvedPath = await (async () => {
|
const resolvedPath = await (async () => {
|
||||||
const distro = activeWslDistro()
|
if (await isWslEnabled()) {
|
||||||
if (distro) return window.api.wslPath(path, "windows", distro)
|
const converted = await window.api.wslPath(path, "windows").catch(() => null)
|
||||||
|
if (converted) return converted
|
||||||
|
}
|
||||||
return path
|
return path
|
||||||
})()
|
})()
|
||||||
return window.api.openPath(resolvedPath, resolvedApp ?? undefined)
|
return window.api.openPath(resolvedPath, resolvedApp ?? undefined)
|
||||||
@@ -244,7 +241,16 @@ const createPlatform = (): Platform => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
fetch,
|
fetch: (input, init) => {
|
||||||
|
if (input instanceof Request) return fetch(input)
|
||||||
|
return fetch(input, init)
|
||||||
|
},
|
||||||
|
|
||||||
|
getWslEnabled: () => isWslEnabled(),
|
||||||
|
|
||||||
|
setWslEnabled: async (enabled) => {
|
||||||
|
await window.api.setWslConfig({ enabled })
|
||||||
|
},
|
||||||
|
|
||||||
getDefaultServer: async () => {
|
getDefaultServer: async () => {
|
||||||
const url = await window.api.getDefaultServerUrl().catch(() => null)
|
const url = await window.api.getDefaultServerUrl().catch(() => null)
|
||||||
@@ -256,8 +262,6 @@ const createPlatform = (): Platform => {
|
|||||||
await window.api.setDefaultServerUrl(url)
|
await window.api.setDefaultServerUrl(url)
|
||||||
},
|
},
|
||||||
|
|
||||||
wslServers: wslServersApi,
|
|
||||||
|
|
||||||
getDisplayBackend: async () => {
|
getDisplayBackend: async () => {
|
||||||
return window.api.getDisplayBackend().catch(() => null)
|
return window.api.getDisplayBackend().catch(() => null)
|
||||||
},
|
},
|
||||||
@@ -299,6 +303,7 @@ listenForDeepLinks()
|
|||||||
|
|
||||||
render(() => {
|
render(() => {
|
||||||
const platform = createPlatform()
|
const platform = createPlatform()
|
||||||
|
const [windowConfig] = createResource(() => window.api.getWindowConfig().catch(() => ({ updaterEnabled: false })))
|
||||||
const loadLocale = async () => {
|
const loadLocale = async () => {
|
||||||
const current = await platform.storage?.("opencode.global.dat").getItem("language")
|
const current = await platform.storage?.("opencode.global.dat").getItem("language")
|
||||||
const legacy = current ? undefined : await platform.storage?.().getItem("language.v1")
|
const legacy = current ? undefined : await platform.storage?.().getItem("language.v1")
|
||||||
@@ -313,11 +318,32 @@ render(() => {
|
|||||||
|
|
||||||
const [windowCount] = createResource(() => window.api.getWindowCount())
|
const [windowCount] = createResource(() => window.api.getWindowCount())
|
||||||
|
|
||||||
|
// Fetch sidecar credentials (available immediately, before health check)
|
||||||
const [sidecar] = createResource(() => window.api.awaitInitialization(() => undefined))
|
const [sidecar] = createResource(() => window.api.awaitInitialization(() => undefined))
|
||||||
|
|
||||||
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)
|
const [locale] = createResource(loadLocale)
|
||||||
|
|
||||||
|
const servers = () => {
|
||||||
|
const data = sidecar()
|
||||||
|
if (!data) return []
|
||||||
|
const server: ServerConnection.Sidecar = {
|
||||||
|
displayName: "Local Server",
|
||||||
|
type: "sidecar",
|
||||||
|
variant: "base",
|
||||||
|
http: {
|
||||||
|
url: data.url,
|
||||||
|
username: data.username ?? undefined,
|
||||||
|
password: data.password ?? undefined,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return [server] as ServerConnection.Any[]
|
||||||
|
}
|
||||||
|
|
||||||
function handleClick(e: MouseEvent) {
|
function handleClick(e: MouseEvent) {
|
||||||
const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
|
const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
|
||||||
if (link?.href) {
|
if (link?.href) {
|
||||||
@@ -344,73 +370,6 @@ render(() => {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
|
||||||
const wslServers = useWslServers()
|
|
||||||
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 ready = createMemo(
|
|
||||||
() =>
|
|
||||||
!defaultServer.loading &&
|
|
||||||
!sidecar.loading &&
|
|
||||||
!windowCount.loading &&
|
|
||||||
!locale.loading,
|
|
||||||
)
|
|
||||||
const servers = createMemo(() => {
|
|
||||||
const data = sidecar()
|
|
||||||
const list: ServerConnection.Any[] = []
|
|
||||||
if (data) {
|
|
||||||
list.push({
|
|
||||||
displayName: "Local Server",
|
|
||||||
type: "sidecar",
|
|
||||||
variant: "base",
|
|
||||||
http: {
|
|
||||||
url: data.url,
|
|
||||||
username: data.username ?? undefined,
|
|
||||||
password: data.password ?? undefined,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
for (const item of wslServers.data?.servers ?? []) {
|
|
||||||
const runtime = item.runtime
|
|
||||||
if (runtime.kind !== "ready") continue
|
|
||||||
list.push({
|
|
||||||
displayName: item.config.distro,
|
|
||||||
type: "sidecar",
|
|
||||||
variant: "wsl",
|
|
||||||
distro: item.config.distro,
|
|
||||||
http: {
|
|
||||||
url: runtime.url,
|
|
||||||
username: runtime.username ?? undefined,
|
|
||||||
password: runtime.password ?? undefined,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return list
|
|
||||||
})
|
|
||||||
const effectiveDefaultServer = createMemo(() => {
|
|
||||||
const key = defaultServer.latest ?? ServerConnection.Key.make("sidecar")
|
|
||||||
if (!key.startsWith("wsl:")) return key
|
|
||||||
const item = wslServers.data?.servers.find((item) => item.config.id === key)
|
|
||||||
if (item?.runtime.kind === "ready") return key
|
|
||||||
return ServerConnection.Key.make("sidecar")
|
|
||||||
})
|
|
||||||
if (!ready()) return splash
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Show when={effectiveDefaultServer()} keyed>
|
|
||||||
{(key) => (
|
|
||||||
<AppInterface defaultServer={key} servers={servers()} router={MemoryRouter}>
|
|
||||||
<Inner />
|
|
||||||
</AppInterface>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
document.addEventListener("click", handleClick)
|
document.addEventListener("click", handleClick)
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
@@ -421,7 +380,27 @@ render(() => {
|
|||||||
return (
|
return (
|
||||||
<PlatformProvider value={platform}>
|
<PlatformProvider value={platform}>
|
||||||
<AppBaseProviders locale={locale.latest}>
|
<AppBaseProviders locale={locale.latest}>
|
||||||
<App />
|
<Show
|
||||||
|
when={
|
||||||
|
!defaultServer.loading &&
|
||||||
|
!sidecar.loading &&
|
||||||
|
!windowConfig.loading &&
|
||||||
|
!windowCount.loading &&
|
||||||
|
!locale.loading
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(_) => {
|
||||||
|
return (
|
||||||
|
<AppInterface
|
||||||
|
defaultServer={defaultServer.latest ?? ServerConnection.Key.make("sidecar")}
|
||||||
|
servers={servers()}
|
||||||
|
router={MemoryRouter}
|
||||||
|
>
|
||||||
|
<Inner />
|
||||||
|
</AppInterface>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
</AppBaseProviders>
|
</AppBaseProviders>
|
||||||
</PlatformProvider>
|
</PlatformProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Auth } from "../../auth"
|
import { Auth } from "../../auth"
|
||||||
import { AuthWellKnown } from "@opencode-ai/core/auth-well-known"
|
|
||||||
import { cmd } from "./cmd"
|
import { cmd } from "./cmd"
|
||||||
import { CliError, effectCmd, fail } from "../effect-cmd"
|
import { CliError, effectCmd, fail } from "../effect-cmd"
|
||||||
import { UI } from "../ui"
|
import { UI } from "../ui"
|
||||||
@@ -253,7 +252,6 @@ export const ProvidersListCommand = effectCmd({
|
|||||||
instance: false,
|
instance: false,
|
||||||
handler: Effect.fn("Cli.providers.list")(function* (_args) {
|
handler: Effect.fn("Cli.providers.list")(function* (_args) {
|
||||||
const authSvc = yield* Auth.Service
|
const authSvc = yield* Auth.Service
|
||||||
const authWellKnown = yield* AuthWellKnown.Service
|
|
||||||
const modelsDev = yield* ModelsDev.Service
|
const modelsDev = yield* ModelsDev.Service
|
||||||
|
|
||||||
UI.empty()
|
UI.empty()
|
||||||
@@ -261,8 +259,7 @@ export const ProvidersListCommand = effectCmd({
|
|||||||
const homedir = os.homedir()
|
const homedir = os.homedir()
|
||||||
const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath
|
const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath
|
||||||
yield* Prompt.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
|
yield* Prompt.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
|
||||||
const results = Object.entries(yield* Effect.orDie(authSvc.all())).filter(([, result]) => result.type !== "wellknown")
|
const results = Object.entries(yield* Effect.orDie(authSvc.all()))
|
||||||
const wellKnownResults = Object.entries(yield* Effect.orDie(authWellKnown.all()))
|
|
||||||
const database = yield* modelsDev.get()
|
const database = yield* modelsDev.get()
|
||||||
|
|
||||||
for (const [providerID, result] of results) {
|
for (const [providerID, result] of results) {
|
||||||
@@ -270,11 +267,7 @@ export const ProvidersListCommand = effectCmd({
|
|||||||
yield* Prompt.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`)
|
yield* Prompt.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [url] of wellKnownResults) {
|
yield* Prompt.outro(`${results.length} credentials`)
|
||||||
yield* Prompt.log.info(`${url} ${UI.Style.TEXT_DIM}wellknown`)
|
|
||||||
}
|
|
||||||
|
|
||||||
yield* Prompt.outro(`${results.length + wellKnownResults.length} credentials`)
|
|
||||||
|
|
||||||
const activeEnvVars: Array<{ provider: string; envVar: string }> = []
|
const activeEnvVars: Array<{ provider: string; envVar: string }> = []
|
||||||
|
|
||||||
@@ -323,19 +316,19 @@ export const ProvidersLoginCommand = effectCmd({
|
|||||||
}),
|
}),
|
||||||
handler: Effect.fn("Cli.providers.login")(function* (args) {
|
handler: Effect.fn("Cli.providers.login")(function* (args) {
|
||||||
const authSvc = yield* Auth.Service
|
const authSvc = yield* Auth.Service
|
||||||
const authWellKnown = yield* AuthWellKnown.Service
|
|
||||||
|
|
||||||
UI.empty()
|
UI.empty()
|
||||||
yield* Prompt.intro("Add credential")
|
yield* Prompt.intro("Add credential")
|
||||||
if (args.url) {
|
if (args.url) {
|
||||||
const url = args.url.replace(/\/+$/, "")
|
const url = args.url.replace(/\/+$/, "")
|
||||||
const wellknown = yield* authWellKnown.metadata(url).pipe(
|
const wellknown = (yield* cliTry(`Failed to load auth provider metadata from ${url}: `, () =>
|
||||||
Effect.mapError((error) => new CliError({ message: `Failed to load auth provider metadata from ${url}: ${errorMessage(error)}` })),
|
fetch(`${url}/.well-known/opencode`).then((x) => x.json()),
|
||||||
)
|
)) as {
|
||||||
if (!wellknown.auth) return yield* fail(`Auth provider metadata from ${url} is missing auth configuration`)
|
auth: { command: string[]; env: string }
|
||||||
|
}
|
||||||
yield* Prompt.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
|
yield* Prompt.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
|
||||||
const abort = new AbortController()
|
const abort = new AbortController()
|
||||||
const proc = Process.spawn([...wellknown.auth.command], { stdout: "pipe", stderr: "inherit", abort: abort.signal })
|
const proc = Process.spawn(wellknown.auth.command, { stdout: "pipe", stderr: "inherit", abort: abort.signal })
|
||||||
if (!proc.stdout) {
|
if (!proc.stdout) {
|
||||||
yield* Prompt.log.error("Failed")
|
yield* Prompt.log.error("Failed")
|
||||||
yield* Prompt.outro("Done")
|
yield* Prompt.outro("Done")
|
||||||
@@ -349,7 +342,7 @@ export const ProvidersLoginCommand = effectCmd({
|
|||||||
yield* Prompt.outro("Done")
|
yield* Prompt.outro("Done")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
yield* Effect.orDie(authWellKnown.set(url, new AuthWellKnown.Entry({ key: wellknown.auth.env, token: token.trim() })))
|
yield* Effect.orDie(authSvc.set(url, { type: "wellknown", key: wellknown.auth.env, token: token.trim() }))
|
||||||
yield* Prompt.log.success("Logged into " + url)
|
yield* Prompt.log.success("Logged into " + url)
|
||||||
yield* Prompt.outro("Done")
|
yield* Prompt.outro("Done")
|
||||||
return
|
return
|
||||||
@@ -499,20 +492,10 @@ export const ProvidersLogoutCommand = effectCmd({
|
|||||||
instance: false,
|
instance: false,
|
||||||
handler: Effect.fn("Cli.providers.logout")(function* (_args) {
|
handler: Effect.fn("Cli.providers.logout")(function* (_args) {
|
||||||
const authSvc = yield* Auth.Service
|
const authSvc = yield* Auth.Service
|
||||||
const authWellKnown = yield* AuthWellKnown.Service
|
|
||||||
const modelsDev = yield* ModelsDev.Service
|
const modelsDev = yield* ModelsDev.Service
|
||||||
|
|
||||||
UI.empty()
|
UI.empty()
|
||||||
const credentials = [
|
const credentials: Array<[string, Auth.Info]> = Object.entries(yield* Effect.orDie(authSvc.all()))
|
||||||
...Object.entries(yield* Effect.orDie(authSvc.all()))
|
|
||||||
.filter(([, value]) => value.type !== "wellknown")
|
|
||||||
.map(([key, value]) => ({ key, type: value.type, auth: "provider" as const })),
|
|
||||||
...Object.keys(yield* Effect.orDie(authWellKnown.all())).map((key) => ({
|
|
||||||
key,
|
|
||||||
type: "wellknown" as const,
|
|
||||||
auth: "wellknown" as const,
|
|
||||||
})),
|
|
||||||
]
|
|
||||||
yield* Prompt.intro("Remove credential")
|
yield* Prompt.intro("Remove credential")
|
||||||
if (credentials.length === 0) {
|
if (credentials.length === 0) {
|
||||||
yield* Prompt.log.error("No credentials found")
|
yield* Prompt.log.error("No credentials found")
|
||||||
@@ -521,15 +504,12 @@ export const ProvidersLogoutCommand = effectCmd({
|
|||||||
const database = yield* modelsDev.get()
|
const database = yield* modelsDev.get()
|
||||||
const selected = yield* Prompt.select({
|
const selected = yield* Prompt.select({
|
||||||
message: "Select provider",
|
message: "Select provider",
|
||||||
options: credentials.map((item, index) => ({
|
options: credentials.map(([key, value]) => ({
|
||||||
label: (database[item.key]?.name || item.key) + UI.Style.TEXT_DIM + " (" + item.type + ")",
|
label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
|
||||||
value: index,
|
value: key,
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
const credential = credentials[yield* promptValue(selected)]
|
yield* Effect.orDie(authSvc.remove(yield* promptValue(selected)))
|
||||||
if (!credential) return
|
|
||||||
if (credential.auth === "wellknown") yield* Effect.orDie(authWellKnown.remove(credential.key))
|
|
||||||
else yield* Effect.orDie(authSvc.remove(credential.key))
|
|
||||||
yield* Prompt.outro("Logout successful")
|
yield* Prompt.outro("Logout successful")
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,42 +1,24 @@
|
|||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Server } from "../../server/server"
|
import { Server } from "../../server/server"
|
||||||
import { ServerDiscovery } from "@/cli/server-discovery"
|
|
||||||
import { effectCmd } from "../effect-cmd"
|
import { effectCmd } from "../effect-cmd"
|
||||||
import { withNetworkOptions, resolveNetworkOptions } from "../network"
|
import { withNetworkOptions, resolveNetworkOptions } from "../network"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
|
|
||||||
export const ServeCommand = effectCmd({
|
export const ServeCommand = effectCmd({
|
||||||
command: "serve",
|
command: "serve",
|
||||||
builder: (yargs) =>
|
builder: (yargs) => withNetworkOptions(yargs),
|
||||||
withNetworkOptions(yargs).option("discoverable", {
|
|
||||||
type: "boolean",
|
|
||||||
describe: "write this server to the local discovery file for default TUI startup",
|
|
||||||
default: false,
|
|
||||||
}),
|
|
||||||
describe: "starts a headless opencode server",
|
describe: "starts a headless opencode server",
|
||||||
// Server loads instances per-request via x-opencode-directory header — no
|
// Server loads instances per-request via x-opencode-directory header — no
|
||||||
// need for an ambient project InstanceContext at startup.
|
// need for an ambient project InstanceContext at startup.
|
||||||
instance: false,
|
instance: false,
|
||||||
handler: (args) =>
|
handler: Effect.fn("Cli.serve")(function* (args) {
|
||||||
Effect.gen(function* () {
|
if (!Flag.OPENCODE_SERVER_PASSWORD) {
|
||||||
if (!Flag.OPENCODE_SERVER_PASSWORD) {
|
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
|
||||||
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
|
}
|
||||||
}
|
const opts = yield* resolveNetworkOptions(args)
|
||||||
const opts = yield* resolveNetworkOptions(args)
|
const server = yield* Effect.promise(() => Server.listen(opts))
|
||||||
const server = yield* Effect.promise(() => Server.listen(opts))
|
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
|
||||||
const discovery = args.discoverable ? yield* ServerDiscovery.Service : undefined
|
|
||||||
if (discovery) {
|
|
||||||
yield* discovery.write(server.url)
|
|
||||||
process.on("exit", ServerDiscovery.removeSync)
|
|
||||||
}
|
|
||||||
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
|
|
||||||
|
|
||||||
yield* Effect.never.pipe(
|
yield* Effect.never
|
||||||
Effect.ensuring(
|
}),
|
||||||
discovery
|
|
||||||
? discovery.remove().pipe(Effect.ensuring(Effect.sync(() => process.off("exit", ServerDiscovery.removeSync))))
|
|
||||||
: Effect.void,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}).pipe(Effect.provide(ServerDiscovery.defaultLayer)),
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -929,17 +929,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "app.toggle.clear_prompt_history",
|
|
||||||
title: kv.get("clear_prompt_save_history", false)
|
|
||||||
? "Don't include cleared prompts in history"
|
|
||||||
: "Include cleared prompts in history",
|
|
||||||
category: "System",
|
|
||||||
run: () => {
|
|
||||||
kv.set("clear_prompt_save_history", !kv.get("clear_prompt_save_history", false))
|
|
||||||
dialog.clear()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
].map((command) => ({
|
].map((command) => ({
|
||||||
namespace: "palette",
|
namespace: "palette",
|
||||||
...command,
|
...command,
|
||||||
|
|||||||
@@ -87,7 +87,6 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create
|
|||||||
return store.history.at(store.index)
|
return store.history.at(store.index)
|
||||||
},
|
},
|
||||||
append(item: PromptInfo) {
|
append(item: PromptInfo) {
|
||||||
if (store.history.at(-1)?.input === item.input) return
|
|
||||||
const entry = structuredClone(unwrap(item))
|
const entry = structuredClone(unwrap(item))
|
||||||
if (isDuplicateEntry(store.history.at(-1), entry)) {
|
if (isDuplicateEntry(store.history.at(-1), entry)) {
|
||||||
setStore("index", 0)
|
setStore("index", 0)
|
||||||
|
|||||||
@@ -157,7 +157,6 @@ export function Prompt(props: PromptProps) {
|
|||||||
const dimensions = useTerminalDimensions()
|
const dimensions = useTerminalDimensions()
|
||||||
const { theme, syntax } = useTheme()
|
const { theme, syntax } = useTheme()
|
||||||
const kv = useKV()
|
const kv = useKV()
|
||||||
const [autoaccept, setAutoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit")
|
|
||||||
const animationsEnabled = createMemo(() => kv.get("animations_enabled", true))
|
const animationsEnabled = createMemo(() => kv.get("animations_enabled", true))
|
||||||
const list = createMemo(() => props.placeholders?.normal ?? [])
|
const list = createMemo(() => props.placeholders?.normal ?? [])
|
||||||
const shell = createMemo(() => props.placeholders?.shell ?? [])
|
const shell = createMemo(() => props.placeholders?.shell ?? [])
|
||||||
@@ -405,15 +404,6 @@ export function Prompt(props: PromptProps) {
|
|||||||
|
|
||||||
const promptCommands = createMemo(() =>
|
const promptCommands = createMemo(() =>
|
||||||
[
|
[
|
||||||
{
|
|
||||||
title: autoaccept() === "none" ? "Enable autoedit" : "Disable autoedit",
|
|
||||||
name: "permission.auto_accept.toggle",
|
|
||||||
category: "Agent",
|
|
||||||
run: () => {
|
|
||||||
setAutoaccept((current) => (current === "none" ? "edit" : "none"))
|
|
||||||
dialog.clear()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: "Clear prompt",
|
title: "Clear prompt",
|
||||||
name: "prompt.clear",
|
name: "prompt.clear",
|
||||||
@@ -809,67 +799,6 @@ export function Prompt(props: PromptProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function expandPasteExtmark(extmark: { id: number; start: number; end: number }) {
|
|
||||||
const partIndex = store.extmarkToPartIndex.get(extmark.id)
|
|
||||||
const part = partIndex === undefined ? undefined : store.prompt.parts[partIndex]
|
|
||||||
if (part?.type !== "text" || !part.source?.text) return false
|
|
||||||
|
|
||||||
const nextInput = store.prompt.input.slice(0, extmark.start) + part.text + store.prompt.input.slice(extmark.end)
|
|
||||||
const delta = part.text.length - (extmark.end - extmark.start)
|
|
||||||
const nextParts = store.prompt.parts
|
|
||||||
.flatMap((item, index) => {
|
|
||||||
if (index === partIndex) return []
|
|
||||||
const next = structuredClone(unwrap(item))
|
|
||||||
if (next.type === "agent" && next.source && next.source.start >= extmark.end) {
|
|
||||||
next.source.start += delta
|
|
||||||
next.source.end += delta
|
|
||||||
}
|
|
||||||
if (next.type === "file" && next.source?.text && next.source.text.start >= extmark.end) {
|
|
||||||
next.source.text.start += delta
|
|
||||||
next.source.text.end += delta
|
|
||||||
}
|
|
||||||
if (next.type === "text" && next.source?.text && next.source.text.start >= extmark.end) {
|
|
||||||
next.source.text.start += delta
|
|
||||||
next.source.text.end += delta
|
|
||||||
}
|
|
||||||
return [next]
|
|
||||||
})
|
|
||||||
.filter((item): item is PromptInfo["parts"][number] => item !== undefined)
|
|
||||||
|
|
||||||
input.setText(nextInput)
|
|
||||||
setStore("prompt", {
|
|
||||||
input: nextInput,
|
|
||||||
parts: nextParts,
|
|
||||||
})
|
|
||||||
restoreExtmarksFromParts(nextParts)
|
|
||||||
input.cursorOffset = extmark.start + part.text.length
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
function expandPasteBlockAtMouse(event: MouseEvent) {
|
|
||||||
if (event.button !== 0) return false
|
|
||||||
const localX = event.x - input.x
|
|
||||||
const localY = event.y - input.y
|
|
||||||
if (localX < 0 || localY < 0 || localX >= input.width || localY >= input.height) return false
|
|
||||||
|
|
||||||
const previousOffset = input.cursorOffset
|
|
||||||
input.editorView.setLocalSelection(localX, localY, localX, localY, undefined, undefined, true, false)
|
|
||||||
input.editorView.resetLocalSelection()
|
|
||||||
const offset = input.cursorOffset
|
|
||||||
const extmark = input.extmarks.getAllForTypeId(promptPartTypeId).find((item) => {
|
|
||||||
const partIndex = store.extmarkToPartIndex.get(item.id)
|
|
||||||
const part = partIndex === undefined ? undefined : store.prompt.parts[partIndex]
|
|
||||||
if (part?.type !== "text") return false
|
|
||||||
return (offset >= item.start && offset <= item.end) || (offset + 1 >= item.start && offset + 1 <= item.end)
|
|
||||||
})
|
|
||||||
if (!extmark) {
|
|
||||||
input.cursorOffset = previousOffset
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return expandPasteExtmark(extmark)
|
|
||||||
}
|
|
||||||
|
|
||||||
const stashCommands = createMemo(() =>
|
const stashCommands = createMemo(() =>
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -1252,27 +1181,25 @@ export function Prompt(props: PromptProps) {
|
|||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
const parts = [
|
|
||||||
...editorParts,
|
|
||||||
{
|
|
||||||
id: PartID.ascending(),
|
|
||||||
type: "text" as const,
|
|
||||||
text: inputText,
|
|
||||||
},
|
|
||||||
...nonTextParts.map(assign),
|
|
||||||
]
|
|
||||||
const request = {
|
|
||||||
sessionID,
|
|
||||||
messageID,
|
|
||||||
agent: agent.name,
|
|
||||||
model: selectedModel,
|
|
||||||
variant,
|
|
||||||
parts,
|
|
||||||
}
|
|
||||||
sync.session.addOptimisticPrompt(request)
|
|
||||||
sdk.client.session
|
sdk.client.session
|
||||||
.prompt(request)
|
.prompt({
|
||||||
.catch(() => sync.session.removeOptimisticPrompt(request.sessionID, request.messageID))
|
sessionID,
|
||||||
|
...selectedModel,
|
||||||
|
messageID,
|
||||||
|
agent: agent.name,
|
||||||
|
model: selectedModel,
|
||||||
|
variant,
|
||||||
|
parts: [
|
||||||
|
...editorParts,
|
||||||
|
{
|
||||||
|
id: PartID.ascending(),
|
||||||
|
type: "text",
|
||||||
|
text: inputText,
|
||||||
|
},
|
||||||
|
...nonTextParts.map(assign),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
if (editorParts.length > 0) editor.markSelectionSent()
|
if (editorParts.length > 0) editor.markSelectionSent()
|
||||||
}
|
}
|
||||||
history.append({
|
history.append({
|
||||||
@@ -1445,8 +1372,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clearPrompt() {
|
function clearPrompt() {
|
||||||
const shouldSave = store.prompt.input.trim().length >= DRAFT_RETENTION_MIN_CHARS || store.prompt.parts.length > 0
|
if (store.prompt.input.trim().length >= DRAFT_RETENTION_MIN_CHARS || store.prompt.parts.length > 0) {
|
||||||
if (shouldSave || kv.get("clear_prompt_save_history", false)) {
|
|
||||||
history.append({
|
history.append({
|
||||||
...store.prompt,
|
...store.prompt,
|
||||||
mode: store.mode,
|
mode: store.mode,
|
||||||
@@ -1633,11 +1559,6 @@ export function Prompt(props: PromptProps) {
|
|||||||
}, 0)
|
}, 0)
|
||||||
}}
|
}}
|
||||||
onMouseDown={(r: MouseEvent) => r.target?.focus()}
|
onMouseDown={(r: MouseEvent) => r.target?.focus()}
|
||||||
onMouseUp={(event: MouseEvent) => {
|
|
||||||
if (!expandPasteBlockAtMouse(event)) return
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
}}
|
|
||||||
focusedBackgroundColor={theme.backgroundElement}
|
focusedBackgroundColor={theme.backgroundElement}
|
||||||
cursorColor={props.disabled ? theme.backgroundElement : theme.text}
|
cursorColor={props.disabled ? theme.backgroundElement : theme.text}
|
||||||
syntaxStyle={syntax()}
|
syntaxStyle={syntax()}
|
||||||
@@ -1674,14 +1595,11 @@ export function Prompt(props: PromptProps) {
|
|||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
<box flexDirection="row" gap={1} alignItems="center">
|
<Show when={hasRightContent()}>
|
||||||
<Show when={autoaccept() === "edit"}>
|
<box flexDirection="row" gap={1} alignItems="center">
|
||||||
<text>
|
{props.right}
|
||||||
<span style={{ fg: theme.warning }}>autoedit</span>
|
</box>
|
||||||
</text>
|
</Show>
|
||||||
</Show>
|
|
||||||
<Show when={hasRightContent()}>{props.right}</Show>
|
|
||||||
</box>
|
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { Flag } from "@opencode-ai/core/flag/flag"
|
|||||||
import { isRecord } from "@/util/record"
|
import { isRecord } from "@/util/record"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { Substitution } from "@opencode-ai/core/substitution"
|
|
||||||
import { CurrentWorkingDirectory } from "./cwd"
|
import { CurrentWorkingDirectory } from "./cwd"
|
||||||
import { ConfigPlugin } from "@/config/plugin"
|
import { ConfigPlugin } from "@/config/plugin"
|
||||||
import { TuiKeybind } from "./keybind"
|
import { TuiKeybind } from "./keybind"
|
||||||
@@ -20,6 +19,7 @@ import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/instal
|
|||||||
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
|
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
import { Filesystem } from "@/util/filesystem"
|
||||||
import * as Log from "@opencode-ai/core/util/log"
|
import * as Log from "@opencode-ai/core/util/log"
|
||||||
|
import { ConfigVariable } from "@/config/variable"
|
||||||
import { Npm } from "@opencode-ai/core/npm"
|
import { Npm } from "@opencode-ai/core/npm"
|
||||||
import type { DeepMutable } from "@opencode-ai/core/schema"
|
import type { DeepMutable } from "@opencode-ai/core/schema"
|
||||||
import type { TuiAttentionSoundName } from "@opencode-ai/plugin/tui"
|
import type { TuiAttentionSoundName } from "@opencode-ai/plugin/tui"
|
||||||
@@ -98,7 +98,6 @@ function dropUnknownKeybinds(input: Record<string, unknown>, configFilepath: str
|
|||||||
|
|
||||||
const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: string }) {
|
const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: string }) {
|
||||||
const afs = yield* AppFileSystem.Service
|
const afs = yield* AppFileSystem.Service
|
||||||
const substitution = yield* Substitution.Service
|
|
||||||
let appliedOrder = 0
|
let appliedOrder = 0
|
||||||
|
|
||||||
const resolvePlugins = (config: Info, configFilepath: string): Effect.Effect<Info> =>
|
const resolvePlugins = (config: Info, configFilepath: string): Effect.Effect<Info> =>
|
||||||
@@ -113,7 +112,9 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
|
|||||||
|
|
||||||
const load = (text: string, configFilepath: string): Effect.Effect<Info> =>
|
const load = (text: string, configFilepath: string): Effect.Effect<Info> =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const expanded = yield* substitution.substitute({ text, type: "path", path: configFilepath, missing: "empty" }).pipe(Effect.orDie)
|
const expanded = yield* Effect.promise(() =>
|
||||||
|
ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty" }),
|
||||||
|
)
|
||||||
const data = ConfigParse.jsonc(expanded, configFilepath)
|
const data = ConfigParse.jsonc(expanded, configFilepath)
|
||||||
if (!isRecord(data)) return {} as Info
|
if (!isRecord(data)) return {} as Info
|
||||||
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
|
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
|
||||||
@@ -294,11 +295,7 @@ export const layer = Layer.effect(
|
|||||||
}).pipe(Effect.withSpan("TuiConfig.layer")),
|
}).pipe(Effect.withSpan("TuiConfig.layer")),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(
|
export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer), Layer.provide(AppFileSystem.defaultLayer))
|
||||||
Layer.provide(Npm.defaultLayer),
|
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
|
||||||
Layer.provide(Substitution.defaultLayer),
|
|
||||||
)
|
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
|
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
import type { AgentPartInput, FilePartInput, Message, Part, SubtaskPartInput, TextPartInput } from "@opencode-ai/sdk/v2"
|
|
||||||
import { Binary } from "@opencode-ai/core/util/binary"
|
|
||||||
|
|
||||||
export type OptimisticPromptPart = (TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput) & { id: string }
|
|
||||||
|
|
||||||
export function optimisticParts(input: { sessionID: string; messageID: string; parts: OptimisticPromptPart[] }) {
|
|
||||||
return input.parts.map((part): Part => {
|
|
||||||
const withIDs = {
|
|
||||||
...part,
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
messageID: input.messageID,
|
|
||||||
}
|
|
||||||
if (withIDs.type === "file") return { ...withIDs, url: "" }
|
|
||||||
return withIDs
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mergeFetchedMessages(input: {
|
|
||||||
currentMessages: Message[]
|
|
||||||
currentParts: Record<string, Part[] | undefined>
|
|
||||||
fetched: { info: Message; parts: Part[] }[]
|
|
||||||
optimisticMessages: ReadonlySet<string>
|
|
||||||
}) {
|
|
||||||
const fetchedIDs = new Set(input.fetched.map((message) => message.info.id))
|
|
||||||
const messages = input.fetched.map((message) => message.info)
|
|
||||||
const parts = new Map<string, Part[]>()
|
|
||||||
const resolved = new Set<string>()
|
|
||||||
|
|
||||||
for (const message of input.currentMessages) {
|
|
||||||
if (input.optimisticMessages.has(message.id) && !fetchedIDs.has(message.id)) {
|
|
||||||
Binary.insert(messages, message, (item) => item.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const message of input.fetched) {
|
|
||||||
if (message.parts.length > 0) {
|
|
||||||
resolved.add(message.info.id)
|
|
||||||
parts.set(message.info.id, message.parts)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (input.optimisticMessages.has(message.info.id)) {
|
|
||||||
const current = input.currentParts[message.info.id]
|
|
||||||
if (current) parts.set(message.info.id, current)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
parts.set(message.info.id, message.parts)
|
|
||||||
}
|
|
||||||
|
|
||||||
return { messages, parts, resolved }
|
|
||||||
}
|
|
||||||
@@ -33,7 +33,6 @@ import { emptyConsoleState, type ConsoleState } from "@/config/console-state"
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { useKV } from "./kv"
|
import { useKV } from "./kv"
|
||||||
import { aggregateFailures } from "./aggregate-failures"
|
import { aggregateFailures } from "./aggregate-failures"
|
||||||
import { mergeFetchedMessages, optimisticParts, type OptimisticPromptPart } from "./sync-optimistic"
|
|
||||||
|
|
||||||
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||||
name: "Sync",
|
name: "Sync",
|
||||||
@@ -112,10 +111,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
const project = useProject()
|
const project = useProject()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const kv = useKV()
|
const kv = useKV()
|
||||||
const [autoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit")
|
|
||||||
|
|
||||||
const fullSyncedSessions = new Set<string>()
|
const fullSyncedSessions = new Set<string>()
|
||||||
const optimisticMessages = new Set<string>()
|
|
||||||
|
|
||||||
function sessionListQuery(): { scope?: "project"; path?: string } {
|
function sessionListQuery(): { scope?: "project"; path?: string } {
|
||||||
if (!kv.get("session_directory_filter_enabled", true)) return { scope: "project" }
|
if (!kv.get("session_directory_filter_enabled", true)) return { scope: "project" }
|
||||||
@@ -155,13 +152,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
|
|
||||||
case "permission.asked": {
|
case "permission.asked": {
|
||||||
const request = event.properties
|
const request = event.properties
|
||||||
if (autoaccept() === "edit" && request.permission === "edit") {
|
|
||||||
void sdk.client.permission.reply({
|
|
||||||
reply: "once",
|
|
||||||
requestID: request.id,
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
const requests = store.permission[request.sessionID]
|
const requests = store.permission[request.sessionID]
|
||||||
if (!requests) {
|
if (!requests) {
|
||||||
setStore("permission", request.sessionID, [request])
|
setStore("permission", request.sessionID, [request])
|
||||||
@@ -229,7 +219,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
break
|
break
|
||||||
|
|
||||||
case "session.deleted": {
|
case "session.deleted": {
|
||||||
for (const message of store.message[event.properties.info.id] ?? []) optimisticMessages.delete(message.id)
|
|
||||||
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
|
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
|
||||||
if (result.found) {
|
if (result.found) {
|
||||||
setStore(
|
setStore(
|
||||||
@@ -301,7 +290,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "message.removed": {
|
case "message.removed": {
|
||||||
optimisticMessages.delete(event.properties.messageID)
|
|
||||||
const messages = store.message[event.properties.sessionID]
|
const messages = store.message[event.properties.sessionID]
|
||||||
const result = Binary.search(messages, event.properties.messageID, (m) => m.id)
|
const result = Binary.search(messages, event.properties.messageID, (m) => m.id)
|
||||||
if (result.found) {
|
if (result.found) {
|
||||||
@@ -316,7 +304,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "message.part.updated": {
|
case "message.part.updated": {
|
||||||
optimisticMessages.delete(event.properties.part.messageID)
|
|
||||||
const parts = store.part[event.properties.part.messageID]
|
const parts = store.part[event.properties.part.messageID]
|
||||||
if (!parts) {
|
if (!parts) {
|
||||||
setStore("part", event.properties.part.messageID, [event.properties.part])
|
setStore("part", event.properties.part.messageID, [event.properties.part])
|
||||||
@@ -531,66 +518,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
if (last.role === "user") return "working"
|
if (last.role === "user") return "working"
|
||||||
return last.time.completed ? "idle" : "working"
|
return last.time.completed ? "idle" : "working"
|
||||||
},
|
},
|
||||||
addOptimisticPrompt(input: {
|
|
||||||
sessionID: string
|
|
||||||
messageID: string
|
|
||||||
agent: string
|
|
||||||
model: { providerID: string; modelID: string }
|
|
||||||
variant?: string
|
|
||||||
parts: OptimisticPromptPart[]
|
|
||||||
}) {
|
|
||||||
optimisticMessages.add(input.messageID)
|
|
||||||
const messages = store.message[input.sessionID]
|
|
||||||
const match = messages ? Binary.search(messages, input.messageID, (m) => m.id) : undefined
|
|
||||||
const info: Message = {
|
|
||||||
id: input.messageID,
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
role: "user",
|
|
||||||
time: { created: Date.now() },
|
|
||||||
agent: input.agent,
|
|
||||||
model: {
|
|
||||||
providerID: input.model.providerID,
|
|
||||||
modelID: input.model.modelID,
|
|
||||||
...(input.variant ? { variant: input.variant } : {}),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
batch(() => {
|
|
||||||
if (!messages) {
|
|
||||||
setStore("message", input.sessionID, [info])
|
|
||||||
} else if (!match?.found) {
|
|
||||||
setStore(
|
|
||||||
"message",
|
|
||||||
input.sessionID,
|
|
||||||
produce((draft) => {
|
|
||||||
Binary.insert(draft, info, (message) => message.id)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
setStore("part", input.messageID, reconcile(optimisticParts(input)))
|
|
||||||
})
|
|
||||||
},
|
|
||||||
removeOptimisticPrompt(sessionID: string, messageID: string) {
|
|
||||||
if (!optimisticMessages.delete(messageID)) return
|
|
||||||
const messages = store.message[sessionID]
|
|
||||||
const match = messages ? Binary.search(messages, messageID, (m) => m.id) : undefined
|
|
||||||
batch(() => {
|
|
||||||
if (match?.found) {
|
|
||||||
setStore(
|
|
||||||
"message",
|
|
||||||
sessionID,
|
|
||||||
produce((draft) => {
|
|
||||||
draft.splice(match.index, 1)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
setStore(
|
|
||||||
"part",
|
|
||||||
produce((draft) => {
|
|
||||||
delete draft[messageID]
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
async sync(sessionID: string) {
|
async sync(sessionID: string) {
|
||||||
if (fullSyncedSessions.has(sessionID)) return
|
if (fullSyncedSessions.has(sessionID)) return
|
||||||
const [session, messages, todo, diff] = await Promise.all([
|
const [session, messages, todo, diff] = await Promise.all([
|
||||||
@@ -602,22 +529,15 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
setStore(
|
setStore(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
const match = Binary.search(draft.session, sessionID, (s) => s.id)
|
const match = Binary.search(draft.session, sessionID, (s) => s.id)
|
||||||
const merged = mergeFetchedMessages({
|
|
||||||
currentMessages: draft.message[sessionID] ?? [],
|
|
||||||
currentParts: draft.part,
|
|
||||||
fetched: messages.data ?? [],
|
|
||||||
optimisticMessages,
|
|
||||||
})
|
|
||||||
if (match.found) draft.session[match.index] = session.data!
|
if (match.found) draft.session[match.index] = session.data!
|
||||||
if (!match.found) draft.session.splice(match.index, 0, session.data!)
|
if (!match.found) draft.session.splice(match.index, 0, session.data!)
|
||||||
draft.todo[sessionID] = todo.data ?? []
|
draft.todo[sessionID] = todo.data ?? []
|
||||||
draft.message[sessionID] = merged.messages
|
const infos: (typeof draft.message)[string] = []
|
||||||
for (const messageID of merged.resolved) {
|
for (const message of messages.data ?? []) {
|
||||||
optimisticMessages.delete(messageID)
|
infos.push(message.info)
|
||||||
}
|
draft.part[message.info.id] = message.parts
|
||||||
for (const [messageID, parts] of merged.parts) {
|
|
||||||
draft.part[messageID] = parts
|
|
||||||
}
|
}
|
||||||
|
draft.message[sessionID] = infos
|
||||||
draft.session_diff[sessionID] = diff.data ?? []
|
draft.session_diff[sessionID] = diff.data ?? []
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1524,8 +1524,6 @@ const PART_MAPPING = {
|
|||||||
reasoning: ReasoningPart,
|
reasoning: ReasoningPart,
|
||||||
}
|
}
|
||||||
|
|
||||||
const INLINE_TOOL_ICON_WIDTH = 2
|
|
||||||
|
|
||||||
function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: AssistantMessage }) {
|
function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: AssistantMessage }) {
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const ctx = use()
|
const ctx = use()
|
||||||
@@ -1797,7 +1795,6 @@ function InlineTool(props: {
|
|||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
const [hover, setHover] = createSignal(false)
|
const [hover, setHover] = createSignal(false)
|
||||||
const [errorExpanded, setErrorExpanded] = createSignal(false)
|
|
||||||
|
|
||||||
const permission = createMemo(() => {
|
const permission = createMemo(() => {
|
||||||
const callID = sync.data.permission[ctx.sessionID]?.at(0)?.tool?.callID
|
const callID = sync.data.permission[ctx.sessionID]?.at(0)?.tool?.callID
|
||||||
@@ -1805,6 +1802,14 @@ function InlineTool(props: {
|
|||||||
return callID === props.part.callID
|
return callID === props.part.callID
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const fg = createMemo(() => {
|
||||||
|
if (props.color) return props.color
|
||||||
|
if (permission()) return theme.warning
|
||||||
|
if (hover() && props.onClick) return theme.text
|
||||||
|
if (props.complete) return theme.textMuted
|
||||||
|
return theme.text
|
||||||
|
})
|
||||||
|
|
||||||
const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error : undefined))
|
const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error : undefined))
|
||||||
|
|
||||||
const denied = createMemo(
|
const denied = createMemo(
|
||||||
@@ -1815,29 +1820,14 @@ function InlineTool(props: {
|
|||||||
error()?.includes("user dismissed"),
|
error()?.includes("user dismissed"),
|
||||||
)
|
)
|
||||||
|
|
||||||
const failed = createMemo(() => Boolean(error() && !denied()))
|
|
||||||
const clickable = createMemo(() => Boolean(props.onClick || failed()))
|
|
||||||
const fg = createMemo(() => {
|
|
||||||
if (permission()) return theme.warning
|
|
||||||
if (failed()) return theme.error
|
|
||||||
if (props.color) return props.color
|
|
||||||
if (hover() && props.onClick) return theme.text
|
|
||||||
if (props.complete) return theme.textMuted
|
|
||||||
return theme.text
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
marginTop={margin()}
|
marginTop={margin()}
|
||||||
paddingLeft={3}
|
paddingLeft={3}
|
||||||
onMouseOver={() => clickable() && setHover(true)}
|
onMouseOver={() => props.onClick && setHover(true)}
|
||||||
onMouseOut={() => setHover(false)}
|
onMouseOut={() => setHover(false)}
|
||||||
onMouseUp={() => {
|
onMouseUp={() => {
|
||||||
if (renderer.getSelection()?.getSelectedText()) return
|
if (renderer.getSelection()?.getSelectedText()) return
|
||||||
if (failed()) {
|
|
||||||
setErrorExpanded((value) => !value)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
props.onClick?.()
|
props.onClick?.()
|
||||||
}}
|
}}
|
||||||
renderBefore={function () {
|
renderBefore={function () {
|
||||||
@@ -1846,10 +1836,21 @@ function InlineTool(props: {
|
|||||||
if (!parent) {
|
if (!parent) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (el.height > 1) {
|
||||||
|
setMargin(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
const children = parent.getChildren()
|
const children = parent.getChildren()
|
||||||
const index = children.indexOf(el)
|
const index = children.indexOf(el)
|
||||||
const previous = children[index - 1]
|
const previous = children[index - 1]
|
||||||
setMargin(previous?.id.startsWith("text-") || previous?.id.startsWith("tool-block-") ? 1 : 0)
|
if (!previous) {
|
||||||
|
setMargin(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (previous.height > 1 || previous.id.startsWith("text-")) {
|
||||||
|
setMargin(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Switch>
|
<Switch>
|
||||||
@@ -1857,37 +1858,15 @@ function InlineTool(props: {
|
|||||||
<Spinner color={fg()} children={props.children} />
|
<Spinner color={fg()} children={props.children} />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={true}>
|
<Match when={true}>
|
||||||
<Show
|
<text paddingLeft={3} fg={fg()} attributes={denied() ? TextAttributes.STRIKETHROUGH : undefined}>
|
||||||
fallback={
|
<Show fallback={<>~ {props.pending}</>} when={props.complete}>
|
||||||
<text paddingLeft={3} fg={fg()} attributes={denied() ? TextAttributes.STRIKETHROUGH : undefined}>
|
<span style={{ fg: props.iconColor }}>{props.icon}</span> {props.children}
|
||||||
~ {props.pending}
|
</Show>
|
||||||
</text>
|
</text>
|
||||||
}
|
|
||||||
when={props.complete}
|
|
||||||
>
|
|
||||||
<box flexDirection="row">
|
|
||||||
<text
|
|
||||||
width={INLINE_TOOL_ICON_WIDTH}
|
|
||||||
fg={failed() ? theme.error : (props.iconColor ?? fg())}
|
|
||||||
attributes={denied() ? TextAttributes.STRIKETHROUGH : undefined}
|
|
||||||
>
|
|
||||||
{props.icon}
|
|
||||||
</text>
|
|
||||||
<text
|
|
||||||
flexGrow={1}
|
|
||||||
fg={failed() ? theme.error : fg()}
|
|
||||||
attributes={denied() ? TextAttributes.STRIKETHROUGH : undefined}
|
|
||||||
>
|
|
||||||
{props.children}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
<Show when={failed() && errorExpanded()}>
|
<Show when={error() && !denied()}>
|
||||||
<box paddingLeft={INLINE_TOOL_ICON_WIDTH}>
|
<text fg={theme.error}>{error()}</text>
|
||||||
<text fg={theme.error}>{error()}</text>
|
|
||||||
</box>
|
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
@@ -1906,7 +1885,6 @@ function BlockTool(props: {
|
|||||||
const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error : undefined))
|
const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error : undefined))
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
id={props.part ? "tool-block-" + props.part.id : undefined}
|
|
||||||
border={["left"]}
|
border={["left"]}
|
||||||
paddingTop={1}
|
paddingTop={1}
|
||||||
paddingBottom={1}
|
paddingBottom={1}
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import { errorMessage } from "@/util/error"
|
|||||||
import { withTimeout } from "@/util/timeout"
|
import { withTimeout } from "@/util/timeout"
|
||||||
import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network"
|
import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
import { Filesystem } from "@/util/filesystem"
|
||||||
import { ServerAuth } from "@/server/auth"
|
|
||||||
import { ServerDiscovery } from "@/cli/server-discovery"
|
|
||||||
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
|
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||||
import type { EventSource } from "./context/sdk"
|
import type { EventSource } from "./context/sdk"
|
||||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||||
@@ -199,26 +197,16 @@ export const TuiThreadCommand = cmd({
|
|||||||
network.mdns ||
|
network.mdns ||
|
||||||
network.port !== 0 ||
|
network.port !== 0 ||
|
||||||
network.hostname !== "127.0.0.1"
|
network.hostname !== "127.0.0.1"
|
||||||
const discovered = external ? undefined : await ServerDiscovery.find()
|
|
||||||
|
|
||||||
const transport = external
|
const transport = external
|
||||||
? {
|
? {
|
||||||
url: (await client.call("server", network)).url,
|
url: (await client.call("server", network)).url,
|
||||||
fetch: undefined,
|
fetch: undefined,
|
||||||
headers: ServerAuth.headers(),
|
|
||||||
events: undefined,
|
events: undefined,
|
||||||
}
|
}
|
||||||
: discovered
|
|
||||||
? {
|
|
||||||
url: discovered,
|
|
||||||
fetch: undefined,
|
|
||||||
headers: ServerAuth.headers(),
|
|
||||||
events: undefined,
|
|
||||||
}
|
|
||||||
: {
|
: {
|
||||||
url: "http://opencode.internal",
|
url: "http://opencode.internal",
|
||||||
fetch: createWorkerFetch(client),
|
fetch: createWorkerFetch(client),
|
||||||
headers: undefined,
|
|
||||||
events: createEventSource(client),
|
events: createEventSource(client),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,7 +216,6 @@ export const TuiThreadCommand = cmd({
|
|||||||
sessionID: args.session,
|
sessionID: args.session,
|
||||||
directory: cwd,
|
directory: cwd,
|
||||||
fetch: transport.fetch,
|
fetch: transport.fetch,
|
||||||
headers: transport.headers,
|
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
UI.error(errorMessage(error))
|
UI.error(errorMessage(error))
|
||||||
@@ -254,7 +241,6 @@ export const TuiThreadCommand = cmd({
|
|||||||
config,
|
config,
|
||||||
directory: cwd,
|
directory: cwd,
|
||||||
fetch: transport.fetch,
|
fetch: transport.fetch,
|
||||||
headers: transport.headers,
|
|
||||||
events: transport.events,
|
events: transport.events,
|
||||||
args: {
|
args: {
|
||||||
continue: args.continue,
|
continue: args.continue,
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ export interface DialogSelectOption<T = any> {
|
|||||||
value: T
|
value: T
|
||||||
description?: string
|
description?: string
|
||||||
details?: string[]
|
details?: string[]
|
||||||
search?: string
|
|
||||||
footer?: JSX.Element | string
|
footer?: JSX.Element | string
|
||||||
category?: string
|
category?: string
|
||||||
categoryView?: JSX.Element
|
categoryView?: JSX.Element
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
export * as ServerDiscovery from "./server-discovery"
|
|
||||||
|
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
|
||||||
import { ServerAuth } from "@/server/auth"
|
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
|
||||||
import { Global } from "@opencode-ai/core/global"
|
|
||||||
import { Context, Effect, Layer, Option, Schema } from "effect"
|
|
||||||
import { readFileSync, unlinkSync } from "fs"
|
|
||||||
import path from "path"
|
|
||||||
|
|
||||||
export const file = path.join(Global.Path.state, "server.json")
|
|
||||||
|
|
||||||
const Entry = Schema.Struct({
|
|
||||||
url: Schema.String,
|
|
||||||
pid: Schema.Number,
|
|
||||||
})
|
|
||||||
type Entry = typeof Entry.Type
|
|
||||||
const decodeEntry = Schema.decodeUnknownOption(Entry)
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly write: (url: URL) => Effect.Effect<void>
|
|
||||||
readonly remove: () => Effect.Effect<void>
|
|
||||||
readonly find: () => Effect.Effect<string | undefined>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/CliServerDiscovery") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const fs = yield* AppFileSystem.Service
|
|
||||||
|
|
||||||
const read = Effect.fn("CliServerDiscovery.read")(function* () {
|
|
||||||
const entry = yield* fs.readJson(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
||||||
return Option.getOrUndefined(decodeEntry(entry))
|
|
||||||
})
|
|
||||||
|
|
||||||
const remove = Effect.fn("CliServerDiscovery.remove")(function* () {
|
|
||||||
const entry = yield* read()
|
|
||||||
if (entry?.pid !== process.pid) return
|
|
||||||
yield* fs.remove(file).pipe(Effect.ignore)
|
|
||||||
})
|
|
||||||
|
|
||||||
const removeStale = Effect.fn("CliServerDiscovery.removeStale")(function* (entry: Entry) {
|
|
||||||
const current = yield* read()
|
|
||||||
if (current?.pid !== entry.pid || current.url !== entry.url) return
|
|
||||||
yield* fs.remove(file).pipe(Effect.ignore)
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({
|
|
||||||
write: Effect.fn("CliServerDiscovery.write")(function* (url) {
|
|
||||||
yield* fs.writeJson(file, { url: localURL(url).toString(), pid: process.pid }, 0o600).pipe(Effect.orDie)
|
|
||||||
}),
|
|
||||||
remove,
|
|
||||||
find: Effect.fn("CliServerDiscovery.find")(function* () {
|
|
||||||
const entry = yield* read()
|
|
||||||
if (!entry) return undefined
|
|
||||||
const url = yield* healthy(entry.url)
|
|
||||||
if (url) return url
|
|
||||||
yield* removeStale(entry)
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
|
||||||
|
|
||||||
export const find = () => runPromise((discovery) => discovery.find())
|
|
||||||
|
|
||||||
export function removeSync() {
|
|
||||||
const entry = readSync()
|
|
||||||
if (entry?.pid !== process.pid) return
|
|
||||||
try {
|
|
||||||
unlinkSync(file)
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function readSync() {
|
|
||||||
try {
|
|
||||||
return Option.getOrUndefined(decodeEntry(JSON.parse(readFileSync(file, "utf8"))))
|
|
||||||
} catch {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function healthy(input: string) {
|
|
||||||
return Effect.tryPromise({
|
|
||||||
try: async () => {
|
|
||||||
const url = new URL(input)
|
|
||||||
if (url.protocol !== "http:" && url.protocol !== "https:") return undefined
|
|
||||||
const response = await fetch(new URL("/global/health", url), {
|
|
||||||
headers: ServerAuth.headers(),
|
|
||||||
signal: AbortSignal.timeout(1000),
|
|
||||||
})
|
|
||||||
if (!response.ok) return undefined
|
|
||||||
const body = (await response.json()) as unknown
|
|
||||||
if (typeof body === "object" && body !== null && "healthy" in body && body.healthy === true) {
|
|
||||||
return url.toString()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
catch: () => undefined,
|
|
||||||
}).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
||||||
}
|
|
||||||
|
|
||||||
function localURL(url: URL) {
|
|
||||||
const result = new URL(url)
|
|
||||||
if (result.hostname === "0.0.0.0") result.hostname = "127.0.0.1"
|
|
||||||
if (result.hostname === "::") result.hostname = "::1"
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
@@ -8,8 +8,7 @@ import { Global } from "@opencode-ai/core/global"
|
|||||||
import fsNode from "fs/promises"
|
import fsNode from "fs/promises"
|
||||||
import { NamedError } from "@opencode-ai/core/util/error"
|
import { NamedError } from "@opencode-ai/core/util/error"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { AuthWellKnown } from "@opencode-ai/core/auth-well-known"
|
import { Auth } from "../auth"
|
||||||
import { Substitution } from "@opencode-ai/core/substitution"
|
|
||||||
import { Env } from "../env"
|
import { Env } from "../env"
|
||||||
import { applyEdits, modify } from "jsonc-parser"
|
import { applyEdits, modify } from "jsonc-parser"
|
||||||
import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version"
|
import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||||
@@ -20,6 +19,7 @@ import type { ConsoleState } from "./console-state"
|
|||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect"
|
import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect"
|
||||||
|
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||||
import { containsPath, type InstanceContext } from "../project/instance-context"
|
import { containsPath, type InstanceContext } from "../project/instance-context"
|
||||||
import { NonNegativeInt, PositiveInt, type DeepMutable } from "@opencode-ai/core/schema"
|
import { NonNegativeInt, PositiveInt, type DeepMutable } from "@opencode-ai/core/schema"
|
||||||
@@ -40,7 +40,9 @@ import { ConfigProvider } from "./provider"
|
|||||||
import { ConfigReference } from "./reference"
|
import { ConfigReference } from "./reference"
|
||||||
import { ConfigServer } from "./server"
|
import { ConfigServer } from "./server"
|
||||||
import { ConfigSkills } from "./skills"
|
import { ConfigSkills } from "./skills"
|
||||||
|
import { ConfigVariable } from "./variable"
|
||||||
import { Npm } from "@opencode-ai/core/npm"
|
import { Npm } from "@opencode-ai/core/npm"
|
||||||
|
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||||
|
|
||||||
const log = Log.create({ service: "config" })
|
const log = Log.create({ service: "config" })
|
||||||
|
|
||||||
@@ -70,6 +72,48 @@ function normalizeLoadedConfig(data: unknown, source: string) {
|
|||||||
return copy
|
return copy
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function substituteWellKnownRemoteConfig(input: {
|
||||||
|
value: unknown
|
||||||
|
dir: string
|
||||||
|
source: string
|
||||||
|
env: Record<string, string>
|
||||||
|
}) {
|
||||||
|
if (!isRecord(input.value) || typeof input.value.url !== "string") return undefined
|
||||||
|
|
||||||
|
const url = await ConfigVariable.substitute({
|
||||||
|
text: input.value.url,
|
||||||
|
type: "virtual",
|
||||||
|
dir: input.dir,
|
||||||
|
source: input.source,
|
||||||
|
env: input.env,
|
||||||
|
})
|
||||||
|
const headers = isRecord(input.value.headers)
|
||||||
|
? Object.fromEntries(
|
||||||
|
await Promise.all(
|
||||||
|
Object.entries(input.value.headers)
|
||||||
|
.filter((entry): entry is [string, string] => typeof entry[1] === "string")
|
||||||
|
.map(async ([key, value]) => [
|
||||||
|
key,
|
||||||
|
await ConfigVariable.substitute({
|
||||||
|
text: value,
|
||||||
|
type: "virtual",
|
||||||
|
dir: input.dir,
|
||||||
|
source: input.source,
|
||||||
|
env: input.env,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return { url, headers }
|
||||||
|
}
|
||||||
|
|
||||||
|
const WellKnownConfig = Schema.Struct({
|
||||||
|
config: Schema.optional(Schema.Json),
|
||||||
|
remote_config: Schema.optional(Schema.Json),
|
||||||
|
})
|
||||||
|
|
||||||
async function resolveLoadedPlugins<T extends { plugin?: ConfigPlugin.Spec[] }>(config: T, filepath: string) {
|
async function resolveLoadedPlugins<T extends { plugin?: ConfigPlugin.Spec[] }>(config: T, filepath: string) {
|
||||||
if (!config.plugin) return config
|
if (!config.plugin) return config
|
||||||
for (let i = 0; i < config.plugin.length; i++) {
|
for (let i = 0; i < config.plugin.length; i++) {
|
||||||
@@ -338,22 +382,44 @@ export const layer = Layer.effect(
|
|||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* AppFileSystem.Service
|
||||||
const authWellKnown = yield* AuthWellKnown.Service
|
const authSvc = yield* Auth.Service
|
||||||
const substitution = yield* Substitution.Service
|
|
||||||
const accountSvc = yield* Account.Service
|
const accountSvc = yield* Account.Service
|
||||||
const env = yield* Env.Service
|
const env = yield* Env.Service
|
||||||
const npmSvc = yield* Npm.Service
|
const npmSvc = yield* Npm.Service
|
||||||
|
const http = yield* HttpClient.HttpClient
|
||||||
|
|
||||||
const readConfigFile = (filepath: string) => fs.readFileStringSafe(filepath).pipe(Effect.orDie)
|
const readConfigFile = (filepath: string) => fs.readFileStringSafe(filepath).pipe(Effect.orDie)
|
||||||
|
|
||||||
|
const fetchRemoteJson = Effect.fnUntraced(function* <S extends Schema.Top>(
|
||||||
|
url: string,
|
||||||
|
headers: Record<string, string> | undefined,
|
||||||
|
schema: S,
|
||||||
|
) {
|
||||||
|
const response = yield* HttpClient.filterStatusOk(withTransientReadRetry(http))
|
||||||
|
.execute(
|
||||||
|
HttpClientRequest.get(url).pipe(HttpClientRequest.acceptJson, HttpClientRequest.setHeaders(headers ?? {})),
|
||||||
|
)
|
||||||
|
.pipe(
|
||||||
|
Effect.catch((error) => Effect.die(new Error(`failed to fetch remote config from ${url}: ${String(error)}`))),
|
||||||
|
)
|
||||||
|
return yield* HttpClientResponse.schemaBodyJson(schema)(response).pipe(
|
||||||
|
Effect.catch((error) => Effect.die(new Error(`failed to decode remote config from ${url}: ${String(error)}`))),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
const loadConfig = Effect.fnUntraced(function* (
|
const loadConfig = Effect.fnUntraced(function* (
|
||||||
text: string,
|
text: string,
|
||||||
options: { path: string } | { dir: string; source: string },
|
options: { path: string } | { dir: string; source: string },
|
||||||
|
env?: Record<string, string>,
|
||||||
) {
|
) {
|
||||||
const source = "path" in options ? options.path : options.source
|
const source = "path" in options ? options.path : options.source
|
||||||
const expanded = yield* substitution.substitute(
|
const expanded = yield* Effect.promise(() =>
|
||||||
"path" in options ? { text, type: "path", path: options.path } : { text, type: "virtual", ...options },
|
ConfigVariable.substitute(
|
||||||
).pipe(Effect.orDie)
|
"path" in options
|
||||||
|
? { text, type: "path", path: options.path, env }
|
||||||
|
: { text, type: "virtual", ...options, env },
|
||||||
|
),
|
||||||
|
)
|
||||||
const parsed = ConfigParse.jsonc(expanded, source)
|
const parsed = ConfigParse.jsonc(expanded, source)
|
||||||
const data = ConfigParse.schema(Info, normalizeLoadedConfig(parsed, source), source)
|
const data = ConfigParse.schema(Info, normalizeLoadedConfig(parsed, source), source)
|
||||||
if (!("path" in options)) return data
|
if (!("path" in options)) return data
|
||||||
@@ -367,14 +433,14 @@ export const layer = Layer.effect(
|
|||||||
return data
|
return data
|
||||||
})
|
})
|
||||||
|
|
||||||
const loadFile = Effect.fnUntraced(function* (filepath: string) {
|
const loadFile = Effect.fnUntraced(function* (filepath: string, env?: Record<string, string>) {
|
||||||
log.info("loading", { path: filepath })
|
log.info("loading", { path: filepath })
|
||||||
const text = yield* readConfigFile(filepath)
|
const text = yield* readConfigFile(filepath)
|
||||||
if (!text) return {} as Info
|
if (!text) return {} as Info
|
||||||
return yield* loadConfig(text, { path: filepath })
|
return yield* loadConfig(text, { path: filepath }, env)
|
||||||
})
|
})
|
||||||
|
|
||||||
const loadGlobal = Effect.fnUntraced(function* () {
|
const loadGlobal = Effect.fnUntraced(function* (env?: Record<string, string>) {
|
||||||
let result: Info = {}
|
let result: Info = {}
|
||||||
// Seed the default global config with the schema for editor completion, but avoid writing when the user
|
// Seed the default global config with the schema for editor completion, but avoid writing when the user
|
||||||
// explicitly routes config through env-provided paths or content.
|
// explicitly routes config through env-provided paths or content.
|
||||||
@@ -386,9 +452,9 @@ export const layer = Layer.effect(
|
|||||||
.pipe(Effect.catch(() => Effect.void))
|
.pipe(Effect.catch(() => Effect.void))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "config.json")))
|
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "config.json"), env))
|
||||||
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.json")))
|
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.json"), env))
|
||||||
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.jsonc")))
|
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"), env))
|
||||||
|
|
||||||
const legacy = path.join(Global.Path.config, "config")
|
const legacy = path.join(Global.Path.config, "config")
|
||||||
if (existsSync(legacy)) {
|
if (existsSync(legacy)) {
|
||||||
@@ -443,7 +509,10 @@ export const layer = Layer.effect(
|
|||||||
|
|
||||||
const loadInstanceState = Effect.fn("Config.loadInstanceState")(
|
const loadInstanceState = Effect.fn("Config.loadInstanceState")(
|
||||||
function* (ctx: InstanceContext) {
|
function* (ctx: InstanceContext) {
|
||||||
|
const auth = yield* authSvc.all().pipe(Effect.orDie)
|
||||||
|
|
||||||
let result: Info = {}
|
let result: Info = {}
|
||||||
|
const authEnv: Record<string, string> = {}
|
||||||
const consoleManagedProviders = new Set<string>()
|
const consoleManagedProviders = new Set<string>()
|
||||||
let activeOrgName: string | undefined
|
let activeOrgName: string | undefined
|
||||||
|
|
||||||
@@ -480,26 +549,59 @@ export const layer = Layer.effect(
|
|||||||
return mergePluginOrigins(source, next.plugin, kind)
|
return mergePluginOrigins(source, next.plugin, kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const item of yield* authWellKnown.configs().pipe(Effect.orDie)) {
|
for (const [key, value] of Object.entries(auth)) {
|
||||||
yield* merge(
|
if (value.type === "wellknown") {
|
||||||
item.source,
|
const url = key.replace(/\/+$/, "")
|
||||||
yield* loadConfig(JSON.stringify(item.content), { dir: item.dir, source: item.source }),
|
authEnv[value.key] = value.token
|
||||||
"global",
|
const wellknownURL = `${url}/.well-known/opencode`
|
||||||
)
|
log.debug("fetching remote config", { url: wellknownURL })
|
||||||
log.debug("loaded well-known config", { url: item.url })
|
const wellknown = yield* fetchRemoteJson(wellknownURL, undefined, WellKnownConfig)
|
||||||
|
const remote = yield* Effect.promise(() =>
|
||||||
|
substituteWellKnownRemoteConfig({
|
||||||
|
value: wellknown.remote_config,
|
||||||
|
dir: url,
|
||||||
|
source: wellknownURL,
|
||||||
|
env: authEnv,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const fetchedConfig = remote
|
||||||
|
? yield* Effect.gen(function* () {
|
||||||
|
log.debug("fetching remote config", { url: remote.url })
|
||||||
|
const data = yield* fetchRemoteJson(remote.url, remote.headers, Schema.Json)
|
||||||
|
if (isRecord(data) && isRecord(data.config)) return data.config
|
||||||
|
if (isRecord(data)) return data
|
||||||
|
return yield* Effect.die(
|
||||||
|
new Error(`failed to decode remote config from ${remote.url}: expected object`),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
: {}
|
||||||
|
const remoteConfig = mergeConfig(isRecord(wellknown.config) ? wellknown.config : {}, fetchedConfig)
|
||||||
|
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
|
||||||
|
const source = wellknownURL
|
||||||
|
const next = yield* loadConfig(
|
||||||
|
JSON.stringify(remoteConfig),
|
||||||
|
{
|
||||||
|
dir: path.dirname(source),
|
||||||
|
source,
|
||||||
|
},
|
||||||
|
authEnv,
|
||||||
|
)
|
||||||
|
yield* merge(source, next, "global")
|
||||||
|
log.debug("loaded remote config from well-known", { url })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const global = yield* getGlobal()
|
const global = Object.keys(authEnv).length ? yield* loadGlobal(authEnv) : yield* getGlobal()
|
||||||
yield* merge(Global.Path.config, global, "global")
|
yield* merge(Global.Path.config, global, "global")
|
||||||
|
|
||||||
if (Flag.OPENCODE_CONFIG) {
|
if (Flag.OPENCODE_CONFIG) {
|
||||||
yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG))
|
yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG, authEnv))
|
||||||
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
|
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
|
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
|
||||||
for (const file of yield* ConfigPaths.files("opencode", ctx.directory, ctx.worktree).pipe(Effect.orDie)) {
|
for (const file of yield* ConfigPaths.files("opencode", ctx.directory, ctx.worktree).pipe(Effect.orDie)) {
|
||||||
yield* merge(file, yield* loadFile(file), "local")
|
yield* merge(file, yield* loadFile(file, authEnv), "local")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,7 +622,7 @@ export const layer = Layer.effect(
|
|||||||
for (const file of ["opencode.json", "opencode.jsonc"]) {
|
for (const file of ["opencode.json", "opencode.jsonc"]) {
|
||||||
const source = path.join(dir, file)
|
const source = path.join(dir, file)
|
||||||
log.debug(`loading config from ${source}`)
|
log.debug(`loading config from ${source}`)
|
||||||
yield* merge(source, yield* loadFile(source))
|
yield* merge(source, yield* loadFile(source, authEnv))
|
||||||
result.agent ??= {}
|
result.agent ??= {}
|
||||||
result.mode ??= {}
|
result.mode ??= {}
|
||||||
result.plugin ??= []
|
result.plugin ??= []
|
||||||
@@ -773,10 +875,10 @@ export const defaultLayer = layer.pipe(
|
|||||||
Layer.provide(EffectFlock.defaultLayer),
|
Layer.provide(EffectFlock.defaultLayer),
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
Layer.provide(Env.defaultLayer),
|
Layer.provide(Env.defaultLayer),
|
||||||
Layer.provide(AuthWellKnown.defaultLayer),
|
Layer.provide(Auth.defaultLayer),
|
||||||
Layer.provide(Substitution.defaultLayer),
|
|
||||||
Layer.provide(Account.defaultLayer),
|
Layer.provide(Account.defaultLayer),
|
||||||
Layer.provide(Npm.defaultLayer),
|
Layer.provide(Npm.defaultLayer),
|
||||||
|
Layer.provide(FetchHttpClient.layer),
|
||||||
)
|
)
|
||||||
|
|
||||||
export * as Config from "./config"
|
export * as Config from "./config"
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
export * as ConfigVariable from "./variable"
|
||||||
|
|
||||||
|
import path from "path"
|
||||||
|
import os from "os"
|
||||||
|
import { Filesystem } from "@/util/filesystem"
|
||||||
|
import { InvalidError } from "./error"
|
||||||
|
|
||||||
|
type ParseSource =
|
||||||
|
| {
|
||||||
|
type: "path"
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "virtual"
|
||||||
|
source: string
|
||||||
|
dir: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubstituteInput = ParseSource & {
|
||||||
|
text: string
|
||||||
|
missing?: "error" | "empty"
|
||||||
|
env?: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
function source(input: ParseSource) {
|
||||||
|
return input.type === "path" ? input.path : input.source
|
||||||
|
}
|
||||||
|
|
||||||
|
function dir(input: ParseSource) {
|
||||||
|
return input.type === "path" ? path.dirname(input.path) : input.dir
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply {env:VAR} and {file:path} substitutions to config text. */
|
||||||
|
export async function substitute(input: SubstituteInput) {
|
||||||
|
const missing = input.missing ?? "error"
|
||||||
|
let text = input.text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
|
||||||
|
return (input.env?.[varName] ?? process.env[varName]) || ""
|
||||||
|
})
|
||||||
|
|
||||||
|
const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g))
|
||||||
|
if (!fileMatches.length) return text
|
||||||
|
|
||||||
|
const configDir = dir(input)
|
||||||
|
const configSource = source(input)
|
||||||
|
let out = ""
|
||||||
|
let cursor = 0
|
||||||
|
|
||||||
|
for (const match of fileMatches) {
|
||||||
|
const token = match[0]
|
||||||
|
const index = match.index
|
||||||
|
out += text.slice(cursor, index)
|
||||||
|
|
||||||
|
const lineStart = text.lastIndexOf("\n", index - 1) + 1
|
||||||
|
const prefix = text.slice(lineStart, index).trimStart()
|
||||||
|
if (prefix.startsWith("//")) {
|
||||||
|
out += token
|
||||||
|
cursor = index + token.length
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let filePath = token.replace(/^\{file:/, "").replace(/\}$/, "")
|
||||||
|
if (filePath.startsWith("~/")) {
|
||||||
|
filePath = path.join(os.homedir(), filePath.slice(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
|
||||||
|
const fileContent = (
|
||||||
|
await Filesystem.readText(resolvedPath).catch((error: NodeJS.ErrnoException) => {
|
||||||
|
if (missing === "empty") return ""
|
||||||
|
|
||||||
|
const errMsg = `bad file reference: "${token}"`
|
||||||
|
if (error.code === "ENOENT") {
|
||||||
|
throw new InvalidError(
|
||||||
|
{
|
||||||
|
path: configSource,
|
||||||
|
message: errMsg + ` ${resolvedPath} does not exist`,
|
||||||
|
},
|
||||||
|
{ cause: error },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
throw new InvalidError({ path: configSource, message: errMsg }, { cause: error })
|
||||||
|
})
|
||||||
|
).trim()
|
||||||
|
|
||||||
|
out += JSON.stringify(fileContent).slice(1, -1)
|
||||||
|
cursor = index + token.length
|
||||||
|
}
|
||||||
|
|
||||||
|
out += text.slice(cursor)
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ import { attach } from "./run-service"
|
|||||||
import * as Observability from "@opencode-ai/core/effect/observability"
|
import * as Observability from "@opencode-ai/core/effect/observability"
|
||||||
|
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { AuthWellKnown } from "@opencode-ai/core/auth-well-known"
|
|
||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
import { Auth } from "@/auth"
|
import { Auth } from "@/auth"
|
||||||
import { Account } from "@/account/account"
|
import { Account } from "@/account/account"
|
||||||
@@ -63,7 +62,6 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
|
|||||||
export const AppLayer = Layer.mergeAll(
|
export const AppLayer = Layer.mergeAll(
|
||||||
Npm.defaultLayer,
|
Npm.defaultLayer,
|
||||||
AppFileSystem.defaultLayer,
|
AppFileSystem.defaultLayer,
|
||||||
AuthWellKnown.defaultLayer,
|
|
||||||
Bus.defaultLayer,
|
Bus.defaultLayer,
|
||||||
Auth.defaultLayer,
|
Auth.defaultLayer,
|
||||||
Account.defaultLayer,
|
Account.defaultLayer,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { expect } from "bun:test"
|
import { expect } from "bun:test"
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { Substitution } from "@opencode-ai/core/substitution"
|
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { FetchHttpClient } from "effect/unstable/http"
|
import { FetchHttpClient } from "effect/unstable/http"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
@@ -13,7 +12,6 @@ import { RuntimeFlags } from "../../src/effect/runtime-flags"
|
|||||||
import { Plugin } from "../../src/plugin"
|
import { Plugin } from "../../src/plugin"
|
||||||
import { AccountTest } from "../fake/account"
|
import { AccountTest } from "../fake/account"
|
||||||
import { AuthTest } from "../fake/auth"
|
import { AuthTest } from "../fake/auth"
|
||||||
import { AuthWellKnownTest } from "../fake/auth-well-known"
|
|
||||||
import { NpmTest } from "../fake/npm"
|
import { NpmTest } from "../fake/npm"
|
||||||
import { ProviderTest } from "../fake/provider"
|
import { ProviderTest } from "../fake/provider"
|
||||||
import { SkillTest } from "../fake/skill"
|
import { SkillTest } from "../fake/skill"
|
||||||
@@ -28,8 +26,6 @@ const pluginUrl = pathToFileURL(path.join(import.meta.dir, "..", "fixture", "age
|
|||||||
const provider = ProviderTest.fake()
|
const provider = ProviderTest.fake()
|
||||||
const configLayer = Config.layer.pipe(
|
const configLayer = Config.layer.pipe(
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
Layer.provide(AuthWellKnownTest.empty),
|
|
||||||
Layer.provide(Substitution.defaultLayer),
|
|
||||||
Layer.provide(Env.defaultLayer),
|
Layer.provide(Env.defaultLayer),
|
||||||
Layer.provide(AuthTest.empty),
|
Layer.provide(AuthTest.empty),
|
||||||
Layer.provide(AccountTest.empty),
|
Layer.provide(AccountTest.empty),
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
import type { Message, Part } from "@opencode-ai/sdk/v2"
|
|
||||||
import { mergeFetchedMessages, optimisticParts } from "@/cli/cmd/tui/context/sync-optimistic"
|
|
||||||
|
|
||||||
function user(id: string): Message {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
sessionID: "ses_test",
|
|
||||||
role: "user",
|
|
||||||
time: { created: 1 },
|
|
||||||
agent: "build",
|
|
||||||
model: { providerID: "test", modelID: "model" },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function text(messageID: string, text: string): Part {
|
|
||||||
return {
|
|
||||||
id: `part_${messageID}`,
|
|
||||||
sessionID: "ses_test",
|
|
||||||
messageID,
|
|
||||||
type: "text",
|
|
||||||
text,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("TUI optimistic prompt sync", () => {
|
|
||||||
test("keeps an optimistic message while session sync has not fetched it yet", () => {
|
|
||||||
const merged = mergeFetchedMessages({
|
|
||||||
currentMessages: [user("msg_2")],
|
|
||||||
currentParts: { msg_2: [text("msg_2", "optimistic")] },
|
|
||||||
fetched: [{ info: user("msg_1"), parts: [text("msg_1", "persisted")] }],
|
|
||||||
optimisticMessages: new Set(["msg_2"]),
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(merged.messages.map((message) => message.id)).toEqual(["msg_1", "msg_2"])
|
|
||||||
expect(merged.parts.get("msg_1")?.map((part) => (part.type === "text" ? part.text : ""))).toEqual(["persisted"])
|
|
||||||
expect(merged.resolved.has("msg_2")).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("preserves optimistic parts when sync fetches the message before its parts", () => {
|
|
||||||
const merged = mergeFetchedMessages({
|
|
||||||
currentMessages: [user("msg_1")],
|
|
||||||
currentParts: { msg_1: [text("msg_1", "optimistic")] },
|
|
||||||
fetched: [{ info: user("msg_1"), parts: [] }],
|
|
||||||
optimisticMessages: new Set(["msg_1"]),
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(merged.messages.map((message) => message.id)).toEqual(["msg_1"])
|
|
||||||
expect(merged.parts.get("msg_1")?.map((part) => (part.type === "text" ? part.text : ""))).toEqual(["optimistic"])
|
|
||||||
expect(merged.resolved.has("msg_1")).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("replaces optimistic parts once real fetched parts arrive", () => {
|
|
||||||
const merged = mergeFetchedMessages({
|
|
||||||
currentMessages: [user("msg_1")],
|
|
||||||
currentParts: { msg_1: [text("msg_1", "optimistic")] },
|
|
||||||
fetched: [{ info: user("msg_1"), parts: [text("msg_1", "persisted")] }],
|
|
||||||
optimisticMessages: new Set(["msg_1"]),
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(merged.parts.get("msg_1")?.map((part) => (part.type === "text" ? part.text : ""))).toEqual(["persisted"])
|
|
||||||
expect(merged.resolved.has("msg_1")).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("strips file URLs from optimistic render parts", () => {
|
|
||||||
const parts = optimisticParts({
|
|
||||||
sessionID: "ses_test",
|
|
||||||
messageID: "msg_1",
|
|
||||||
parts: [
|
|
||||||
{
|
|
||||||
id: "part_file",
|
|
||||||
type: "file",
|
|
||||||
mime: "image/png",
|
|
||||||
filename: "image.png",
|
|
||||||
url: "data:image/png;base64,large",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(parts).toEqual([
|
|
||||||
{
|
|
||||||
id: "part_file",
|
|
||||||
sessionID: "ses_test",
|
|
||||||
messageID: "msg_1",
|
|
||||||
type: "file",
|
|
||||||
mime: "image/png",
|
|
||||||
filename: "image.png",
|
|
||||||
url: "",
|
|
||||||
},
|
|
||||||
])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
|
|
||||||
|
|
||||||
exports[`TUI inline tool wrapping snapshots consecutive grep, glob, and read rows at a narrow width 1`] = `
|
|
||||||
" ✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.
|
|
||||||
*dir|xdg|APPDATA" in packages/opencode/src (151 matches)
|
|
||||||
✱ Glob "**/*db*" in packages/opencode (6 matches)
|
|
||||||
→ Read packages/opencode/src/storage/db.ts [offset=1, limit=130]
|
|
||||||
→ Read packages/opencode/src/index.ts [offset=1, limit=100]
|
|
||||||
✱ Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\.
|
|
||||||
Path\\.data|data =" in packages/opencode/src (115 matches)"
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`TUI inline tool wrapping snapshots expanded tool errors under the tool text 1`] = `
|
|
||||||
" ✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.
|
|
||||||
*dir|xdg|APPDATA" in packages/opencode/src (151 matches)
|
|
||||||
✱ Glob "**/*db*" in packages/opencode (6 matches)
|
|
||||||
→ Read packages/opencode/src/storage/db.ts [offset=1, limit=130]
|
|
||||||
→ Read packages/opencode/src/index.ts [offset=1, limit=100]
|
|
||||||
No LSP server available for this file type.
|
|
||||||
✱ Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\.
|
|
||||||
Path\\.data|data =" in packages/opencode/src (115 matches)"
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`TUI inline tool wrapping keeps separation after a shell output block 1`] = `
|
|
||||||
"
|
|
||||||
|
|
||||||
# List files
|
|
||||||
|
|
||||||
$ ls
|
|
||||||
|
|
||||||
file.ts
|
|
||||||
|
|
||||||
|
|
||||||
✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.
|
|
||||||
*dir|xdg|APPDATA" in packages/opencode/src (151 matches)
|
|
||||||
✱ Glob "**/*db*" in packages/opencode (6 matches)
|
|
||||||
→ Read packages/opencode/src/storage/db.ts [offset=1, limit=130]
|
|
||||||
→ Read packages/opencode/src/index.ts [offset=1, limit=100]
|
|
||||||
✱ Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\.
|
|
||||||
Path\\.data|data =" in packages/opencode/src (115 matches)"
|
|
||||||
`;
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
import { afterEach, describe, expect, test } from "bun:test"
|
|
||||||
import { createSignal, For } from "solid-js"
|
|
||||||
import { testRender } from "@opentui/solid"
|
|
||||||
|
|
||||||
let testSetup: Awaited<ReturnType<typeof testRender>> | undefined
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
testSetup?.renderer.destroy()
|
|
||||||
testSetup = undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
type ToolFixture = { icon: string; label: string; error?: string }
|
|
||||||
|
|
||||||
const INLINE_TOOL_ICON_WIDTH = 2
|
|
||||||
|
|
||||||
const tools: readonly ToolFixture[] = [
|
|
||||||
{
|
|
||||||
icon: "✱",
|
|
||||||
label:
|
|
||||||
'Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.*dir|xdg|APPDATA" in packages/opencode/src (151 matches)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: "✱",
|
|
||||||
label: 'Glob "**/*db*" in packages/opencode (6 matches)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: "→",
|
|
||||||
label: "Read packages/opencode/src/storage/db.ts [offset=1, limit=130]",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: "→",
|
|
||||||
label: "Read packages/opencode/src/index.ts [offset=1, limit=100]",
|
|
||||||
error: "No LSP server available for this file type.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: "✱",
|
|
||||||
label:
|
|
||||||
'Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\.Path\\.data|data =" in packages/opencode/src (115 matches)',
|
|
||||||
},
|
|
||||||
] as const
|
|
||||||
|
|
||||||
function InlineToolRow(props: { item: ToolFixture; errorExpanded?: boolean }) {
|
|
||||||
const [margin, setMargin] = createSignal(0)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<box
|
|
||||||
marginTop={margin()}
|
|
||||||
paddingLeft={3}
|
|
||||||
renderBefore={function () {
|
|
||||||
const parent = this.parent
|
|
||||||
if (!parent) return
|
|
||||||
const previous = parent.getChildren()[parent.getChildren().indexOf(this) - 1]
|
|
||||||
setMargin(previous?.id.startsWith("text-") || previous?.id.startsWith("tool-block-") ? 1 : 0)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<box flexDirection="row">
|
|
||||||
<text width={INLINE_TOOL_ICON_WIDTH}>{props.item.icon}</text>
|
|
||||||
<text flexGrow={1}>{props.item.label}</text>
|
|
||||||
</box>
|
|
||||||
{props.item.error && props.errorExpanded && (
|
|
||||||
<box paddingLeft={INLINE_TOOL_ICON_WIDTH}>
|
|
||||||
<text>{props.item.error}</text>
|
|
||||||
</box>
|
|
||||||
)}
|
|
||||||
</box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ShellOutput() {
|
|
||||||
return (
|
|
||||||
<box id="tool-block-shell" marginTop={1} paddingTop={1} paddingBottom={1} paddingLeft={2} gap={1}>
|
|
||||||
<text paddingLeft={3}># List files</text>
|
|
||||||
<box gap={1}>
|
|
||||||
<text>$ ls</text>
|
|
||||||
<text>file.ts</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Fixture(props: { errorExpanded?: boolean; shellOutput?: boolean }) {
|
|
||||||
return (
|
|
||||||
<box flexDirection="column" width={72}>
|
|
||||||
<box flexDirection="column">
|
|
||||||
{props.shellOutput && <ShellOutput />}
|
|
||||||
<For each={tools}>{(item) => <InlineToolRow item={item} errorExpanded={props.errorExpanded} />}</For>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("TUI inline tool wrapping", () => {
|
|
||||||
test("snapshots consecutive grep, glob, and read rows at a narrow width", async () => {
|
|
||||||
testSetup = await testRender(() => <Fixture />, { width: 72, height: 12 })
|
|
||||||
await testSetup.renderOnce()
|
|
||||||
await testSetup.renderOnce()
|
|
||||||
|
|
||||||
expect(
|
|
||||||
testSetup
|
|
||||||
.captureCharFrame()
|
|
||||||
.split("\n")
|
|
||||||
.map((line) => line.trimEnd())
|
|
||||||
.join("\n")
|
|
||||||
.trimEnd(),
|
|
||||||
).toMatchSnapshot()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("snapshots expanded tool errors under the tool text", async () => {
|
|
||||||
testSetup = await testRender(() => <Fixture errorExpanded />, { width: 72, height: 12 })
|
|
||||||
await testSetup.renderOnce()
|
|
||||||
await testSetup.renderOnce()
|
|
||||||
|
|
||||||
expect(
|
|
||||||
testSetup
|
|
||||||
.captureCharFrame()
|
|
||||||
.split("\n")
|
|
||||||
.map((line) => line.trimEnd())
|
|
||||||
.join("\n")
|
|
||||||
.trimEnd(),
|
|
||||||
).toMatchSnapshot()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("keeps separation after a shell output block", async () => {
|
|
||||||
testSetup = await testRender(() => <Fixture shellOutput />, { width: 72, height: 16 })
|
|
||||||
await testSetup.renderOnce()
|
|
||||||
await testSetup.renderOnce()
|
|
||||||
|
|
||||||
expect(
|
|
||||||
testSetup
|
|
||||||
.captureCharFrame()
|
|
||||||
.split("\n")
|
|
||||||
.map((line) => line.trimEnd())
|
|
||||||
.join("\n")
|
|
||||||
.trimEnd(),
|
|
||||||
).toMatchSnapshot()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -6,7 +6,6 @@ import { Config } from "@/config/config"
|
|||||||
import { ConfigManaged } from "@/config/managed"
|
import { ConfigManaged } from "@/config/managed"
|
||||||
import { ConfigParse } from "../../src/config/parse"
|
import { ConfigParse } from "../../src/config/parse"
|
||||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||||
import { Substitution } from "@opencode-ai/core/substitution"
|
|
||||||
|
|
||||||
import { InstanceRef } from "../../src/effect/instance-ref"
|
import { InstanceRef } from "../../src/effect/instance-ref"
|
||||||
import type { InstanceContext } from "../../src/project/instance-context"
|
import type { InstanceContext } from "../../src/project/instance-context"
|
||||||
@@ -37,7 +36,6 @@ import { Filesystem } from "@/util/filesystem"
|
|||||||
import { ConfigPlugin } from "@/config/plugin"
|
import { ConfigPlugin } from "@/config/plugin"
|
||||||
import { AccountTest } from "../fake/account"
|
import { AccountTest } from "../fake/account"
|
||||||
import { AuthTest } from "../fake/auth"
|
import { AuthTest } from "../fake/auth"
|
||||||
import { AuthWellKnownTest } from "../fake/auth-well-known"
|
|
||||||
import { NpmTest } from "../fake/npm"
|
import { NpmTest } from "../fake/npm"
|
||||||
|
|
||||||
/** Infra layer that provides FileSystem, Path, ChildProcessSpawner for test fixtures */
|
/** Infra layer that provides FileSystem, Path, ChildProcessSpawner for test fixtures */
|
||||||
@@ -96,9 +94,7 @@ const configLayer = (
|
|||||||
) =>
|
) =>
|
||||||
Config.layer.pipe(
|
Config.layer.pipe(
|
||||||
Layer.provide(testFlock),
|
Layer.provide(testFlock),
|
||||||
Layer.provide(Substitution.defaultLayer),
|
|
||||||
Layer.provide(Env.defaultLayer),
|
Layer.provide(Env.defaultLayer),
|
||||||
Layer.provide(AuthWellKnownTest.empty),
|
|
||||||
Layer.provide(options.auth ?? AuthTest.empty),
|
Layer.provide(options.auth ?? AuthTest.empty),
|
||||||
Layer.provide(options.account ?? AccountTest.empty),
|
Layer.provide(options.account ?? AccountTest.empty),
|
||||||
Layer.provideMerge(infra),
|
Layer.provideMerge(infra),
|
||||||
@@ -1507,9 +1503,7 @@ test("remote well-known config can use FetchHttpClient layer", async () => {
|
|||||||
Config.layer.pipe(
|
Config.layer.pipe(
|
||||||
Layer.provide(testFlock),
|
Layer.provide(testFlock),
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
Layer.provide(Substitution.defaultLayer),
|
|
||||||
Layer.provide(Env.defaultLayer),
|
Layer.provide(Env.defaultLayer),
|
||||||
Layer.provide(AuthWellKnownTest.empty),
|
|
||||||
Layer.provide(wellKnownAuth(server.url.origin)),
|
Layer.provide(wellKnownAuth(server.url.origin)),
|
||||||
Layer.provide(AccountTest.empty),
|
Layer.provide(AccountTest.empty),
|
||||||
Layer.provideMerge(infra),
|
Layer.provideMerge(infra),
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
import { AuthWellKnown } from "@opencode-ai/core/auth-well-known"
|
|
||||||
import { Effect, Layer } from "effect"
|
|
||||||
|
|
||||||
export const AuthWellKnownTest = {
|
|
||||||
empty: Layer.mock(AuthWellKnown.Service, {
|
|
||||||
configs: () => Effect.succeed([]),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,6 @@ import { FetchHttpClient } from "effect/unstable/http"
|
|||||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||||
import { Substitution } from "@opencode-ai/core/substitution"
|
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { pathToFileURL } from "url"
|
import { pathToFileURL } from "url"
|
||||||
import { Bus } from "../../src/bus"
|
import { Bus } from "../../src/bus"
|
||||||
@@ -18,13 +17,10 @@ import { testEffect } from "../lib/effect"
|
|||||||
import { AccountTest } from "../fake/account"
|
import { AccountTest } from "../fake/account"
|
||||||
import { AuthTest } from "../fake/auth"
|
import { AuthTest } from "../fake/auth"
|
||||||
import { NpmTest } from "../fake/npm"
|
import { NpmTest } from "../fake/npm"
|
||||||
import { AuthWellKnownTest } from "../fake/auth-well-known"
|
|
||||||
|
|
||||||
const configLayer = Config.layer.pipe(
|
const configLayer = Config.layer.pipe(
|
||||||
Layer.provide(EffectFlock.defaultLayer),
|
Layer.provide(EffectFlock.defaultLayer),
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
Layer.provide(AuthWellKnownTest.empty),
|
|
||||||
Layer.provide(Substitution.defaultLayer),
|
|
||||||
Layer.provide(Env.defaultLayer),
|
Layer.provide(Env.defaultLayer),
|
||||||
Layer.provide(AuthTest.empty),
|
Layer.provide(AuthTest.empty),
|
||||||
Layer.provide(AccountTest.empty),
|
Layer.provide(AccountTest.empty),
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { FetchHttpClient } from "effect/unstable/http"
|
|||||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||||
import { Substitution } from "@opencode-ai/core/substitution"
|
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { pathToFileURL } from "url"
|
import { pathToFileURL } from "url"
|
||||||
import { Auth } from "../../src/auth"
|
import { Auth } from "../../src/auth"
|
||||||
@@ -27,13 +26,10 @@ import { testEffect } from "../lib/effect"
|
|||||||
import { AccountTest } from "../fake/account"
|
import { AccountTest } from "../fake/account"
|
||||||
import { AuthTest } from "../fake/auth"
|
import { AuthTest } from "../fake/auth"
|
||||||
import { NpmTest } from "../fake/npm"
|
import { NpmTest } from "../fake/npm"
|
||||||
import { AuthWellKnownTest } from "../fake/auth-well-known"
|
|
||||||
|
|
||||||
const configLayer = Config.layer.pipe(
|
const configLayer = Config.layer.pipe(
|
||||||
Layer.provide(EffectFlock.defaultLayer),
|
Layer.provide(EffectFlock.defaultLayer),
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
Layer.provide(AuthWellKnownTest.empty),
|
|
||||||
Layer.provide(Substitution.defaultLayer),
|
|
||||||
Layer.provide(Env.defaultLayer),
|
Layer.provide(Env.defaultLayer),
|
||||||
Layer.provide(AuthTest.empty),
|
Layer.provide(AuthTest.empty),
|
||||||
Layer.provide(AccountTest.empty),
|
Layer.provide(AccountTest.empty),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"start": "vite start"
|
"start": "vite start"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ibm/plex": "6.4.1",
|
||||||
"@opencode-ai/stats-core": "workspace:*",
|
"@opencode-ai/stats-core": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
"@solidjs/meta": "catalog:",
|
"@solidjs/meta": "catalog:",
|
||||||
|
|||||||
@@ -1,3 +1,47 @@
|
|||||||
|
@font-face {
|
||||||
|
font-family: "IBM Plex Mono";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Regular-Latin1.woff2") format("woff2");
|
||||||
|
unicode-range:
|
||||||
|
U+0020-007E, U+00A0-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2013-2014, U+2018-201A, U+201C-201E,
|
||||||
|
U+2020-2022, U+2026, U+2030, U+2039-203A, U+2044, U+20AC, U+2122, U+2212, U+FB01-FB02;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "IBM Plex Mono";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Medium-Latin1.woff2") format("woff2");
|
||||||
|
unicode-range:
|
||||||
|
U+0020-007E, U+00A0-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2013-2014, U+2018-201A, U+201C-201E,
|
||||||
|
U+2020-2022, U+2026, U+2030, U+2039-203A, U+2044, U+20AC, U+2122, U+2212, U+FB01-FB02;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "IBM Plex Mono";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-SemiBold-Latin1.woff2") format("woff2");
|
||||||
|
unicode-range:
|
||||||
|
U+0020-007E, U+00A0-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2013-2014, U+2018-201A, U+201C-201E,
|
||||||
|
U+2020-2022, U+2026, U+2030, U+2039-203A, U+2044, U+20AC, U+2122, U+2212, U+FB01-FB02;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "IBM Plex Mono";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Bold-Latin1.woff2") format("woff2");
|
||||||
|
unicode-range:
|
||||||
|
U+0020-007E, U+00A0-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2013-2014, U+2018-201A, U+201C-201E,
|
||||||
|
U+2020-2022, U+2026, U+2030, U+2039-203A, U+2044, U+20AC, U+2122, U+2212, U+FB01-FB02;
|
||||||
|
}
|
||||||
|
|
||||||
[data-page="stats"] {
|
[data-page="stats"] {
|
||||||
--color-background: #ffffff;
|
--color-background: #ffffff;
|
||||||
--color-background-weak: #fafafa;
|
--color-background-weak: #fafafa;
|
||||||
@@ -21,12 +65,16 @@
|
|||||||
--stats-accent-text: #6c7dff;
|
--stats-accent-text: #6c7dff;
|
||||||
--stats-bar-idle: #d4d4d4;
|
--stats-bar-idle: #d4d4d4;
|
||||||
--stats-dot: #d4d4d4;
|
--stats-dot: #d4d4d4;
|
||||||
|
--stats-hero-muted: #5c5c5c;
|
||||||
|
--stats-hero-pattern: #eeeeee;
|
||||||
--stats-page-padding: 5rem;
|
--stats-page-padding: 5rem;
|
||||||
--stats-section-padding: 6rem;
|
--stats-section-padding: 6rem;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4rem;
|
gap: 4rem;
|
||||||
|
font-synthesis: none;
|
||||||
|
overflow-x: clip;
|
||||||
padding-bottom: 5rem;
|
padding-bottom: 5rem;
|
||||||
background: var(--stats-bg);
|
background: var(--stats-bg);
|
||||||
}
|
}
|
||||||
@@ -39,7 +87,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-page="stats"] [data-component="container"] {
|
[data-page="stats"] [data-component="container"] {
|
||||||
max-width: 67.5rem;
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
max-width: calc(80rem + 2px);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
border-left: 1px solid var(--stats-line);
|
border-left: 1px solid var(--stats-line);
|
||||||
border-right: 1px solid var(--stats-line);
|
border-right: 1px solid var(--stats-line);
|
||||||
@@ -49,74 +99,245 @@
|
|||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
display: flex;
|
box-sizing: border-box;
|
||||||
align-items: center;
|
width: 100%;
|
||||||
justify-content: space-between;
|
color: var(--stats-text);
|
||||||
height: 80px;
|
|
||||||
min-height: 80px;
|
|
||||||
padding: 24px var(--stats-page-padding);
|
|
||||||
color: var(--stats-muted);
|
|
||||||
background: var(--stats-bg);
|
background: var(--stats-bg);
|
||||||
border-bottom: 1px solid var(--stats-line);
|
|
||||||
font-family:
|
font-family:
|
||||||
"IBM Plex Mono",
|
"IBM Plex Mono",
|
||||||
var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);
|
var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-page="stats"] [data-component="top"] * {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-page="stats"] [data-slot="header-bar"] {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
min-height: 72px;
|
||||||
|
padding: 20px 20px 20px 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
[data-page="stats"] [data-component="top"] a {
|
[data-page="stats"] [data-component="top"] a {
|
||||||
color: var(--stats-text);
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 18px;
|
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-page="stats"] [data-component="top"] a:hover {
|
[data-page="stats"] [data-component="top"] a:hover {
|
||||||
text-decoration: underline;
|
text-decoration: none;
|
||||||
text-underline-offset: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-page="stats"] [data-slot="brand"] {
|
[data-page="stats"] [data-slot="brand"] {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
margin-right: auto;
|
||||||
|
color: var(--stats-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-page="stats"] [data-slot="brand"] img {
|
[data-page="stats"] [data-slot="stats-wordmark"] {
|
||||||
width: auto;
|
display: flex;
|
||||||
height: 34px;
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-page="stats"] [data-slot="logo dark"] {
|
[data-page="stats"] [data-slot="brand-mark"] {
|
||||||
|
display: block;
|
||||||
|
width: 19px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-page="stats"] [data-slot="brand-label"] {
|
||||||
|
display: block;
|
||||||
|
width: 50.851px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-page="stats"] [data-component="section-nav"] {
|
||||||
display: none;
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-page="stats"] [data-component="nav-desktop"] ul {
|
[data-page="stats"] [data-component="section-nav"] ul {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 32px;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-page="stats"] [data-component="nav-desktop"] a span {
|
[data-page="stats"] [data-component="section-nav"] a {
|
||||||
color: var(--stats-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-page="stats"] [data-component="nav-desktop"] [data-slot="cta-button"] {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 15px;
|
||||||
|
color: var(--stats-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-page="stats"] [data-component="section-nav"] a:hover {
|
||||||
|
color: var(--stats-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-page="stats"] [data-slot="header-actions"] {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 8px 16px 8px 10px;
|
}
|
||||||
border-radius: 4px;
|
|
||||||
background: var(--color-background-strong);
|
[data-page="stats"] [data-slot="header-button"],
|
||||||
color: var(--color-text-inverted);
|
[data-page="stats"] [data-slot="menu-button"] {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 32px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
appearance: none;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-page="stats"] [data-slot="header-button"]::before,
|
||||||
|
[data-page="stats"] [data-slot="menu-button"]::before {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 16px;
|
||||||
|
pointer-events: none;
|
||||||
|
content: "";
|
||||||
|
background: linear-gradient(180deg, #ffffff00 0%, #ffffff12 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-page="stats"] [data-slot="header-button"] {
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-page="stats"] [data-slot="header-button"] strong,
|
||||||
|
[data-page="stats"] [data-slot="header-button"] span,
|
||||||
|
[data-page="stats"] [data-slot="menu-button"] svg {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-page="stats"] [data-slot="header-button"] strong {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-page="stats"] [data-component="nav-desktop"] [data-slot="cta-button"]:hover {
|
[data-page="stats"] [data-slot="header-button"][data-variant="neutral"] {
|
||||||
background: var(--color-background-strong-hover);
|
display: none;
|
||||||
text-decoration: none;
|
gap: 6px;
|
||||||
|
color: #161616;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 0 #00000024,
|
||||||
|
0 0 0 0.5px #00000024,
|
||||||
|
0 1px 1.5px 0 #0000001a;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-page="stats"] [data-slot="header-button"][data-variant="neutral"] span {
|
||||||
|
color: #5c5c5c;
|
||||||
|
font-weight: 400;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-page="stats"] [data-slot="header-button"][data-variant="contrast"] {
|
||||||
|
gap: 8px;
|
||||||
|
color: #ffffff;
|
||||||
|
background: #242424;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 0 #00000000,
|
||||||
|
0 0 0 0.5px #3a3a3a,
|
||||||
|
0 1px 1.5px 0 #00000033,
|
||||||
|
inset 0 -1px 2px 0 #0000000f,
|
||||||
|
inset 0 1px 2px 0 #ffffff24;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-page="stats"] [data-slot="menu-button"] {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 32px;
|
||||||
|
padding: 0;
|
||||||
|
color: #3a3a3a;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 0 #00000024,
|
||||||
|
0 0 0 0.5px #00000024,
|
||||||
|
0 1px 1.5px 0 #0000001a;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-page="stats"] [data-slot="header-button"]:focus-visible,
|
||||||
|
[data-page="stats"] [data-slot="menu-button"]:focus-visible,
|
||||||
|
[data-page="stats"] [data-component="section-nav"] a:focus-visible,
|
||||||
|
[data-page="stats"] [data-slot="mobile-menu-item"]:focus-visible,
|
||||||
|
[data-page="stats"] [data-slot="brand"]:focus-visible {
|
||||||
|
outline: 2px solid var(--stats-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-page="stats"] [data-slot="mobile-menu"] {
|
||||||
|
position: fixed;
|
||||||
|
top: 72px;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 9;
|
||||||
|
display: none;
|
||||||
|
overflow: auto;
|
||||||
|
background: var(--stats-bg);
|
||||||
|
border-top: 1px solid var(--stats-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-page="stats"] [data-menu-open="true"] [data-slot="mobile-menu"]:not([hidden]) {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-page="stats"] [data-slot="mobile-menu-item"] {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 65px;
|
||||||
|
padding: 24px;
|
||||||
|
color: var(--stats-text);
|
||||||
|
border-bottom: 1px solid var(--stats-line);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 16px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-page="stats"] [data-slot="mobile-menu-item"]:hover {
|
||||||
|
color: var(--stats-text);
|
||||||
|
background: var(--stats-layer);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-page="stats"] [data-slot="mobile-menu-item"] strong {
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-page="stats"] [data-slot="mobile-menu-item"] span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--stats-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-page="stats"] [data-component="footer"] {
|
[data-page="stats"] [data-component="footer"] {
|
||||||
@@ -192,11 +413,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-page="stats"] [data-section="hero"] {
|
[data-page="stats"] [data-section="hero"] {
|
||||||
min-height: 270px;
|
box-sizing: border-box;
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: 1fr 1fr;
|
flex-direction: column;
|
||||||
gap: 64px;
|
align-items: flex-start;
|
||||||
align-items: start;
|
justify-content: flex-end;
|
||||||
|
gap: 24px;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 128px 24px 48px;
|
||||||
|
border-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-page="stats"] h1,
|
[data-page="stats"] h1,
|
||||||
@@ -210,6 +436,7 @@
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
letter-spacing: normal;
|
letter-spacing: normal;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
max-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-page="stats"] h2 {
|
[data-page="stats"] h2 {
|
||||||
@@ -219,52 +446,146 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-page="stats"] [data-section="hero"] > p,
|
|
||||||
[data-page="stats"] [data-slot="section-header"] p {
|
[data-page="stats"] [data-slot="section-header"] p {
|
||||||
color: var(--stats-muted);
|
color: var(--stats-muted);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-page="stats"] [data-section="hero"] > div {
|
[data-page="stats"] [data-slot="hero-canvas"] {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 24px;
|
||||||
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-page="stats"] [data-slot="meta"] {
|
[data-page="stats"] [data-section="hero"] h1 {
|
||||||
|
order: 1;
|
||||||
|
color: var(--stats-text);
|
||||||
|
font-size: 64px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-page="stats"] [data-slot="hero-copy"] {
|
||||||
|
order: 3;
|
||||||
|
color: var(--stats-hero-muted);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-page="stats"] [data-slot="hero-copy-break"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-page="stats"] [data-slot="hero-pattern"] {
|
||||||
|
order: 2;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
height: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--stats-hero-pattern);
|
||||||
|
mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
|
||||||
|
mask-repeat: repeat;
|
||||||
|
mask-size: 6px 6px;
|
||||||
|
-webkit-mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
|
||||||
|
-webkit-mask-repeat: repeat;
|
||||||
|
-webkit-mask-size: 6px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-page="stats"] [data-slot="hero-meta"] {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
|
max-width: 100%;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
padding: 0 8px 0 4px;
|
padding: 0 8px 0 4px;
|
||||||
background: var(--stats-layer-2);
|
background: var(--stats-layer-2);
|
||||||
color: var(--stats-faint);
|
color: var(--stats-hero-muted);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-page="stats"] [data-slot="meta"] svg {
|
[data-page="stats"] [data-slot="hero-meta"] svg {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
color: var(--stats-faint);
|
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-page="stats"] [data-slot="meta"] span {
|
[data-page="stats"] [data-slot="hero-meta"] span {
|
||||||
color: var(--stats-muted);
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-page="stats"] [data-slot="meta"] b,
|
@media (min-width: 48rem) {
|
||||||
[data-page="stats"] [data-slot="meta"] em {
|
[data-page="stats"] [data-section="hero"] {
|
||||||
color: var(--stats-faint);
|
gap: 16px;
|
||||||
font-style: normal;
|
padding: 128px 32px 32px;
|
||||||
font-weight: 600;
|
}
|
||||||
|
|
||||||
|
[data-page="stats"] [data-slot="hero-canvas"] {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
height: 232px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-page="stats"] [data-slot="hero-pattern"] {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 1280px;
|
||||||
|
height: 351px;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-page="stats"] [data-section="hero"] h1 {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 1;
|
||||||
|
width: max-content;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0 12px 12px 0;
|
||||||
|
background: var(--stats-bg);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-page="stats"] [data-slot="hero-copy"] {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 1;
|
||||||
|
width: min(563px, 100%);
|
||||||
|
padding: 12px 0 0 16px;
|
||||||
|
background: var(--stats-bg);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-page="stats"] [data-slot="hero-copy-break"] {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 75rem) {
|
||||||
|
[data-page="stats"] [data-section="hero"] {
|
||||||
|
padding: 128px 40px 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 90rem) {
|
||||||
|
[data-page="stats"] [data-section="hero"] {
|
||||||
|
padding-right: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-page="stats"] [data-section="chart"] {
|
[data-page="stats"] [data-section="chart"] {
|
||||||
@@ -1154,6 +1475,8 @@
|
|||||||
--stats-faint: #808080;
|
--stats-faint: #808080;
|
||||||
--stats-bar-idle: #303030;
|
--stats-bar-idle: #303030;
|
||||||
--stats-dot: #303030;
|
--stats-dot: #303030;
|
||||||
|
--stats-hero-muted: #808080;
|
||||||
|
--stats-hero-pattern: #303030;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-page="stats"] [data-component="chart-tooltip"],
|
[data-page="stats"] [data-component="chart-tooltip"],
|
||||||
@@ -1168,44 +1491,82 @@
|
|||||||
0 2px 4px #0000003d;
|
0 2px 4px #0000003d;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-page="stats"] [data-slot="logo light"] {
|
[data-page="stats"] [data-slot="header-button"][data-variant="neutral"],
|
||||||
display: none;
|
[data-page="stats"] [data-slot="menu-button"] {
|
||||||
|
color: #fafafa;
|
||||||
|
background: #ffffff0f;
|
||||||
|
box-shadow:
|
||||||
|
0 -0.5px 0 0 #ffffff33,
|
||||||
|
0 0 0 0.5px #ffffff33,
|
||||||
|
0 1px 2px 0 #00000066;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-page="stats"] [data-slot="logo dark"] {
|
[data-page="stats"] [data-slot="header-button"][data-variant="neutral"] span {
|
||||||
display: block;
|
color: #aeaeae;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-page="stats"] [data-slot="header-button"][data-variant="contrast"] {
|
||||||
|
color: #ffffff;
|
||||||
|
background: #5c5c5c;
|
||||||
|
box-shadow:
|
||||||
|
0 -0.5px 0 0 #ffffff4d,
|
||||||
|
0 0 0 0.5px #ffffff66,
|
||||||
|
0 1px 2px 0 #00000066;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 74rem) {
|
@media (max-width: 80rem) {
|
||||||
[data-page="stats"] [data-component="container"] {
|
[data-page="stats"] [data-component="container"] {
|
||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (min-width: 48rem) {
|
||||||
|
[data-page="stats"] [data-slot="header-button"][data-variant="neutral"] {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 75rem) {
|
||||||
|
[data-page="stats"] [data-slot="header-bar"] {
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-page="stats"] [data-slot="brand"],
|
||||||
|
[data-page="stats"] [data-component="section-nav"],
|
||||||
|
[data-page="stats"] [data-slot="header-actions"] {
|
||||||
|
flex: 1 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-page="stats"] [data-slot="brand"] {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-page="stats"] [data-component="section-nav"] {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-page="stats"] [data-slot="menu-button"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-page="stats"] [data-slot="mobile-menu"] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 58rem) {
|
@media (max-width: 58rem) {
|
||||||
[data-page="stats"] {
|
[data-page="stats"] {
|
||||||
--stats-page-padding: 24px;
|
--stats-page-padding: 24px;
|
||||||
--stats-section-padding: 4rem;
|
--stats-section-padding: 4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-page="stats"] [data-component="top"] {
|
|
||||||
padding-left: 24px;
|
|
||||||
padding-right: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-page="stats"] [data-component="nav-desktop"] ul {
|
|
||||||
gap: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-page="stats"] [data-section="hero"],
|
|
||||||
[data-page="stats"] [data-section="chart"],
|
[data-page="stats"] [data-section="chart"],
|
||||||
[data-page="stats"] [data-section="newsletter"] {
|
[data-page="stats"] [data-section="newsletter"] {
|
||||||
padding-left: 24px;
|
padding-left: 24px;
|
||||||
padding-right: 24px;
|
padding-right: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-page="stats"] [data-section="hero"],
|
|
||||||
[data-page="stats"] [data-section="newsletter"] {
|
[data-page="stats"] [data-section="newsletter"] {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -1301,10 +1662,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 40rem) {
|
@media (max-width: 40rem) {
|
||||||
[data-page="stats"] [data-component="nav-desktop"] li:not(:last-child) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-page="stats"] [data-component="footer"],
|
[data-page="stats"] [data-component="footer"],
|
||||||
[data-page="stats"] [data-component="legal"] {
|
[data-page="stats"] [data-component="legal"] {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import "./index.css"
|
import "./index.css"
|
||||||
import { Meta, Title } from "@solidjs/meta"
|
import { Link, Meta, Title } from "@solidjs/meta"
|
||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
|
import ibmPlexMonoRegularLatin1 from "@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Regular-Latin1.woff2?url"
|
||||||
|
import ibmPlexMonoMediumLatin1 from "@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Medium-Latin1.woff2?url"
|
||||||
|
import ibmPlexMonoSemiBoldLatin1 from "@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-SemiBold-Latin1.woff2?url"
|
||||||
|
import ibmPlexMonoBoldLatin1 from "@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Bold-Latin1.woff2?url"
|
||||||
import {
|
import {
|
||||||
type CountryEntry,
|
type CountryEntry,
|
||||||
getStatsHomeData,
|
getStatsHomeData,
|
||||||
@@ -14,14 +18,19 @@ import {
|
|||||||
import { runtime } from "@opencode-ai/stats-core/runtime"
|
import { runtime } from "@opencode-ai/stats-core/runtime"
|
||||||
import { createAsync, query } from "@solidjs/router"
|
import { createAsync, query } from "@solidjs/router"
|
||||||
import { scaleBand, scaleLinear } from "d3-scale"
|
import { scaleBand, scaleLinear } from "d3-scale"
|
||||||
import { createMemo, createSignal, For, Show, type JSX } from "solid-js"
|
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show, type JSX } from "solid-js"
|
||||||
import { getRequestEvent } from "solid-js/web"
|
import { getRequestEvent } from "solid-js/web"
|
||||||
import logoDark from "../asset/logo-ornate-dark.svg"
|
|
||||||
import logoLight from "../asset/logo-ornate-light.svg"
|
|
||||||
|
|
||||||
const products = ["All Users", "Zen", "Go", "Enterprise"] as const
|
const products = ["All Users", "Zen", "Go", "Enterprise"] as const
|
||||||
const tokenProducts = ["Zen", "Go", "Enterprise"] as const
|
const tokenProducts = ["Zen", "Go", "Enterprise"] as const
|
||||||
const ranges = ["1D", "1W", "1M", "3M", "YTD", "ALL"] as const
|
const ranges = ["1D", "1W", "1M", "3M", "YTD", "ALL"] as const
|
||||||
|
const headerLinks = [
|
||||||
|
{ href: "#top-models", label: "Top Models" },
|
||||||
|
{ href: "#leaderboard", label: "Leaderboard" },
|
||||||
|
{ href: "#market-share", label: "Market Share" },
|
||||||
|
{ href: "#token-cost", label: "Token Cost" },
|
||||||
|
{ href: "#session-cost", label: "Session Cost" },
|
||||||
|
] as const
|
||||||
const usageColors = ["#ff5d64", "#ff8a00", "#8bef00", "#12c8b3", "#18c7dc", "#6c7dff", "#9d73f7"]
|
const usageColors = ["#ff5d64", "#ff8a00", "#8bef00", "#12c8b3", "#18c7dc", "#6c7dff", "#9d73f7"]
|
||||||
const marketColors = ["#ed6aff", "#a684ff", "#7c86ff", "#51a2ff", "#00d3f2", "#00d5be", "#00bc7d", "#9ae600", "#ffb900"]
|
const marketColors = ["#ed6aff", "#a684ff", "#7c86ff", "#51a2ff", "#00d3f2", "#00d5be", "#00bc7d", "#9ae600", "#ffb900"]
|
||||||
const countryPositions = [
|
const countryPositions = [
|
||||||
@@ -59,6 +68,10 @@ export default function StatsHome() {
|
|||||||
<main data-page="stats">
|
<main data-page="stats">
|
||||||
<Title>OpenCode Stats</Title>
|
<Title>OpenCode Stats</Title>
|
||||||
<Meta name="description" content="OpenCode usage, market share, token cost, and session cost stats." />
|
<Meta name="description" content="OpenCode usage, market share, token cost, and session cost stats." />
|
||||||
|
<Link rel="preload" href={ibmPlexMonoRegularLatin1} as="font" type="font/woff2" crossorigin="anonymous" />
|
||||||
|
<Link rel="preload" href={ibmPlexMonoMediumLatin1} as="font" type="font/woff2" crossorigin="anonymous" />
|
||||||
|
<Link rel="preload" href={ibmPlexMonoSemiBoldLatin1} as="font" type="font/woff2" crossorigin="anonymous" />
|
||||||
|
<Link rel="preload" href={ibmPlexMonoBoldLatin1} as="font" type="font/woff2" crossorigin="anonymous" />
|
||||||
<div data-component="container">
|
<div data-component="container">
|
||||||
<Header />
|
<Header />
|
||||||
<div data-component="content">
|
<div data-component="content">
|
||||||
@@ -85,21 +98,30 @@ export default function StatsHome() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Hero(props: { updatedAt: string | null }) {
|
function Hero(props: { updatedAt: string | null }) {
|
||||||
|
const [timeZone, setTimeZone] = createSignal("UTC")
|
||||||
|
onMount(() => setTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section data-section="hero">
|
<section data-section="hero">
|
||||||
<div>
|
<p data-slot="hero-meta">
|
||||||
<h1>OpenCode Stats</h1>
|
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16">
|
||||||
<p data-slot="meta">
|
<path
|
||||||
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16">
|
fill-rule="evenodd"
|
||||||
<rect x="3" y="3" width="10" height="10" fill="currentColor" />
|
clip-rule="evenodd"
|
||||||
<rect x="7" y="6.5" width="2" height="4.5" fill="var(--stats-layer-2)" />
|
d="M13 13H3V3H13V13ZM6.46777 6.81641V7.81641H7.5791V11.3721H8.5791V6.81641H6.46777ZM7.30078 4.62891V5.62891H8.85645V4.62891H7.30078Z"
|
||||||
<rect x="7" y="5" width="2" height="1" fill="var(--stats-layer-2)" />
|
fill="currentColor"
|
||||||
</svg>
|
/>
|
||||||
<span>OpenCode data</span> <b>·</b>{" "}
|
</svg>
|
||||||
<em>{props.updatedAt ? `Updated ${formatUpdatedAt(props.updatedAt)}` : "No rows yet"}</em>
|
<span>{props.updatedAt ? `Updated ${formatUpdatedAt(props.updatedAt, timeZone())}` : "No rows yet"}</span>
|
||||||
|
</p>
|
||||||
|
<div data-slot="hero-canvas">
|
||||||
|
<div data-slot="hero-pattern" aria-hidden="true" />
|
||||||
|
<h1>Model Stats</h1>
|
||||||
|
<p data-slot="hero-copy">
|
||||||
|
See which models are winning real usage, how the mix <br data-slot="hero-copy-break" />
|
||||||
|
shifts over time, and where momentum is moving each week.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p>See how model usage, provider share, cost, and geography move across OpenCode traffic.</p>
|
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -115,9 +137,15 @@ function StatsLoading() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChartSection(props: { title: string; description?: string; controls?: JSX.Element; children: JSX.Element }) {
|
function ChartSection(props: {
|
||||||
|
id?: string
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
controls?: JSX.Element
|
||||||
|
children: JSX.Element
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<section data-section="chart">
|
<section id={props.id} data-section="chart">
|
||||||
<div data-slot="section-header">
|
<div data-slot="section-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>{props.title}</h2>
|
<h2>{props.title}</h2>
|
||||||
@@ -139,7 +167,7 @@ function EmptyState(props: { title: string; description: string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatUpdatedAt(value: string) {
|
function formatUpdatedAt(value: string, timeZone: string) {
|
||||||
const date = new Date(value)
|
const date = new Date(value)
|
||||||
if (Number.isNaN(date.getTime())) return "just now"
|
if (Number.isNaN(date.getTime())) return "just now"
|
||||||
return new Intl.DateTimeFormat("en", {
|
return new Intl.DateTimeFormat("en", {
|
||||||
@@ -147,7 +175,7 @@ function formatUpdatedAt(value: string) {
|
|||||||
day: "numeric",
|
day: "numeric",
|
||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
timeZone: "UTC",
|
timeZone,
|
||||||
timeZoneName: "short",
|
timeZoneName: "short",
|
||||||
}).format(date)
|
}).format(date)
|
||||||
}
|
}
|
||||||
@@ -158,7 +186,7 @@ function UsageSection(props: { data: StatsHomeData["usage"] }) {
|
|||||||
const data = createMemo(() => props.data[product()][range()])
|
const data = createMemo(() => props.data[product()][range()])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartSection title="Usage">
|
<ChartSection id="top-models" title="Usage">
|
||||||
<Show
|
<Show
|
||||||
when={data().some((item) => usageTotal(item) > 0)}
|
when={data().some((item) => usageTotal(item) > 0)}
|
||||||
fallback={<EmptyState title="No usage data" description="No model_stat rows matched this product and range." />}
|
fallback={<EmptyState title="No usage data" description="No model_stat rows matched this product and range." />}
|
||||||
@@ -394,6 +422,7 @@ function LeaderboardSection(props: { data: StatsHomeData["leaderboard"] }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartSection
|
<ChartSection
|
||||||
|
id="leaderboard"
|
||||||
title="Leaderboard"
|
title="Leaderboard"
|
||||||
description="Shown are the sum of prompt and completion tokens per model, including reasoning tokens."
|
description="Shown are the sum of prompt and completion tokens per model, including reasoning tokens."
|
||||||
>
|
>
|
||||||
@@ -476,7 +505,7 @@ function MarketShareSection(props: { data: StatsHomeData["market"] }) {
|
|||||||
const activeDay = createMemo(() => data()[selectedIndex()])
|
const activeDay = createMemo(() => data()[selectedIndex()])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartSection title="Market Share" description="Compare token share by model author.">
|
<ChartSection id="market-share" title="Market Share" description="Compare token share by model author.">
|
||||||
<Show
|
<Show
|
||||||
when={activeDay()}
|
when={activeDay()}
|
||||||
fallback={<EmptyState title="No market data" description="No model_stat rows matched this range." />}
|
fallback={<EmptyState title="No market data" description="No model_stat rows matched this range." />}
|
||||||
@@ -572,7 +601,7 @@ function TokenCostSection(props: { data: StatsHomeData["tokenCost"] }) {
|
|||||||
const selectedIndex = createMemo(() => Math.min(activeIndex(), Math.max(data().length - 1, 0)))
|
const selectedIndex = createMemo(() => Math.min(activeIndex(), Math.max(data().length - 1, 0)))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartSection title="Token Cost" description="Price per 1M tokens.">
|
<ChartSection id="token-cost" title="Token Cost" description="Price per 1M tokens.">
|
||||||
<Show
|
<Show
|
||||||
when={data().length > 0}
|
when={data().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
@@ -661,7 +690,7 @@ function SessionCostSection(props: { data: StatsHomeData["sessionCost"] }) {
|
|||||||
const selectedIndex = createMemo(() => Math.min(activeIndex(), Math.max(data().length - 1, 0)))
|
const selectedIndex = createMemo(() => Math.min(activeIndex(), Math.max(data().length - 1, 0)))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartSection title="Session Cost" description="Average cost per session.">
|
<ChartSection id="session-cost" title="Session Cost" description="Average cost per session.">
|
||||||
<Show
|
<Show
|
||||||
when={data().length > 0}
|
when={data().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
@@ -886,47 +915,137 @@ function Newsletter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Header() {
|
function Header() {
|
||||||
|
const [menuOpen, setMenuOpen] = createSignal(false)
|
||||||
|
const [menuViewport, setMenuViewport] = createSignal(false)
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
const media = window.matchMedia("(max-width: 74.999rem)")
|
||||||
|
const update = () => setMenuViewport(media.matches)
|
||||||
|
update()
|
||||||
|
media.addEventListener("change", update)
|
||||||
|
onCleanup(() => media.removeEventListener("change", update))
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!menuOpen()) return
|
||||||
|
if (!menuViewport()) return
|
||||||
|
if (typeof document === "undefined") return
|
||||||
|
const page = document.querySelector<HTMLElement>('[data-page="stats"]')
|
||||||
|
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth
|
||||||
|
const htmlOverflow = document.documentElement.style.overflow
|
||||||
|
const pagePaddingRight = page?.style.paddingRight
|
||||||
|
const bodyOverflow = document.body.style.overflow
|
||||||
|
document.documentElement.style.overflow = "hidden"
|
||||||
|
if (scrollbarWidth > 0 && page) page.style.paddingRight = `${scrollbarWidth}px`
|
||||||
|
document.body.style.overflow = "hidden"
|
||||||
|
onCleanup(() => {
|
||||||
|
document.documentElement.style.overflow = htmlOverflow
|
||||||
|
if (page && pagePaddingRight !== undefined) page.style.paddingRight = pagePaddingRight
|
||||||
|
document.body.style.overflow = bodyOverflow
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section data-component="top">
|
<header data-component="top" data-menu-open={menuOpen() ? "true" : undefined}>
|
||||||
<a data-slot="brand" href="https://opencode.ai/" aria-label="OpenCode home">
|
<div data-slot="header-bar">
|
||||||
<img data-slot="logo light" src={logoLight} alt="OpenCode" width="234" height="42" />
|
<a data-slot="brand" href="/" aria-label="OpenCode home">
|
||||||
<img data-slot="logo dark" src={logoDark} alt="OpenCode" width="234" height="42" />
|
<StatsWordmark />
|
||||||
</a>
|
</a>
|
||||||
<nav data-component="nav-desktop" aria-label="Main navigation">
|
<nav data-component="section-nav" aria-label="Stats sections">
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<For each={headerLinks}>
|
||||||
<a href="https://github.com/sst/opencode" target="_blank" rel="noreferrer">
|
{(link) => (
|
||||||
GitHub
|
<li>
|
||||||
|
<a href={link.href}>{link.label}</a>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<div data-slot="header-actions">
|
||||||
|
<a
|
||||||
|
data-slot="header-button"
|
||||||
|
data-variant="neutral"
|
||||||
|
href="https://github.com/sst/opencode"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
<strong>GitHub</strong>
|
||||||
|
<span>[150K]</span>
|
||||||
|
</a>
|
||||||
|
<a data-slot="header-button" data-variant="contrast" href="https://opencode.ai/">
|
||||||
|
<strong>Try OpenCode</strong>
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
data-slot="menu-button"
|
||||||
|
type="button"
|
||||||
|
aria-controls="stats-mobile-nav"
|
||||||
|
aria-expanded={menuOpen() ? "true" : "false"}
|
||||||
|
aria-label={menuOpen() ? "Close navigation" : "Open navigation"}
|
||||||
|
onClick={() => setMenuOpen((value) => !value)}
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
|
<Show when={menuOpen()} fallback={<path d="M2 4.72H14M2 8.5H14M2 12.28H14" stroke="currentColor" />}>
|
||||||
|
<path d="M4.44 4.44L11.56 11.56M11.56 4.44L4.44 11.56" stroke="currentColor" />
|
||||||
|
</Show>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<nav id="stats-mobile-nav" data-slot="mobile-menu" aria-label="Stats sections" hidden={!menuOpen()}>
|
||||||
|
<a
|
||||||
|
data-slot="mobile-menu-item"
|
||||||
|
data-variant="github"
|
||||||
|
href="https://github.com/sst/opencode"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
<strong>GitHub</strong>
|
||||||
|
<span>[150K]</span>
|
||||||
|
</a>
|
||||||
|
<For each={headerLinks}>
|
||||||
|
{(link) => (
|
||||||
|
<a data-slot="mobile-menu-item" href={link.href} onClick={() => setMenuOpen(false)}>
|
||||||
|
{link.label}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
)}
|
||||||
<li>
|
</For>
|
||||||
<a href="https://opencode.ai/docs">Docs</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="https://opencode.ai/zen">Zen</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="https://opencode.ai/go">Go</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="https://opencode.ai/enterprise">Enterprise</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="https://opencode.ai/download" data-slot="cta-button">
|
|
||||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
|
||||||
<path
|
|
||||||
d="M12.1875 9.75L9.00001 12.9375L5.8125 9.75M9.00001 2.0625L9 12.375M14.4375 15.9375H3.5625"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.5"
|
|
||||||
stroke-linecap="square"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Download
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
</nav>
|
||||||
</section>
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatsWordmark() {
|
||||||
|
return (
|
||||||
|
<span data-slot="stats-wordmark" aria-hidden="true">
|
||||||
|
<svg data-slot="brand-mark" width="19" height="24" viewBox="0 0 19 24" fill="none">
|
||||||
|
<path opacity="0.2" d="M14.25 19.2H4.75V9.6H14.25V19.2Z" fill="currentColor" />
|
||||||
|
<path d="M14.25 4.8H4.75V19.2H14.25V4.8ZM19 24H0V0H19V24Z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
<svg data-slot="brand-label" width="51" height="14" viewBox="0 0 50.8509 14" fill="none">
|
||||||
|
<path
|
||||||
|
d="M46.2359 14C45.2276 14 44.3356 13.819 43.56 13.4571C42.7973 13.0822 42.138 12.5328 41.5822 11.8089L43.1722 10.277C43.56 10.807 44.0124 11.2142 44.5295 11.4986C45.0466 11.7701 45.6283 11.9058 46.2747 11.9058C47.7225 11.9058 48.4464 11.2465 48.4464 9.92798C48.4464 9.38504 48.3172 8.97138 48.0586 8.68698C47.8001 8.40259 47.3735 8.19575 46.7788 8.06648L45.596 7.8338C44.3679 7.57525 43.463 7.13573 42.8813 6.51524C42.2996 5.89474 42.0088 5.02862 42.0088 3.9169C42.0088 2.62419 42.3901 1.6482 43.1528 0.98892C43.9284 0.32964 45.0272 0 46.4492 0C47.4187 0 48.2461 0.161588 48.9312 0.484764C49.6293 0.795014 50.2239 1.28624 50.7151 1.95845L49.1251 3.45152C48.789 2.99908 48.4076 2.66297 47.9811 2.44321C47.5545 2.21053 47.0309 2.09418 46.4104 2.09418C45.7253 2.09418 45.2211 2.22992 44.898 2.50139C44.5748 2.77285 44.4132 3.21237 44.4132 3.81995C44.4132 4.3241 44.536 4.71191 44.7816 4.98338C45.0401 5.25485 45.4538 5.45522 46.0226 5.58449L47.2054 5.83656C47.8647 5.97876 48.4206 6.15328 48.873 6.36011C49.3384 6.56694 49.7133 6.82548 49.9977 7.13573C50.295 7.44598 50.5083 7.8144 50.6376 8.241C50.7798 8.65466 50.8509 9.14589 50.8509 9.71468C50.8509 11.1108 50.4501 12.1773 49.6486 12.9141C48.8601 13.638 47.7225 14 46.2359 14Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M36.9543 2.34643V13.7675H34.5305V2.34643H31.1371V0.232856H40.367V2.34643H36.9543Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M28.6196 13.7675L27.6695 10.2384H23.3066L22.3565 13.7675H20.0296L23.9853 0.232856H27.049L31.0047 13.7675H28.6196ZM26.0407 4.57635L25.6141 2.42399H25.3426L24.916 4.57635L23.8883 8.27995H27.0878L26.0407 4.57635Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M16.4849 2.34643V13.7675H14.0611V2.34643H10.6678V0.232856H19.8977V2.34643H16.4849Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M4.65374 14C3.64543 14 2.75346 13.819 1.97784 13.4571C1.21514 13.0822 0.555863 12.5328 0 11.8089L1.59003 10.277C1.97784 10.807 2.43029 11.2142 2.94737 11.4986C3.46445 11.7701 4.04617 11.9058 4.69252 11.9058C6.14035 11.9058 6.86427 11.2465 6.86427 9.92798C6.86427 9.38504 6.735 8.97138 6.47646 8.68698C6.21791 8.40259 5.79132 8.19575 5.19668 8.06648L4.01385 7.8338C2.78578 7.57525 1.88089 7.13573 1.29917 6.51524C0.717452 5.89474 0.426593 5.02862 0.426593 3.9169C0.426593 2.62419 0.807941 1.6482 1.57064 0.98892C2.34626 0.32964 3.44506 0 4.86704 0C5.83657 0 6.6639 0.161588 7.34903 0.484764C8.04709 0.795014 8.64174 1.28624 9.13297 1.95845L7.54294 3.45152C7.20683 2.99908 6.82549 2.66297 6.39889 2.44321C5.9723 2.21053 5.44875 2.09418 4.82826 2.09418C4.14312 2.09418 3.63897 2.22992 3.31579 2.50139C2.99261 2.77285 2.83103 3.21237 2.83103 3.81995C2.83103 4.3241 2.95383 4.71191 3.19945 4.98338C3.45799 5.25485 3.87165 5.45522 4.44044 5.58449L5.62327 5.83656C6.28255 5.97876 6.83841 6.15328 7.29086 6.36011C7.75623 6.56694 8.13112 6.82548 8.41551 7.13573C8.71284 7.44598 8.92613 7.8144 9.0554 8.241C9.1976 8.65466 9.2687 9.14589 9.2687 9.71468C9.2687 11.1108 8.86796 12.1773 8.06648 12.9141C7.27793 13.638 6.14035 14 4.65374 14Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ OpenCode Zen هي بوابة AI تتيح لك الوصول إلى هذه الن
|
|||||||
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
|
| Claude Opus 4.8 | claude-opus-4-8 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
@@ -132,6 +133,7 @@ https://opencode.ai/zen/v1/models
|
|||||||
| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 |
|
| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 |
|
||||||
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
|
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
|
||||||
| Grok Build 0.1 | 1.00 | $2.00 | $0.20 | - |
|
| Grok Build 0.1 | 1.00 | $2.00 | $0.20 | - |
|
||||||
|
| Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ Našim modelima možete pristupiti i preko sljedećih API endpointa.
|
|||||||
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
|
| Claude Opus 4.8 | claude-opus-4-8 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
@@ -139,6 +140,7 @@ Podržavamo pay-as-you-go model. Ispod su cijene **po 1M tokena**.
|
|||||||
| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 |
|
| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 |
|
||||||
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
|
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
|
||||||
| Grok Build 0.1 | 1.00 | $2.00 | $0.20 | - |
|
| Grok Build 0.1 | 1.00 | $2.00 | $0.20 | - |
|
||||||
|
| Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ Du kan også få adgang til vores modeller gennem følgende API-endpoints.
|
|||||||
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
|
| Claude Opus 4.8 | claude-opus-4-8 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
@@ -139,6 +140,7 @@ Vi understøtter en pay-as-you-go-model. Nedenfor er priserne **pr. 1M tokens**.
|
|||||||
| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 |
|
| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 |
|
||||||
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
|
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
|
||||||
| Grok Build 0.1 | 1.00 | $2.00 | $0.20 | - |
|
| Grok Build 0.1 | 1.00 | $2.00 | $0.20 | - |
|
||||||
|
| Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ Du kannst auch über die folgenden API-Endpunkte auf unsere Modelle zugreifen.
|
|||||||
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
|
| Claude Opus 4.8 | claude-opus-4-8 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
@@ -128,6 +129,7 @@ Wir unterstützen ein Pay-as-you-go-Modell. Unten findest du die Preise **pro 1M
|
|||||||
| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 |
|
| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 |
|
||||||
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
|
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
|
||||||
| Grok Build 0.1 | 1.00 | $2.00 | $0.20 | - |
|
| Grok Build 0.1 | 1.00 | $2.00 | $0.20 | - |
|
||||||
|
| Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ También puedes acceder a nuestros modelos a través de los siguientes endpoints
|
|||||||
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
|
| Claude Opus 4.8 | claude-opus-4-8 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
@@ -139,6 +140,7 @@ Admitimos un modelo de pago por uso. A continuación se muestran los precios **p
|
|||||||
| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 |
|
| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 |
|
||||||
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
|
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
|
||||||
| Grok Build 0.1 | 1.00 | $2.00 | $0.20 | - |
|
| Grok Build 0.1 | 1.00 | $2.00 | $0.20 | - |
|
||||||
|
| Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ Vous pouvez également accéder à nos modèles via les points de terminaison AP
|
|||||||
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
|
| Claude Opus 4.8 | claude-opus-4-8 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
@@ -128,6 +129,7 @@ Nous prenons en charge un modèle de paiement à l'utilisation. Vous trouverez c
|
|||||||
| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 |
|
| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 |
|
||||||
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
|
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
|
||||||
| Grok Build 0.1 | 1.00 | $2.00 | $0.20 | - |
|
| Grok Build 0.1 | 1.00 | $2.00 | $0.20 | - |
|
||||||
|
| Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ Puoi anche accedere ai nostri modelli tramite i seguenti endpoint API.
|
|||||||
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
|
| Claude Opus 4.8 | claude-opus-4-8 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
@@ -139,6 +140,7 @@ Supportiamo un modello pay-as-you-go. Qui sotto trovi i prezzi **per 1M token**.
|
|||||||
| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 |
|
| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 |
|
||||||
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
|
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
|
||||||
| Grok Build 0.1 | 1.00 | $2.00 | $0.20 | - |
|
| Grok Build 0.1 | 1.00 | $2.00 | $0.20 | - |
|
||||||
|
| Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ OpenCode Zen は、OpenCode のほかのプロバイダーと同じように動
|
|||||||
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
|
| Claude Opus 4.8 | claude-opus-4-8 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
@@ -128,6 +129,7 @@ https://opencode.ai/zen/v1/models
|
|||||||
| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 |
|
| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 |
|
||||||
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
|
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
|
||||||
| Grok Build 0.1 | 1.00 | $2.00 | $0.20 | - |
|
| Grok Build 0.1 | 1.00 | $2.00 | $0.20 | - |
|
||||||
|
| Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ OpenCode Zen은 OpenCode의 다른 provider와 똑같이 작동합니다.
|
|||||||
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
|
| Claude Opus 4.8 | claude-opus-4-8 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
@@ -128,6 +129,7 @@ https://opencode.ai/zen/v1/models
|
|||||||
| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 |
|
| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 |
|
||||||
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
|
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
|
||||||
| Grok Build 0.1 | 1.00 | $2.00 | $0.20 | - |
|
| Grok Build 0.1 | 1.00 | $2.00 | $0.20 | - |
|
||||||
|
| Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ Du kan også få tilgang til modellene våre gjennom følgende API-endepunkter.
|
|||||||
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
|
| Claude Opus 4.8 | claude-opus-4-8 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
@@ -139,6 +140,7 @@ Vi støtter en pay-as-you-go-modell. Nedenfor er prisene **per 1M tokens**.
|
|||||||
| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 |
|
| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 |
|
||||||
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
|
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
|
||||||
| Grok Build 0.1 | 1.00 | $2.00 | $0.20 | - |
|
| Grok Build 0.1 | 1.00 | $2.00 | $0.20 | - |
|
||||||
|
| Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ Możesz też uzyskać dostęp do naszych modeli przez poniższe endpointy API.
|
|||||||
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
|
| Claude Opus 4.8 | claude-opus-4-8 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
@@ -139,6 +140,7 @@ Obsługujemy model pay-as-you-go. Poniżej znajdują się ceny **za 1M tokenów*
|
|||||||
| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 |
|
| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 |
|
||||||
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
|
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
|
||||||
| Grok Build 0.1 | 1.00 | $2.00 | $0.20 | - |
|
| Grok Build 0.1 | 1.00 | $2.00 | $0.20 | - |
|
||||||
|
| Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ Você também pode acessar nossos modelos pelos seguintes endpoints de API.
|
|||||||
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
|
| Claude Opus 4.8 | claude-opus-4-8 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
@@ -128,6 +129,7 @@ Oferecemos um modelo pay-as-you-go. Abaixo estão os preços **por 1M tokens**.
|
|||||||
| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 |
|
| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 |
|
||||||
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
|
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
|
||||||
| Grok Build 0.1 | 1.00 | $2.00 | $0.20 | - |
|
| Grok Build 0.1 | 1.00 | $2.00 | $0.20 | - |
|
||||||
|
| Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ OpenCode Zen работает как любой другой провайдер
|
|||||||
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
|
| Claude Opus 4.8 | claude-opus-4-8 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
@@ -139,6 +140,7 @@ https://opencode.ai/zen/v1/models
|
|||||||
| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 |
|
| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 |
|
||||||
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
|
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
|
||||||
| Grok Build 0.1 | 1.00 | $2.00 | $0.20 | - |
|
| Grok Build 0.1 | 1.00 | $2.00 | $0.20 | - |
|
||||||
|
| Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ OpenCode Zen ทำงานเหมือน provider อื่น ๆ ใน
|
|||||||
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
|
| Claude Opus 4.8 | claude-opus-4-8 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
@@ -130,6 +131,7 @@ https://opencode.ai/zen/v1/models
|
|||||||
| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 |
|
| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 |
|
||||||
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
|
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
|
||||||
| Grok Build 0.1 | 1.00 | $2.00 | $0.20 | - |
|
| Grok Build 0.1 | 1.00 | $2.00 | $0.20 | - |
|
||||||
|
| Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ Modellerimize aşağıdaki API uç noktaları aracılığıyla da erişebilirsin
|
|||||||
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
|
| Claude Opus 4.8 | claude-opus-4-8 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
@@ -128,6 +129,7 @@ Kullandıkça öde modelini destekliyoruz. Aşağıda **1M token başına** fiya
|
|||||||
| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 |
|
| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 |
|
||||||
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
|
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
|
||||||
| Grok Build 0.1 | 1.00 | $2.00 | $0.20 | - |
|
| Grok Build 0.1 | 1.00 | $2.00 | $0.20 | - |
|
||||||
|
| Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ You can also access our models through the following API endpoints.
|
|||||||
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
|
| Claude Opus 4.8 | claude-opus-4-8 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
@@ -139,6 +140,7 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**.
|
|||||||
| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 |
|
| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 |
|
||||||
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
|
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
|
||||||
| Grok Build 0.1 | 1.00 | $2.00 | $0.20 | - |
|
| Grok Build 0.1 | 1.00 | $2.00 | $0.20 | - |
|
||||||
|
| Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ OpenCode Zen 的工作方式与 OpenCode 中的任何其他提供商相同。
|
|||||||
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
|
| Claude Opus 4.8 | claude-opus-4-8 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
@@ -128,6 +129,7 @@ https://opencode.ai/zen/v1/models
|
|||||||
| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 |
|
| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 |
|
||||||
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
|
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
|
||||||
| Grok Build 0.1 | 1.00 | $2.00 | $0.20 | - |
|
| Grok Build 0.1 | 1.00 | $2.00 | $0.20 | - |
|
||||||
|
| Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ OpenCode Zen 的運作方式和 OpenCode 中的其他供應商一樣。
|
|||||||
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||||
|
| Claude Opus 4.8 | claude-opus-4-8 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||||
@@ -133,6 +134,7 @@ https://opencode.ai/zen/v1/models
|
|||||||
| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 |
|
| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 |
|
||||||
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
|
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
|
||||||
| Grok Build 0.1 | 1.00 | $2.00 | $0.20 | - |
|
| Grok Build 0.1 | 1.00 | $2.00 | $0.20 | - |
|
||||||
|
| Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
|
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||||
|
|||||||
Reference in New Issue
Block a user