mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-09 10:59:49 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ff736fbbc8 |
@@ -178,7 +178,6 @@ export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompat
|
|||||||
toolSchema: Schema.optional(LanguageModelToolSchemaCompatibility),
|
toolSchema: Schema.optional(LanguageModelToolSchemaCompatibility),
|
||||||
reasoningField: Schema.optional(Schema.String),
|
reasoningField: Schema.optional(Schema.String),
|
||||||
maxTokensField: Schema.optional(LanguageModelMaxTokensFieldCompatibility),
|
maxTokensField: Schema.optional(LanguageModelMaxTokensFieldCompatibility),
|
||||||
requireFinishReason: Schema.optional(Schema.Boolean),
|
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export namespace LanguageModelCompatibility {
|
export namespace LanguageModelCompatibility {
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ describe("llm constructors", () => {
|
|||||||
const updated = LanguageModel.update(base, {
|
const updated = LanguageModel.update(base, {
|
||||||
route: responsesRoute,
|
route: responsesRoute,
|
||||||
defaults: { generation: { maxTokens: 20 } },
|
defaults: { generation: { maxTokens: 20 } },
|
||||||
compatibility: { toolSchema: "gemini", requireFinishReason: false },
|
compatibility: { toolSchema: "gemini" },
|
||||||
})
|
})
|
||||||
const updatedInput = LanguageModel.input(updated)
|
const updatedInput = LanguageModel.input(updated)
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ describe("llm constructors", () => {
|
|||||||
expect(String(updated.id)).toBe("fake-model")
|
expect(String(updated.id)).toBe("fake-model")
|
||||||
expect(updated.route).toBe(responsesRoute)
|
expect(updated.route).toBe(responsesRoute)
|
||||||
expect(updated.defaults?.generation).toEqual({ maxTokens: 20 })
|
expect(updated.defaults?.generation).toEqual({ maxTokens: 20 })
|
||||||
expect(updated.compatibility).toEqual({ toolSchema: "gemini", requireFinishReason: false })
|
expect(updated.compatibility).toEqual({ toolSchema: "gemini" })
|
||||||
expect(updatedInput.defaults).toBe(updated.defaults)
|
expect(updatedInput.defaults).toBe(updated.defaults)
|
||||||
expect(updatedInput.compatibility).toBe(updated.compatibility)
|
expect(updatedInput.compatibility).toBe(updated.compatibility)
|
||||||
expect(String(updatedInput.provider)).toBe("fake")
|
expect(String(updatedInput.provider)).toBe("fake")
|
||||||
|
|||||||
+10
-8
@@ -1,18 +1,19 @@
|
|||||||
import { expect, test } from "@playwright/test"
|
import { expect, test } from "@playwright/test"
|
||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
|
|
||||||
const draftID = "draft_removed_layout_preference"
|
const draftID = "draft_legacy_new_session"
|
||||||
const directory = "C:/OpenCode/RemovedLayoutPreference"
|
const directory = "C:/OpenCode/LegacyNewSession"
|
||||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||||
|
|
||||||
test("ignores persisted old layout preferences when opening drafts", async ({ page }) => {
|
test("redirects a draft to the legacy new-session route", async ({ page }) => {
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
id: "proj_removed_layout_preference",
|
id: "proj_legacy_new_session",
|
||||||
worktree: directory,
|
worktree: directory,
|
||||||
vcs: "git",
|
vcs: "git",
|
||||||
name: "removed-layout-preference",
|
name: "legacy-new-session",
|
||||||
time: { created: 1700000000000, updated: 1700000000000 },
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
sandboxes: [],
|
sandboxes: [],
|
||||||
},
|
},
|
||||||
@@ -23,6 +24,7 @@ test("ignores persisted old layout preferences when opening drafts", async ({ pa
|
|||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ directory, draftID, server }) => {
|
({ directory, draftID, server }) => {
|
||||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } }))
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } }))
|
||||||
|
localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" }))
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"opencode.window.browser.dat:tabs",
|
"opencode.window.browser.dat:tabs",
|
||||||
JSON.stringify([{ type: "draft", draftID, server, directory }]),
|
JSON.stringify([{ type: "draft", draftID, server, directory }]),
|
||||||
@@ -33,7 +35,7 @@ test("ignores persisted old layout preferences when opening drafts", async ({ pa
|
|||||||
|
|
||||||
await page.goto(`/new-session?draftId=${draftID}`)
|
await page.goto(`/new-session?draftId=${draftID}`)
|
||||||
|
|
||||||
await expect(page).toHaveURL(`/new-session?draftId=${draftID}`)
|
await expect(page).toHaveURL(`/${base64Encode(directory)}/session`)
|
||||||
await expect(page.locator("body")).toHaveAttribute("data-new-layout", "")
|
await expect(page.locator("header[data-tauri-drag-region]")).toBeVisible()
|
||||||
await expect(page.getByRole("textbox", { name: "Prompt" })).toBeVisible()
|
await expect(page.locator('[data-component="prompt-input"]')).toBeVisible()
|
||||||
})
|
})
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
<meta property="twitter:image" content="/social-share.png" />
|
<meta property="twitter:image" content="/social-share.png" />
|
||||||
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
|
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body data-new-layout class="antialiased overscroll-none font-(family-name:--font-family-text) text-[13px] font-[440] overflow-hidden bg-v2-background-bg-deep">
|
<body class="antialiased overscroll-none text-12-regular overflow-hidden bg-v2-background-bg-deep">
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
<div id="root" class="flex flex-col h-dvh bg-v2-background-bg-deep p-px"></div>
|
<div id="root" class="flex flex-col h-dvh bg-v2-background-bg-deep p-px"></div>
|
||||||
<script src="/src/entry.tsx" type="module"></script>
|
<script src="/src/entry.tsx" type="module"></script>
|
||||||
|
|||||||
+146
-31
@@ -14,11 +14,14 @@ import {
|
|||||||
Navigate,
|
Navigate,
|
||||||
Route,
|
Route,
|
||||||
Router,
|
Router,
|
||||||
|
useLocation,
|
||||||
|
useNavigate,
|
||||||
useParams,
|
useParams,
|
||||||
useSearchParams,
|
useSearchParams,
|
||||||
} from "@solidjs/router"
|
} from "@solidjs/router"
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import {
|
import {
|
||||||
type Component,
|
type Component,
|
||||||
createEffect,
|
createEffect,
|
||||||
@@ -40,7 +43,7 @@ import { CommandProvider, useCommand, type CommandOption } from "@/context/comma
|
|||||||
import { CommentsProvider } from "@/context/comments"
|
import { CommentsProvider } from "@/context/comments"
|
||||||
import { FileProvider } from "@/context/file"
|
import { FileProvider } from "@/context/file"
|
||||||
import { ServerSDKProvider } from "@/context/server-sdk"
|
import { ServerSDKProvider } from "@/context/server-sdk"
|
||||||
import { ServerSyncProvider } from "@/context/server-sync"
|
import { ServerSyncProvider, useServerSync } from "@/context/server-sync"
|
||||||
import { GlobalProvider, useGlobal } from "@/context/global"
|
import { GlobalProvider, useGlobal } from "@/context/global"
|
||||||
import { HighlightsProvider } from "@/context/highlights"
|
import { HighlightsProvider } from "@/context/highlights"
|
||||||
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
||||||
@@ -51,36 +54,58 @@ import { PermissionProvider } from "@/context/permission"
|
|||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { PromptProvider } from "@/context/prompt"
|
import { PromptProvider } from "@/context/prompt"
|
||||||
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
||||||
import { SettingsProvider } from "@/context/settings"
|
import { SettingsProvider, useSettings } from "@/context/settings"
|
||||||
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
|
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
|
||||||
import { SDKProvider } from "@/context/sdk"
|
import { SDKProvider, useSDK } from "@/context/sdk"
|
||||||
import { WslServersProvider } from "@/wsl/context"
|
import { WslServersProvider } from "@/wsl/context"
|
||||||
import { DirectoryDataProvider } from "@/pages/directory-layout"
|
import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout"
|
||||||
import Layout from "@/pages/layout"
|
import LegacyLayout from "@/pages/layout"
|
||||||
|
import NewLayout from "@/pages/layout-new"
|
||||||
import { ErrorPage } from "./pages/error"
|
import { ErrorPage } from "./pages/error"
|
||||||
import { useCheckServerHealth } from "./utils/server-health"
|
import { useCheckServerHealth } from "./utils/server-health"
|
||||||
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { createSessionLineage } from "@/pages/session/session-lineage"
|
||||||
|
|
||||||
import { TargetSessionRouteContent } from "@/pages/session"
|
import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
|
||||||
import { Home } from "@/pages/home"
|
import { NewHome } from "@/pages/home"
|
||||||
|
import { LegacyHome } from "@/pages/home/legacy-home"
|
||||||
|
|
||||||
const NewSession = lazy(() => import("@/pages/new-session"))
|
const NewSession = lazy(() => import("@/pages/new-session"))
|
||||||
|
|
||||||
const DirectoryDraftRedirect = () => {
|
const SessionRoute = () => {
|
||||||
|
const settings = useSettings()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
||||||
|
const sdk = useSDK()
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
|
|
||||||
|
if (params.id && settings.general.newLayoutDesigns()) {
|
||||||
|
const sessionID = params.id
|
||||||
|
return (
|
||||||
|
<Show when={tabs.ready()}>
|
||||||
|
{(_) => {
|
||||||
|
const persisted = tabs.store.filter((item) => item.type === "session")
|
||||||
|
return <Navigate href={sessionHref(legacySessionServer(persisted, sessionID, server.key), sessionID)} />
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// When the new layout is enabled, the legacy new-session route (/:dir/session with no id)
|
||||||
|
// is replaced by a draft at /new-session?draftId=…
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (search.draftId || !tabs.ready()) return
|
if (!settings.general.newLayoutDesigns()) return
|
||||||
const directory = decode64(params.dir)
|
if (params.id || search.draftId) return
|
||||||
if (!directory) return
|
if (!tabs.ready() || !sdk().directory) return
|
||||||
tabs.newDraft({ server: server.key, directory }, search.prompt)
|
tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt)
|
||||||
})
|
})
|
||||||
|
|
||||||
return null
|
return (
|
||||||
|
<SessionRouteErrorBoundary sessionID={params.id}>
|
||||||
|
<SessionPage />
|
||||||
|
</SessionRouteErrorBoundary>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TargetServerRoute(props: ParentProps) {
|
function TargetServerRoute(props: ParentProps) {
|
||||||
@@ -92,7 +117,9 @@ function TargetServerRoute(props: ParentProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// Owns the server-identity remount. Session changes must not remount this subtree.
|
// Owns the server-identity remount. Session changes must NOT remount this
|
||||||
|
// subtree (SessionRouteErrorBoundary resets and createSessionLineage
|
||||||
|
// re-resolves reactively instead); both rely on this key for server changes.
|
||||||
<Show when={requireServerKey(params.serverKey)} keyed>
|
<Show when={requireServerKey(params.serverKey)} keyed>
|
||||||
<ServerSDKProvider server={conn}>
|
<ServerSDKProvider server={conn}>
|
||||||
<ServerSyncProvider server={conn}>{props.children}</ServerSyncProvider>
|
<ServerSyncProvider server={conn}>{props.children}</ServerSyncProvider>
|
||||||
@@ -107,6 +134,35 @@ const TargetSessionRoute = () => (
|
|||||||
</TargetServerRoute>
|
</TargetServerRoute>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
function LegacyTargetSessionRoute() {
|
||||||
|
const params = useParams<{ serverKey: string; id: string }>()
|
||||||
|
return (
|
||||||
|
<TargetServerRoute>
|
||||||
|
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)}>
|
||||||
|
<LegacyTargetSessionRedirect />
|
||||||
|
</SessionRouteErrorBoundary>
|
||||||
|
</TargetServerRoute>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LegacyTargetSessionRedirect() {
|
||||||
|
const params = useParams<{ id: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const sync = useServerSync()
|
||||||
|
const current = createSessionLineage(
|
||||||
|
() => params.id,
|
||||||
|
() => sync().session.lineage,
|
||||||
|
)
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const directory = current()?.session.location.directory
|
||||||
|
if (!directory) return
|
||||||
|
navigate(legacySessionHref(directory, params.id), { replace: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
|
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
|
||||||
// server via ServerKey, then provide the server-scoped shell for that server.
|
// server via ServerKey, then provide the server-scoped shell for that server.
|
||||||
function SelectedServerProviders(props: ParentProps) {
|
function SelectedServerProviders(props: ParentProps) {
|
||||||
@@ -119,8 +175,17 @@ function SelectedServerProviders(props: ParentProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LegacyServerLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
|
||||||
|
return (
|
||||||
|
<SelectedServerProviders>
|
||||||
|
<LegacyServerScopedShell serverScoped={props.serverScoped}>{props.children}</LegacyServerScopedShell>
|
||||||
|
</SelectedServerProviders>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function DraftRoute() {
|
function DraftRoute() {
|
||||||
const [search] = useSearchParams<{ draftId?: string }>()
|
const [search] = useSearchParams<{ draftId?: string }>()
|
||||||
|
const settings = useSettings()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
return (
|
return (
|
||||||
<Show when={tabs.ready()}>
|
<Show when={tabs.ready()}>
|
||||||
@@ -129,7 +194,14 @@ function DraftRoute() {
|
|||||||
keyed
|
keyed
|
||||||
fallback={<Navigate href="/" />}
|
fallback={<Navigate href="/" />}
|
||||||
>
|
>
|
||||||
{(draft) => <ResolvedDraftRoute draft={draft} />}
|
{(draft) => (
|
||||||
|
<Show
|
||||||
|
when={settings.general.newLayoutDesigns()}
|
||||||
|
fallback={<Navigate href={`/${base64Encode(draft.directory)}/session`} />}
|
||||||
|
>
|
||||||
|
<ResolvedDraftRoute draft={draft} />
|
||||||
|
</Show>
|
||||||
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
@@ -165,6 +237,10 @@ function UiI18nBridge(props: ParentProps) {
|
|||||||
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
|
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LayoutCompatibility(props: ParentProps) {
|
||||||
|
return <>{props.children}</>
|
||||||
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
__OPENCODE__?: {
|
__OPENCODE__?: {
|
||||||
@@ -191,11 +267,17 @@ function QueryProvider(props: ParentProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function BodyDesignClass() {
|
function BodyDesignClass() {
|
||||||
|
const settings = useSettings()
|
||||||
|
|
||||||
createRenderEffect(() => {
|
createRenderEffect(() => {
|
||||||
if (typeof document === "undefined") return
|
if (typeof document === "undefined") return
|
||||||
document.body.toggleAttribute("data-new-layout", true)
|
|
||||||
document.body.classList.remove("text-12-regular")
|
const enabled = settings.general.newLayoutDesigns()
|
||||||
document.body.classList.add("font-(family-name:--font-family-text)", "text-[13px]", "font-[440]")
|
document.body.toggleAttribute("data-new-layout", enabled)
|
||||||
|
document.body.classList.toggle("text-12-regular", !enabled)
|
||||||
|
document.body.classList.toggle("font-(family-name:--font-family-text)", enabled)
|
||||||
|
document.body.classList.toggle("text-[13px]", enabled)
|
||||||
|
document.body.classList.toggle("font-[440]", enabled)
|
||||||
})
|
})
|
||||||
|
|
||||||
return null
|
return null
|
||||||
@@ -238,6 +320,7 @@ function DesktopCommands() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Server-scoped providers shared by the legacy shell and the top-level new shell.
|
||||||
type ServerScopedShellProps = ParentProps<{
|
type ServerScopedShellProps = ParentProps<{
|
||||||
directory?: () => string | undefined
|
directory?: () => string | undefined
|
||||||
serverScoped?: JSX.Element
|
serverScoped?: JSX.Element
|
||||||
@@ -252,11 +335,19 @@ function ServerScopedProviders(props: ServerScopedShellProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function AppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
|
function LegacyServerScopedShell(props: ServerScopedShellProps) {
|
||||||
|
return (
|
||||||
|
<ServerScopedProviders directory={props.directory} serverScoped={props.serverScoped}>
|
||||||
|
<LegacyLayout>{props.children}</LegacyLayout>
|
||||||
|
</ServerScopedProviders>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewAppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
|
||||||
return (
|
return (
|
||||||
<SelectedServerProviders>
|
<SelectedServerProviders>
|
||||||
<ServerScopedProviders serverScoped={props.serverScoped}>
|
<ServerScopedProviders serverScoped={props.serverScoped}>
|
||||||
<Layout>{props.children}</Layout>
|
<NewLayout>{props.children}</NewLayout>
|
||||||
</ServerScopedProviders>
|
</ServerScopedProviders>
|
||||||
</SelectedServerProviders>
|
</SelectedServerProviders>
|
||||||
)
|
)
|
||||||
@@ -445,7 +536,7 @@ export function AppInterface(props: {
|
|||||||
startup?: Promise<void>
|
startup?: Promise<void>
|
||||||
serverScoped?: JSX.Element
|
serverScoped?: JSX.Element
|
||||||
}) {
|
}) {
|
||||||
// The visual layout lives in the router root so it remains mounted across
|
// The visual new layout lives in the router root so it remains mounted across
|
||||||
// route changes. Draft and session routes override only their server-bound data
|
// route changes. Draft and session routes override only their server-bound data
|
||||||
// providers beneath it.
|
// providers beneath it.
|
||||||
const ServerShell = (shellProps: ParentProps) => (
|
const ServerShell = (shellProps: ParentProps) => (
|
||||||
@@ -466,6 +557,7 @@ export function AppInterface(props: {
|
|||||||
<GlobalProvider>
|
<GlobalProvider>
|
||||||
<SettingsProvider>
|
<SettingsProvider>
|
||||||
<ConnectionGate disableHealthCheck={props.disableHealthCheck} startup={props.startup}>
|
<ConnectionGate disableHealthCheck={props.disableHealthCheck} startup={props.startup}>
|
||||||
|
<Show when={useSettings().general.newLayoutDesigns().toString()} keyed>
|
||||||
<Dynamic
|
<Dynamic
|
||||||
component={props.router ?? Router}
|
component={props.router ?? Router}
|
||||||
root={(routerProps) => (
|
root={(routerProps) => (
|
||||||
@@ -473,15 +565,18 @@ export function AppInterface(props: {
|
|||||||
<PermissionProvider>
|
<PermissionProvider>
|
||||||
<NotificationProvider>
|
<NotificationProvider>
|
||||||
<ServerShell>
|
<ServerShell>
|
||||||
<AppLayout serverScoped={props.serverScoped}>{routerProps.children}</AppLayout>
|
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
||||||
|
<NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
|
||||||
|
</Show>
|
||||||
</ServerShell>
|
</ServerShell>
|
||||||
</NotificationProvider>
|
</NotificationProvider>
|
||||||
</PermissionProvider>
|
</PermissionProvider>
|
||||||
</TabsProvider>
|
</TabsProvider>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Routes />
|
<Routes serverScoped={props.serverScoped} />
|
||||||
</Dynamic>
|
</Dynamic>
|
||||||
|
</Show>
|
||||||
</ConnectionGate>
|
</ConnectionGate>
|
||||||
</SettingsProvider>
|
</SettingsProvider>
|
||||||
</GlobalProvider>
|
</GlobalProvider>
|
||||||
@@ -489,20 +584,40 @@ export function AppInterface(props: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Routes() {
|
function Routes(props: { serverScoped?: JSX.Element }) {
|
||||||
|
const settings = useSettings()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Route path="/" component={Home} />
|
<Route
|
||||||
<Route path="/:dir" component={DirectoryDraftRedirect} />
|
component={(routeProps) => (
|
||||||
<Route path="/:dir/session" component={DirectoryDraftRedirect} />
|
<LegacyServerLayout serverScoped={props.serverScoped}>{routeProps.children}</LegacyServerLayout>
|
||||||
<Route path="/:dir/session/:id" component={LegacySessionRedirect} />
|
)}
|
||||||
|
>
|
||||||
|
<Show when={!settings.general.newLayoutDesigns()}>
|
||||||
|
{
|
||||||
|
<>
|
||||||
|
<Route path="/" component={LegacyHome} />
|
||||||
|
<Route path="/server/:serverKey/session/:id" component={LegacyTargetSessionRoute} />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</Show>
|
||||||
|
<Route path="/:dir" component={DirectoryLayout}>
|
||||||
|
<Route path="/" component={() => <Navigate href="session" />} />
|
||||||
|
<Route path="/session/:id?" component={SessionRoute} />
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
|
<Show when={settings.general.newLayoutDesigns()}>
|
||||||
|
<Route path="/" component={NewHome} />
|
||||||
|
<Route path="/:dir/session/:id" component={NewLayoutLegacySessionRedirect} />
|
||||||
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
|
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
|
||||||
|
</Show>
|
||||||
<Route path="/new-session" component={DraftRoute} />
|
<Route path="/new-session" component={DraftRoute} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function LegacySessionRedirect() {
|
function NewLayoutLegacySessionRedirect() {
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
const params = useParams<{ id: string }>()
|
const params = useParams<{ id: string }>()
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 187 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 163 KiB |
@@ -0,0 +1,170 @@
|
|||||||
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { For, Show } from "solid-js"
|
||||||
|
import { type LocalProject, getAvatarColors } from "@/context/layout"
|
||||||
|
import { Avatar } from "@opencode-ai/ui/avatar"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||||
|
import { ServerConnection } from "@/context/server"
|
||||||
|
import { createEditProjectModel } from "./edit-project"
|
||||||
|
|
||||||
|
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
||||||
|
|
||||||
|
export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) {
|
||||||
|
const language = useLanguage()
|
||||||
|
const model = createEditProjectModel(props)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog title={language.t("dialog.project.edit.title")} class="w-full max-w-[480px] mx-auto">
|
||||||
|
<form onSubmit={model.submit} class="flex flex-col gap-6 p-6 pt-0">
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<TextField
|
||||||
|
autofocus
|
||||||
|
type="text"
|
||||||
|
label={language.t("dialog.project.edit.name")}
|
||||||
|
placeholder={model.folderName()}
|
||||||
|
value={model.store.name}
|
||||||
|
onChange={(v) => model.setStore("name", v)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.icon")}</label>
|
||||||
|
<div class="flex gap-3 items-start">
|
||||||
|
<div
|
||||||
|
class="relative"
|
||||||
|
onMouseEnter={() => model.setStore("iconHover", true)}
|
||||||
|
onMouseLeave={() => model.setStore("iconHover", false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="relative size-16 rounded-md transition-colors cursor-pointer"
|
||||||
|
classList={{
|
||||||
|
"border-text-interactive-base bg-surface-info-base/20": model.store.dragOver,
|
||||||
|
"border-border-base hover:border-border-strong": !model.store.dragOver,
|
||||||
|
"overflow-hidden": !!model.store.iconOverride,
|
||||||
|
}}
|
||||||
|
onDrop={model.drop}
|
||||||
|
onDragOver={model.dragOver}
|
||||||
|
onDragLeave={model.dragLeave}
|
||||||
|
onClick={model.iconClick}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={getProjectAvatarSource(props.project.id, {
|
||||||
|
color: model.store.color,
|
||||||
|
url: props.project.icon?.url,
|
||||||
|
override: model.store.iconOverride,
|
||||||
|
})}
|
||||||
|
fallback={
|
||||||
|
<div class="size-full flex items-center justify-center">
|
||||||
|
<Avatar
|
||||||
|
fallback={model.store.name || model.defaultName()}
|
||||||
|
{...getAvatarColors(model.store.color)}
|
||||||
|
class="size-full text-[32px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(src) => (
|
||||||
|
<img
|
||||||
|
src={src()}
|
||||||
|
alt={language.t("dialog.project.edit.icon.alt")}
|
||||||
|
class="size-full object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
|
||||||
|
classList={{
|
||||||
|
"opacity-100": model.store.iconHover && !model.store.iconOverride,
|
||||||
|
"opacity-0": !(model.store.iconHover && !model.store.iconOverride),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="cloud-upload" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
|
||||||
|
classList={{
|
||||||
|
"opacity-100": model.store.iconHover && !!model.store.iconOverride,
|
||||||
|
"opacity-0": !(model.store.iconHover && !!model.store.iconOverride),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="icon-upload"
|
||||||
|
ref={(el) => {
|
||||||
|
model.setIconInput(el)
|
||||||
|
}}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="hidden"
|
||||||
|
onChange={model.inputChange}
|
||||||
|
/>
|
||||||
|
<div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center">
|
||||||
|
<span>{language.t("dialog.project.edit.icon.hint")}</span>
|
||||||
|
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={!model.store.iconOverride}>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.color")}</label>
|
||||||
|
<div class="flex gap-1.5">
|
||||||
|
<For each={AVATAR_COLOR_KEYS}>
|
||||||
|
{(color) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={language.t("dialog.project.edit.color.select", { color })}
|
||||||
|
aria-pressed={model.store.color === color}
|
||||||
|
classList={{
|
||||||
|
"flex items-center justify-center size-10 p-0.5 rounded-lg overflow-hidden transition-colors cursor-default": true,
|
||||||
|
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover":
|
||||||
|
model.store.color === color,
|
||||||
|
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
|
||||||
|
model.store.color !== color,
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (model.store.color === color && !props.project.icon?.url) return
|
||||||
|
model.setStore("color", model.store.color === color ? undefined : color)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
fallback={model.store.name || model.defaultName()}
|
||||||
|
{...getAvatarColors(color)}
|
||||||
|
class="size-full rounded"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
label={language.t("dialog.project.edit.worktree.startup")}
|
||||||
|
description={language.t("dialog.project.edit.worktree.startup.description")}
|
||||||
|
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
|
||||||
|
value={model.store.startup}
|
||||||
|
onChange={(v) => model.setStore("startup", v)}
|
||||||
|
spellcheck={false}
|
||||||
|
class="max-h-14 w-full overflow-y-auto font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="ghost" size="large" onClick={model.close}>
|
||||||
|
{language.t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="primary" size="large" disabled={!model.supported || model.save.isPending}>
|
||||||
|
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ const statusLabels = {
|
|||||||
connected: "mcp.status.connected",
|
connected: "mcp.status.connected",
|
||||||
failed: "mcp.status.failed",
|
failed: "mcp.status.failed",
|
||||||
needs_auth: "mcp.status.needs_auth",
|
needs_auth: "mcp.status.needs_auth",
|
||||||
|
needs_client_registration: "mcp.status.needs_client_registration",
|
||||||
disabled: "mcp.status.disabled",
|
disabled: "mcp.status.disabled",
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
@@ -56,7 +57,7 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
}
|
}
|
||||||
const error = () => {
|
const error = () => {
|
||||||
const s = mcpStatus()
|
const s = mcpStatus()
|
||||||
if (s?.status === "failed") return s.error
|
if (s?.status === "failed" || s?.status === "needs_client_registration") return s.error
|
||||||
}
|
}
|
||||||
const enabled = () => status() === "connected"
|
const enabled = () => status() === "connected"
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -6,17 +6,21 @@ 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 { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { Show } from "solid-js"
|
import { useMutation } from "@tanstack/solid-query"
|
||||||
|
import { showToast } from "@/utils/toast"
|
||||||
|
import { useNavigate } from "@solidjs/router"
|
||||||
|
import { createEffect, createMemo, createResource, Show } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
||||||
|
import { useGlobal } from "@/context/global"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { ServerConnection } from "@/context/server"
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||||
|
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import {
|
import { useTabs } from "@/context/tabs"
|
||||||
type ServerDomainController,
|
|
||||||
type ServerFormController,
|
const DEFAULT_USERNAME = "opencode"
|
||||||
useServerDomainController,
|
|
||||||
useServerFormController,
|
|
||||||
} from "@/components/server/server-management-controller"
|
|
||||||
|
|
||||||
interface ServerFormProps {
|
interface ServerFormProps {
|
||||||
value: string
|
value: string
|
||||||
@@ -35,6 +39,76 @@ interface ServerFormProps {
|
|||||||
onBack: () => void
|
onBack: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: language.t("common.requestFailed"),
|
||||||
|
description: err instanceof Error ? err.message : String(err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function useDefaultServer() {
|
||||||
|
const language = useLanguage()
|
||||||
|
const platform = usePlatform()
|
||||||
|
const [defaultKey, defaultUrlActions] = createResource(
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
const key = await platform.getDefaultServer?.()
|
||||||
|
if (!key) return null
|
||||||
|
return key
|
||||||
|
} catch (err) {
|
||||||
|
showRequestError(language, err)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ initialValue: null },
|
||||||
|
)
|
||||||
|
|
||||||
|
const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer)
|
||||||
|
const setDefault = async (key: ServerConnection.Key | null) => {
|
||||||
|
try {
|
||||||
|
await platform.setDefaultServer?.(key)
|
||||||
|
defaultUrlActions.mutate(key)
|
||||||
|
} catch (err) {
|
||||||
|
showRequestError(language, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { defaultKey: () => defaultKey.latest, 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) => {
|
||||||
@@ -102,40 +176,385 @@ function ServerForm(props: ServerFormProps) {
|
|||||||
|
|
||||||
export function DialogSelectServer() {
|
export function DialogSelectServer() {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const controller = useServerManagementController({ onSelect: dialog.close })
|
||||||
const domain = useServerDomainController({ onSelect: () => dialog.close() })
|
|
||||||
const form = useServerFormController({ onSelect: () => dialog.close() })
|
|
||||||
const title = () => {
|
|
||||||
if (!form.state.open()) return language.t("dialog.server.title")
|
|
||||||
return (
|
|
||||||
<div class="flex items-center gap-2 -ml-2">
|
|
||||||
<IconButton icon="arrow-left" variant="ghost" onClick={form.reset} aria-label={language.t("common.goBack")} />
|
|
||||||
<span>
|
|
||||||
{form.state.adding() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog title={title()}>
|
<Dialog title={controller.formTitle()}>
|
||||||
<div class="flex flex-1 min-h-0 flex-col px-5">
|
<div class="flex flex-1 min-h-0 flex-col px-5">
|
||||||
<Show
|
<Show when={controller.isFormMode()} fallback={<ServerConnectionList controller={controller} />}>
|
||||||
when={form.state.open()}
|
<ServerConnectionForm controller={controller} />
|
||||||
fallback={<ServerConnectionList domain={domain} onAdd={form.start.add} onEdit={form.start.edit} />}
|
|
||||||
>
|
|
||||||
<ServerConnectionForm form={form} />
|
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ServerConnectionList(props: {
|
export function useServerManagementController(options: { onSelect?: () => void; navigateOnAdd?: boolean } = {}) {
|
||||||
domain: ServerDomainController
|
const navigate = useNavigate()
|
||||||
onAdd: () => void
|
const server = useServer()
|
||||||
onEdit: (server: ServerConnection.Http) => void
|
const tabs = useTabs()
|
||||||
}) {
|
const global = useGlobal()
|
||||||
|
const platform = usePlatform()
|
||||||
|
const language = useLanguage()
|
||||||
|
const { defaultKey, canDefault, setDefault } = useDefaultServer()
|
||||||
|
const { previewStatus } = useServerPreview()
|
||||||
|
const checkServerHealth = useCheckServerHealth()
|
||||||
|
const [store, setStore] = createStore({
|
||||||
|
addServer: {
|
||||||
|
url: "",
|
||||||
|
name: "",
|
||||||
|
username: DEFAULT_USERNAME,
|
||||||
|
password: "",
|
||||||
|
error: "",
|
||||||
|
showForm: false,
|
||||||
|
status: undefined as boolean | undefined,
|
||||||
|
},
|
||||||
|
editServer: {
|
||||||
|
id: undefined as string | undefined,
|
||||||
|
value: "",
|
||||||
|
name: "",
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
error: "",
|
||||||
|
status: undefined as boolean | undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const resetAdd = () => {
|
||||||
|
setStore("addServer", {
|
||||||
|
url: "",
|
||||||
|
name: "",
|
||||||
|
username: DEFAULT_USERNAME,
|
||||||
|
password: "",
|
||||||
|
error: "",
|
||||||
|
showForm: false,
|
||||||
|
status: undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const resetEdit = () => {
|
||||||
|
setStore("editServer", {
|
||||||
|
id: undefined,
|
||||||
|
value: "",
|
||||||
|
name: "",
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
error: "",
|
||||||
|
status: undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const addMutation = useMutation(() => ({
|
||||||
|
mutationFn: async (value: string) => {
|
||||||
|
const normalized = normalizeServerUrl(value)
|
||||||
|
if (!normalized) {
|
||||||
|
resetAdd()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const conn: ServerConnection.Http = {
|
||||||
|
type: "http",
|
||||||
|
http: { url: normalized },
|
||||||
|
}
|
||||||
|
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
|
||||||
|
if (store.addServer.password) conn.http.password = store.addServer.password
|
||||||
|
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
|
||||||
|
const result = await checkServerHealth(conn.http)
|
||||||
|
if (!result.healthy) {
|
||||||
|
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resetAdd()
|
||||||
|
if (options.navigateOnAdd === false) {
|
||||||
|
server.add(conn)
|
||||||
|
options.onSelect?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await select(conn, true)
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const editMutation = useMutation(() => ({
|
||||||
|
mutationFn: async (input: { original: ServerConnection.Any; value: string }) => {
|
||||||
|
if (input.original.type !== "http") return
|
||||||
|
const normalized = normalizeServerUrl(input.value)
|
||||||
|
if (!normalized) {
|
||||||
|
resetEdit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = store.editServer.name.trim() || undefined
|
||||||
|
const username = store.editServer.username || undefined
|
||||||
|
const password = store.editServer.password || undefined
|
||||||
|
const existingName = input.original.displayName
|
||||||
|
if (
|
||||||
|
normalized === input.original.http.url &&
|
||||||
|
name === existingName &&
|
||||||
|
username === input.original.http.username &&
|
||||||
|
password === input.original.http.password
|
||||||
|
) {
|
||||||
|
resetEdit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const conn: ServerConnection.Http = {
|
||||||
|
type: "http",
|
||||||
|
displayName: name,
|
||||||
|
http: { url: normalized, username, password },
|
||||||
|
}
|
||||||
|
const result = await checkServerHealth(conn.http)
|
||||||
|
if (!result.healthy) {
|
||||||
|
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (normalized === input.original.http.url) {
|
||||||
|
server.add(conn)
|
||||||
|
} else {
|
||||||
|
replaceServer(input.original, conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
resetEdit()
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
|
||||||
|
const originalKey = ServerConnection.key(original)
|
||||||
|
const active = server.key
|
||||||
|
tabs.removeServer(originalKey)
|
||||||
|
const newConn = server.add(next)
|
||||||
|
if (!newConn) return
|
||||||
|
const nextActive = active === originalKey ? ServerConnection.key(newConn) : active
|
||||||
|
if (nextActive) server.setActive(nextActive)
|
||||||
|
server.remove(originalKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = createMemo(() => {
|
||||||
|
const current = server.current
|
||||||
|
const list = server.list
|
||||||
|
if (!current) return list
|
||||||
|
if (!list.includes(current)) return [current, ...list]
|
||||||
|
return [current, ...list.filter((x) => x !== current)]
|
||||||
|
})
|
||||||
|
|
||||||
|
const settings = useSettings()
|
||||||
|
const current = createMemo<ServerConnection.Any | undefined>(() =>
|
||||||
|
settings.general.newLayoutDesigns()
|
||||||
|
? undefined
|
||||||
|
: (items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const sortedItems = createMemo(() => {
|
||||||
|
const raw = items()
|
||||||
|
const list = raw
|
||||||
|
if (!list.length) return list
|
||||||
|
const active = current()
|
||||||
|
const order = new Map(list.map((url, index) => [url, index] as const))
|
||||||
|
const rank = (value?: ServerHealth) => {
|
||||||
|
if (value?.healthy === true) return 0
|
||||||
|
if (value?.healthy === false) return 2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return list.slice().sort((a, b) => {
|
||||||
|
if (a === active) return -1
|
||||||
|
if (b === active) return 1
|
||||||
|
const diff =
|
||||||
|
rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)])
|
||||||
|
if (diff !== 0) return diff
|
||||||
|
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
async function select(conn: ServerConnection.Any, persist?: boolean) {
|
||||||
|
if (!persist && global.servers.health[ServerConnection.key(conn)]?.healthy === false) return
|
||||||
|
options.onSelect?.()
|
||||||
|
if (persist && conn.type === "http") {
|
||||||
|
server.add(conn)
|
||||||
|
navigate("/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
navigate("/")
|
||||||
|
queueMicrotask(() => server.setActive(ServerConnection.key(conn)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddChange = (value: string) => {
|
||||||
|
if (addMutation.isPending) return
|
||||||
|
setStore("addServer", { url: value, error: "" })
|
||||||
|
void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
|
||||||
|
setStore("addServer", { status: next }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddNameChange = (value: string) => {
|
||||||
|
if (addMutation.isPending) return
|
||||||
|
setStore("addServer", { name: value, error: "" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddUsernameChange = (value: string) => {
|
||||||
|
if (addMutation.isPending) return
|
||||||
|
setStore("addServer", { username: value, error: "" })
|
||||||
|
void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
|
||||||
|
setStore("addServer", { status: next }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddPasswordChange = (value: string) => {
|
||||||
|
if (addMutation.isPending) return
|
||||||
|
setStore("addServer", { password: value, error: "" })
|
||||||
|
void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
|
||||||
|
setStore("addServer", { status: next }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditChange = (value: string) => {
|
||||||
|
if (editMutation.isPending) return
|
||||||
|
setStore("editServer", { value, error: "" })
|
||||||
|
void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
|
||||||
|
setStore("editServer", { status: next }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditNameChange = (value: string) => {
|
||||||
|
if (editMutation.isPending) return
|
||||||
|
setStore("editServer", { name: value, error: "" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditUsernameChange = (value: string) => {
|
||||||
|
if (editMutation.isPending) return
|
||||||
|
setStore("editServer", { username: value, error: "" })
|
||||||
|
void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
|
||||||
|
setStore("editServer", { status: next }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditPasswordChange = (value: string) => {
|
||||||
|
if (editMutation.isPending) return
|
||||||
|
setStore("editServer", { password: value, error: "" })
|
||||||
|
void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
|
||||||
|
setStore("editServer", { status: next }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const mode = createMemo<"list" | "add" | "edit">(() => {
|
||||||
|
if (store.editServer.id) return "edit"
|
||||||
|
if (store.addServer.showForm) return "add"
|
||||||
|
return "list"
|
||||||
|
})
|
||||||
|
|
||||||
|
const editing = createMemo(() => {
|
||||||
|
if (!store.editServer.id) return
|
||||||
|
return items().find((x) => x.type === "http" && x.http.url === store.editServer.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
resetAdd()
|
||||||
|
resetEdit()
|
||||||
|
}
|
||||||
|
|
||||||
|
const startAdd = () => {
|
||||||
|
resetEdit()
|
||||||
|
setStore("addServer", {
|
||||||
|
showForm: true,
|
||||||
|
url: "",
|
||||||
|
name: "",
|
||||||
|
username: DEFAULT_USERNAME,
|
||||||
|
password: "",
|
||||||
|
error: "",
|
||||||
|
status: undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const startEdit = (conn: ServerConnection.Http) => {
|
||||||
|
resetAdd()
|
||||||
|
setStore("editServer", {
|
||||||
|
id: conn.http.url,
|
||||||
|
value: conn.http.url,
|
||||||
|
name: conn.displayName ?? "",
|
||||||
|
username: conn.http.username ?? "",
|
||||||
|
password: conn.http.password ?? "",
|
||||||
|
error: "",
|
||||||
|
status: global.servers.health[ServerConnection.key(conn)]?.healthy,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitForm = () => {
|
||||||
|
if (mode() === "add") {
|
||||||
|
if (addMutation.isPending) return
|
||||||
|
setStore("addServer", { error: "" })
|
||||||
|
addMutation.mutate(store.addServer.url)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const original = editing()
|
||||||
|
if (!original) return
|
||||||
|
if (editMutation.isPending) return
|
||||||
|
setStore("editServer", { error: "" })
|
||||||
|
editMutation.mutate({ original, value: store.editServer.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFormMode = createMemo(() => mode() !== "list")
|
||||||
|
const isAddMode = createMemo(() => mode() === "add")
|
||||||
|
const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending))
|
||||||
|
|
||||||
|
const formTitle = createMemo(() => {
|
||||||
|
if (!isFormMode()) return language.t("dialog.server.title")
|
||||||
|
return (
|
||||||
|
<div class="flex items-center gap-2 -ml-2">
|
||||||
|
<IconButton icon="arrow-left" variant="ghost" onClick={resetForm} aria-label={language.t("common.goBack")} />
|
||||||
|
<span>{isAddMode() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!store.editServer.id) return
|
||||||
|
if (editing()) return
|
||||||
|
resetEdit()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleRemove(key: ServerConnection.Key) {
|
||||||
|
try {
|
||||||
|
if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key)
|
||||||
|
tabs.removeServer(key)
|
||||||
|
server.remove(key)
|
||||||
|
if ((await platform.getDefaultServer?.()) === key) {
|
||||||
|
await setDefault(null)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showRequestError(language, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
defaultKey,
|
||||||
|
canDefault,
|
||||||
|
current,
|
||||||
|
sortedItems,
|
||||||
|
status: () => global.servers.health,
|
||||||
|
isFormMode,
|
||||||
|
isAddMode,
|
||||||
|
formTitle,
|
||||||
|
formBusy,
|
||||||
|
formValue: () => (isAddMode() ? store.addServer.url : store.editServer.value),
|
||||||
|
formName: () => (isAddMode() ? store.addServer.name : store.editServer.name),
|
||||||
|
formUsername: () => (isAddMode() ? store.addServer.username : store.editServer.username),
|
||||||
|
formPassword: () => (isAddMode() ? store.addServer.password : store.editServer.password),
|
||||||
|
formError: () => (isAddMode() ? store.addServer.error : store.editServer.error),
|
||||||
|
formStatus: () => (isAddMode() ? store.addServer.status : store.editServer.status),
|
||||||
|
select,
|
||||||
|
setDefault,
|
||||||
|
startAdd,
|
||||||
|
startEdit,
|
||||||
|
resetForm,
|
||||||
|
submitForm,
|
||||||
|
canRemove: server.canRemove,
|
||||||
|
handleRemove,
|
||||||
|
handleFormChange: () => (isAddMode() ? handleAddChange : handleEditChange),
|
||||||
|
handleFormNameChange: () => (isAddMode() ? handleAddNameChange : handleEditNameChange),
|
||||||
|
handleFormUsernameChange: () => (isAddMode() ? handleAddUsernameChange : handleEditUsernameChange),
|
||||||
|
handleFormPasswordChange: () => (isAddMode() ? handleAddPasswordChange : handleEditPasswordChange),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServerConnectionList(props: { controller: ReturnType<typeof useServerManagementController> }) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
|
|
||||||
@@ -149,10 +568,10 @@ export function ServerConnectionList(props: {
|
|||||||
}}
|
}}
|
||||||
noInitialSelection
|
noInitialSelection
|
||||||
emptyMessage={language.t("dialog.server.empty")}
|
emptyMessage={language.t("dialog.server.empty")}
|
||||||
items={props.domain.collection.items}
|
items={props.controller.sortedItems}
|
||||||
key={(x) => x.http.url}
|
key={(x) => x.http.url}
|
||||||
onSelect={(x) => {
|
onSelect={(x) => {
|
||||||
if (x && !settings.general.newLayoutDesigns()) void props.domain.selection.select(x)
|
if (x && !settings.general.newLayoutDesigns()) void props.controller.select(x)
|
||||||
}}
|
}}
|
||||||
divider={true}
|
divider={true}
|
||||||
>
|
>
|
||||||
@@ -161,15 +580,15 @@ export function ServerConnectionList(props: {
|
|||||||
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-center w-5">
|
<div class="flex flex-col h-full items-center w-5">
|
||||||
<ServerHealthIndicator health={props.domain.collection.health()[key]} />
|
<ServerHealthIndicator health={props.controller.status()[key]} />
|
||||||
</div>
|
</div>
|
||||||
<ServerRow
|
<ServerRow
|
||||||
conn={i}
|
conn={i}
|
||||||
dimmed={props.domain.collection.health()[key]?.healthy === false}
|
dimmed={props.controller.status()[key]?.healthy === false}
|
||||||
status={props.domain.collection.health()[key]}
|
status={props.controller.status()[key]}
|
||||||
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={props.domain.defaults.key() === ServerConnection.key(i)}>
|
<Show when={props.controller.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>
|
||||||
@@ -178,12 +597,7 @@ export function ServerConnectionList(props: {
|
|||||||
showCredentials
|
showCredentials
|
||||||
/>
|
/>
|
||||||
<div class="flex items-center justify-center gap-4 pl-4">
|
<div class="flex items-center justify-center gap-4 pl-4">
|
||||||
<Show
|
<Show when={props.controller.current() && ServerConnection.key(props.controller.current()!) === key}>
|
||||||
when={
|
|
||||||
props.domain.collection.current() &&
|
|
||||||
ServerConnection.key(props.domain.collection.current()!) === key
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Icon name="check" class="h-6" />
|
<Icon name="check" class="h-6" />
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
@@ -202,27 +616,27 @@ export function ServerConnectionList(props: {
|
|||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
if (i.type !== "http") return
|
if (i.type !== "http") return
|
||||||
props.onEdit(i)
|
props.controller.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={props.domain.defaults.available() && props.domain.defaults.key() !== key}>
|
<Show when={props.controller.canDefault() && props.controller.defaultKey() !== key}>
|
||||||
<DropdownMenu.Item onSelect={() => props.domain.defaults.set(key)}>
|
<DropdownMenu.Item onSelect={() => props.controller.setDefault(key)}>
|
||||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.default")}</DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.default")}</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
|
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}>
|
||||||
<DropdownMenu.Item onSelect={() => props.domain.defaults.set(null)}>
|
<DropdownMenu.Item onSelect={() => props.controller.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={props.domain.connection.canRemove(key)}>
|
<Show when={props.controller.canRemove(key)}>
|
||||||
<DropdownMenu.Separator />
|
<DropdownMenu.Separator />
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onSelect={() => props.domain.connection.remove(key)}
|
onSelect={() => props.controller.handleRemove(ServerConnection.key(i))}
|
||||||
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
|
||||||
@@ -243,7 +657,7 @@ export function ServerConnectionList(props: {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
icon="plus-small"
|
icon="plus-small"
|
||||||
size="large"
|
size="large"
|
||||||
onClick={props.onAdd}
|
onClick={props.controller.startAdd}
|
||||||
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
|
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
|
||||||
>
|
>
|
||||||
{language.t("dialog.server.add.button")}
|
{language.t("dialog.server.add.button")}
|
||||||
@@ -253,38 +667,38 @@ export function ServerConnectionList(props: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ServerConnectionForm(props: { form: ServerFormController }) {
|
export function ServerConnectionForm(props: { controller: ReturnType<typeof useServerManagementController> }) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-1 min-h-0 flex-col gap-4">
|
<div class="flex flex-1 min-h-0 flex-col gap-4">
|
||||||
<ServerForm
|
<ServerForm
|
||||||
value={props.form.state.value()}
|
value={props.controller.formValue()}
|
||||||
name={props.form.state.name()}
|
name={props.controller.formName()}
|
||||||
username={props.form.state.username()}
|
username={props.controller.formUsername()}
|
||||||
password={props.form.state.password()}
|
password={props.controller.formPassword()}
|
||||||
placeholder={language.t("dialog.server.add.placeholder")}
|
placeholder={language.t("dialog.server.add.placeholder")}
|
||||||
busy={props.form.state.busy()}
|
busy={props.controller.formBusy()}
|
||||||
error={props.form.state.error()}
|
error={props.controller.formError()}
|
||||||
status={props.form.state.status()}
|
status={props.controller.formStatus()}
|
||||||
onChange={props.form.change.value}
|
onChange={props.controller.handleFormChange()}
|
||||||
onNameChange={props.form.change.name}
|
onNameChange={props.controller.handleFormNameChange()}
|
||||||
onUsernameChange={props.form.change.username}
|
onUsernameChange={props.controller.handleFormUsernameChange()}
|
||||||
onPasswordChange={props.form.change.password}
|
onPasswordChange={props.controller.handleFormPasswordChange()}
|
||||||
onSubmit={props.form.submit}
|
onSubmit={props.controller.submitForm}
|
||||||
onBack={props.form.reset}
|
onBack={props.controller.resetForm}
|
||||||
/>
|
/>
|
||||||
<div class="shrink-0 pb-5">
|
<div class="shrink-0 pb-5">
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="large"
|
size="large"
|
||||||
onClick={props.form.submit}
|
onClick={props.controller.submitForm}
|
||||||
disabled={props.form.state.busy()}
|
disabled={props.controller.formBusy()}
|
||||||
class="px-3 py-1.5"
|
class="px-3 py-1.5"
|
||||||
>
|
>
|
||||||
{props.form.state.busy()
|
{props.controller.formBusy()
|
||||||
? language.t("dialog.server.add.checking")
|
? language.t("dialog.server.add.checking")
|
||||||
: props.form.state.adding()
|
: props.controller.isAddMode()
|
||||||
? language.t("dialog.server.add.button")
|
? language.t("dialog.server.add.button")
|
||||||
: language.t("common.save")}
|
: language.t("common.save")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { Component, createSignal, startTransition } from "solid-js"
|
||||||
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
|
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { SettingsGeneral } from "./settings-general"
|
||||||
|
import { SettingsKeybinds } from "./settings-keybinds"
|
||||||
|
import { SettingsProviders } from "./settings-providers"
|
||||||
|
import { SettingsModels } from "./settings-models"
|
||||||
|
import { SettingsServers } from "./settings-servers"
|
||||||
|
|
||||||
|
export const DialogSettings: Component<{ defaultValue?: string }> = (props) => {
|
||||||
|
const language = useLanguage()
|
||||||
|
const platform = usePlatform()
|
||||||
|
const dialog = useDialog()
|
||||||
|
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
|
||||||
|
|
||||||
|
const showProviders = () => {
|
||||||
|
void dialog.show(() => <DialogSettings defaultValue="providers" />)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog size="x-large" transition>
|
||||||
|
<Tabs
|
||||||
|
orientation="vertical"
|
||||||
|
variant="settings"
|
||||||
|
value={tab()}
|
||||||
|
onChange={(value) => void startTransition(() => setTab(value))}
|
||||||
|
class="h-full settings-dialog"
|
||||||
|
>
|
||||||
|
<Tabs.List>
|
||||||
|
<div class="flex flex-col justify-between h-full w-full gap-4">
|
||||||
|
<div class="flex flex-col gap-3 w-full pt-3">
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<Tabs.SectionTitle>{language.t("settings.section.desktop")}</Tabs.SectionTitle>
|
||||||
|
<div class="flex flex-col gap-1.5 w-full">
|
||||||
|
<Tabs.Trigger value="general">
|
||||||
|
<Icon name="sliders" />
|
||||||
|
{language.t("settings.tab.general")}
|
||||||
|
</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger value="shortcuts">
|
||||||
|
<Icon name="keyboard" />
|
||||||
|
{language.t("settings.tab.shortcuts")}
|
||||||
|
</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger value="servers">
|
||||||
|
<Icon name="server" />
|
||||||
|
{language.t("status.popover.tab.servers")}
|
||||||
|
</Tabs.Trigger>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<Tabs.SectionTitle>{language.t("settings.section.server")}</Tabs.SectionTitle>
|
||||||
|
<div class="flex flex-col gap-1.5 w-full">
|
||||||
|
<Tabs.Trigger value="providers">
|
||||||
|
<Icon name="providers" />
|
||||||
|
{language.t("settings.providers.title")}
|
||||||
|
</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger value="models">
|
||||||
|
<Icon name="models" />
|
||||||
|
{language.t("settings.models.title")}
|
||||||
|
</Tabs.Trigger>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1 pl-1 py-1 text-12-medium text-text-weak">
|
||||||
|
<span>{language.t("app.name.desktop")}</span>
|
||||||
|
<span class="text-11-regular">v{platform.version}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tabs.List>
|
||||||
|
<Tabs.Content value="general" class="no-scrollbar">
|
||||||
|
<SettingsGeneral />
|
||||||
|
</Tabs.Content>
|
||||||
|
<Tabs.Content value="shortcuts" class="no-scrollbar">
|
||||||
|
<SettingsKeybinds />
|
||||||
|
</Tabs.Content>
|
||||||
|
<Tabs.Content value="servers" class="no-scrollbar">
|
||||||
|
<SettingsServers />
|
||||||
|
</Tabs.Content>
|
||||||
|
<Tabs.Content value="providers" class="no-scrollbar">
|
||||||
|
<SettingsProviders onBack={showProviders} />
|
||||||
|
</Tabs.Content>
|
||||||
|
<Tabs.Content value="models" class="no-scrollbar">
|
||||||
|
<SettingsModels />
|
||||||
|
</Tabs.Content>
|
||||||
|
</Tabs>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
|
import { createSignal, Show } from "solid-js"
|
||||||
|
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { useSettings } from "@/context/settings"
|
||||||
|
import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
|
||||||
|
import homeImage from "@/assets/help/home.png"
|
||||||
|
import tabsImage from "@/assets/help/tabs.png"
|
||||||
|
|
||||||
|
// TODO: wire to changelog / seen-state when available
|
||||||
|
const showPopover = () => true
|
||||||
|
|
||||||
|
// can remove this after the tabs rollout has been out for a while
|
||||||
|
export function TabsInfoPopup() {
|
||||||
|
const settings = useSettings()
|
||||||
|
const platform = usePlatform()
|
||||||
|
const [drawerOpen, setDrawerOpen] = createSignal(false)
|
||||||
|
const windows = () => platform.platform === "desktop" && platform.os === "windows"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
|
||||||
|
<Show when={settings.general.shouldDisplayTabsToast()}>
|
||||||
|
<div
|
||||||
|
class="fixed bottom-5 right-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
|
||||||
|
aria-label="Introducing Tabs. Organize your work and active sessions with tabs"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Dismiss Tabs information"
|
||||||
|
class="absolute top-3 right-3 z-10 size-5 flex items-center justify-center rounded-[4px] bg-[rgba(0,0,0,0.4)]"
|
||||||
|
onClick={settings.general.dismissTabsToast}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M4.25 11.75L11.75 4.25M11.75 11.75L4.25 4.25" stroke="white" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="relative block h-[232px] w-[184px] cursor-pointer overflow-hidden rounded-[4px] text-left"
|
||||||
|
onClick={() => {
|
||||||
|
settings.general.dismissTabsToast()
|
||||||
|
setDrawerOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
src={introducingTabsVideo}
|
||||||
|
class="absolute inset-0 h-full w-full object-cover"
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
autoplay
|
||||||
|
playsinline
|
||||||
|
aria-hidden="true"
|
||||||
|
onContextMenu={(event) => event.preventDefault()}
|
||||||
|
/>
|
||||||
|
<div class="absolute inset-x-0 bottom-0 flex w-full flex-col items-start gap-1.5 bg-[linear-gradient(180deg,rgba(0,0,0,0)_0%,#000000_100%)] px-3 py-5">
|
||||||
|
<p class="w-full select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-[#FFFFFF]">
|
||||||
|
Introducing Tabs
|
||||||
|
</p>
|
||||||
|
<p class="w-full select-none text-[13px] font-[440] leading-[140%] tracking-[-0.04px] text-[#808080]">
|
||||||
|
Organize your work and active sessions with tabs
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<DrawerContent
|
||||||
|
style={
|
||||||
|
windows()
|
||||||
|
? {
|
||||||
|
inset: "0 0 0 auto",
|
||||||
|
"max-height": "100vh",
|
||||||
|
"max-width": "100vw",
|
||||||
|
"border-radius": "0",
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Show when={windows()}>
|
||||||
|
<DrawerClose
|
||||||
|
as={IconButtonV2}
|
||||||
|
type="button"
|
||||||
|
size="small"
|
||||||
|
variant="neutral"
|
||||||
|
aria-label="Close"
|
||||||
|
icon={<IconV2 name="xmark-small" />}
|
||||||
|
class="absolute top-[10px] left-[-36px]"
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
<div
|
||||||
|
class="flex w-full shrink-0 items-center gap-4 self-stretch border-b border-v2-border-border-muted"
|
||||||
|
classList={{
|
||||||
|
"h-[40px] px-4": windows(),
|
||||||
|
"h-[52px] p-4": !windows(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p class="min-h-0 min-w-0 flex-1 text-[13px] font-[530] leading-5 tracking-[-0.04px] tabular-nums text-v2-text-text-muted">
|
||||||
|
July 14
|
||||||
|
</p>
|
||||||
|
<Show when={!windows()}>
|
||||||
|
<DrawerClose
|
||||||
|
as={IconButtonV2}
|
||||||
|
type="button"
|
||||||
|
size="small"
|
||||||
|
variant="ghost-muted"
|
||||||
|
aria-label="Close"
|
||||||
|
icon={<IconV2 name="xmark-small" />}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<div class="relative flex min-h-0 w-full flex-1 flex-col items-start gap-6 overflow-y-auto p-8">
|
||||||
|
<p class="w-full shrink-0 self-stretch text-[21px] font-[610] leading-6 tracking-[-0.37px] tabular-nums text-v2-text-text-base">
|
||||||
|
Introducing Tabs
|
||||||
|
</p>
|
||||||
|
<div class="flex w-full flex-1 flex-col gap-4 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
|
||||||
|
<p>OpenCode Desktop is now built around tabs.</p>
|
||||||
|
<img src={tabsImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
|
||||||
|
<p>
|
||||||
|
Start a new session in a tab, or open an existing session from any of your projects. Open a new tab when
|
||||||
|
you're starting something new, and close it when you're done.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Keeping a few tabs open makes it easier to organize your active sessions. Rename tabs to something
|
||||||
|
memorable if you plan to keep them around.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
You'll find all your sessions and projects on the new Home screen. Selecting a session opens it in a tab.
|
||||||
|
</p>
|
||||||
|
<img src={homeImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
|
||||||
|
<p>When you reopen the app, your tabs are still open.</p>
|
||||||
|
<p>
|
||||||
|
The new design does not support Git Worktrees yet, it's coming soon. So if you'd prefer to continue using
|
||||||
|
the previous layout, you can switch between layouts in Settings. Just keep in mind that the new layout
|
||||||
|
will become permanent in a few weeks.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,323 +0,0 @@
|
|||||||
import { useNavigate } from "@solidjs/router"
|
|
||||||
import { useMutation } from "@tanstack/solid-query"
|
|
||||||
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
|
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { useGlobal } from "@/context/global"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { usePlatform } from "@/context/platform"
|
|
||||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
|
||||||
import { useSettings } from "@/context/settings"
|
|
||||||
import { useTabs } from "@/context/tabs"
|
|
||||||
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
|
|
||||||
import { showToast } from "@/utils/toast"
|
|
||||||
import { createServerHealthPreview, replaceServerConnection, type ServerFormValues } from "./server-management"
|
|
||||||
|
|
||||||
const DEFAULT_USERNAME = "opencode"
|
|
||||||
|
|
||||||
type FormMode = "list" | "add" | "edit"
|
|
||||||
|
|
||||||
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
|
|
||||||
showToast({
|
|
||||||
variant: "error",
|
|
||||||
title: language.t("common.requestFailed"),
|
|
||||||
description: err instanceof Error ? err.message : String(err),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function useDefaultServer() {
|
|
||||||
const language = useLanguage()
|
|
||||||
const platform = usePlatform()
|
|
||||||
const [defaultKey, defaultKeyActions] = createResource(
|
|
||||||
async () => {
|
|
||||||
try {
|
|
||||||
return (await platform.getDefaultServer?.()) ?? null
|
|
||||||
} catch (err) {
|
|
||||||
showRequestError(language, err)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ initialValue: null },
|
|
||||||
)
|
|
||||||
|
|
||||||
const set = async (key: ServerConnection.Key | null) => {
|
|
||||||
try {
|
|
||||||
await platform.setDefaultServer?.(key)
|
|
||||||
defaultKeyActions.mutate(key)
|
|
||||||
} catch (err) {
|
|
||||||
showRequestError(language, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
key: () => defaultKey.latest,
|
|
||||||
available: createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer),
|
|
||||||
set,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function useServerMutations() {
|
|
||||||
const server = useServer()
|
|
||||||
const tabs = useTabs()
|
|
||||||
|
|
||||||
return {
|
|
||||||
add: (connection: ServerConnection.Http) => server.add(connection),
|
|
||||||
replace: (originalKey: ServerConnection.Key, next: ServerConnection.Http) =>
|
|
||||||
replaceServerConnection(originalKey, next, {
|
|
||||||
active: () => server.key,
|
|
||||||
removeTabs: (key) => tabs.removeServer(key),
|
|
||||||
add: (connection) => server.add(connection),
|
|
||||||
setActive: (key) => server.setActive(key),
|
|
||||||
remove: (key) => server.remove(key),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useServerActionsController() {
|
|
||||||
const server = useServer()
|
|
||||||
const tabs = useTabs()
|
|
||||||
const platform = usePlatform()
|
|
||||||
const language = useLanguage()
|
|
||||||
const defaults = useDefaultServer()
|
|
||||||
|
|
||||||
const remove = async (key: ServerConnection.Key) => {
|
|
||||||
try {
|
|
||||||
if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key)
|
|
||||||
tabs.removeServer(key)
|
|
||||||
server.remove(key)
|
|
||||||
if ((await platform.getDefaultServer?.()) === key) await defaults.set(null)
|
|
||||||
} catch (err) {
|
|
||||||
showRequestError(language, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { defaults, connection: { canRemove: server.canRemove, remove } }
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ServerActionsController = ReturnType<typeof useServerActionsController>
|
|
||||||
|
|
||||||
export function useServerCollectionController() {
|
|
||||||
const server = useServer()
|
|
||||||
const global = useGlobal()
|
|
||||||
const settings = useSettings()
|
|
||||||
const actions = useServerActionsController()
|
|
||||||
|
|
||||||
const items = createMemo(() => {
|
|
||||||
const current = server.current
|
|
||||||
const list = server.list
|
|
||||||
if (!current) return list
|
|
||||||
if (!list.includes(current)) return [current, ...list]
|
|
||||||
return [current, ...list.filter((item) => item !== current)]
|
|
||||||
})
|
|
||||||
const current = createMemo<ServerConnection.Any | undefined>(() =>
|
|
||||||
settings.general.newLayoutDesigns()
|
|
||||||
? undefined
|
|
||||||
: (items().find((item) => ServerConnection.key(item) === server.key) ?? items()[0]),
|
|
||||||
)
|
|
||||||
const sorted = createMemo(() => {
|
|
||||||
const raw = items()
|
|
||||||
const list = raw
|
|
||||||
if (!list.length) return list
|
|
||||||
const active = current()
|
|
||||||
const order = new Map(list.map((item, index) => [item, index] as const))
|
|
||||||
const rank = (value?: ServerHealth) => {
|
|
||||||
if (value?.healthy === true) return 0
|
|
||||||
if (value?.healthy === false) return 2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
return list.slice().sort((a, b) => {
|
|
||||||
if (a === active) return -1
|
|
||||||
if (b === active) return 1
|
|
||||||
const diff =
|
|
||||||
rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)])
|
|
||||||
if (diff !== 0) return diff
|
|
||||||
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
collection: {
|
|
||||||
items: sorted,
|
|
||||||
current,
|
|
||||||
health: () => global.servers.health,
|
|
||||||
},
|
|
||||||
...actions,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ServerCollectionController = ReturnType<typeof useServerCollectionController>
|
|
||||||
|
|
||||||
export function useServerDomainController(options: { onSelect?: () => void } = {}) {
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const server = useServer()
|
|
||||||
const global = useGlobal()
|
|
||||||
const collection = useServerCollectionController()
|
|
||||||
|
|
||||||
const select = async (connection: ServerConnection.Any) => {
|
|
||||||
if (global.servers.health[ServerConnection.key(connection)]?.healthy === false) return
|
|
||||||
options.onSelect?.()
|
|
||||||
navigate("/")
|
|
||||||
queueMicrotask(() => server.setActive(ServerConnection.key(connection)))
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...collection, selection: { select } }
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ServerDomainController = ReturnType<typeof useServerDomainController>
|
|
||||||
|
|
||||||
export function useServerFormController(options: { onSelect?: () => void; navigateOnAdd?: boolean } = {}) {
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const server = useServer()
|
|
||||||
const global = useGlobal()
|
|
||||||
const language = useLanguage()
|
|
||||||
const mutations = useServerMutations()
|
|
||||||
const checkServerHealth = useCheckServerHealth()
|
|
||||||
const healthPreview = createServerHealthPreview(checkServerHealth)
|
|
||||||
const [store, setStore] = createStore({
|
|
||||||
mode: "list" as FormMode,
|
|
||||||
originalUrl: undefined as string | undefined,
|
|
||||||
values: { url: "", name: "", username: DEFAULT_USERNAME, password: "" },
|
|
||||||
error: "",
|
|
||||||
status: undefined as boolean | undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
onCleanup(healthPreview.cancel)
|
|
||||||
|
|
||||||
const reset = () => {
|
|
||||||
healthPreview.cancel()
|
|
||||||
setStore({
|
|
||||||
mode: "list",
|
|
||||||
originalUrl: undefined,
|
|
||||||
values: { url: "", name: "", username: DEFAULT_USERNAME, password: "" },
|
|
||||||
error: "",
|
|
||||||
status: undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const allServers = () => {
|
|
||||||
if (!server.current || server.list.includes(server.current)) return server.list
|
|
||||||
return [server.current, ...server.list]
|
|
||||||
}
|
|
||||||
const editing = createMemo(() =>
|
|
||||||
allServers().find((item) => item.type === "http" && item.http.url === store.originalUrl),
|
|
||||||
)
|
|
||||||
|
|
||||||
const request = useMutation(() => ({
|
|
||||||
mutationFn: async () => {
|
|
||||||
const normalized = normalizeServerUrl(store.values.url)
|
|
||||||
if (!normalized) {
|
|
||||||
reset()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const original = store.mode === "edit" ? editing() : undefined
|
|
||||||
if (store.mode === "edit" && !original) return
|
|
||||||
const name = store.values.name.trim() || undefined
|
|
||||||
const username = store.values.username || undefined
|
|
||||||
const password = store.values.password || undefined
|
|
||||||
if (
|
|
||||||
original?.type === "http" &&
|
|
||||||
normalized === original.http.url &&
|
|
||||||
name === original.displayName &&
|
|
||||||
username === original.http.username &&
|
|
||||||
password === original.http.password
|
|
||||||
) {
|
|
||||||
reset()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const connection: ServerConnection.Http = {
|
|
||||||
type: "http",
|
|
||||||
displayName: name,
|
|
||||||
http: {
|
|
||||||
url: normalized,
|
|
||||||
username: store.mode === "add" && !password ? undefined : username,
|
|
||||||
password,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
const result = await checkServerHealth(connection.http)
|
|
||||||
if (!result.healthy) {
|
|
||||||
setStore("error", language.t("dialog.server.add.error"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (original?.type === "http") {
|
|
||||||
if (normalized === original.http.url) mutations.add(connection)
|
|
||||||
if (normalized !== original.http.url) mutations.replace(ServerConnection.key(original), connection)
|
|
||||||
reset()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
reset()
|
|
||||||
if (options.navigateOnAdd === false) {
|
|
||||||
mutations.add(connection)
|
|
||||||
options.onSelect?.()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
mutations.add(connection)
|
|
||||||
options.onSelect?.()
|
|
||||||
navigate("/")
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
const preview = () => void healthPreview.preview(store.values, (status) => setStore("status", status))
|
|
||||||
const change = (field: keyof ServerFormValues, value: string) => {
|
|
||||||
if (request.isPending) return
|
|
||||||
setStore("values", field, value)
|
|
||||||
setStore("error", "")
|
|
||||||
if (field !== "name") preview()
|
|
||||||
}
|
|
||||||
const startAdd = () => {
|
|
||||||
reset()
|
|
||||||
setStore("mode", "add")
|
|
||||||
}
|
|
||||||
const startEdit = (connection: ServerConnection.Http) => {
|
|
||||||
reset()
|
|
||||||
setStore({
|
|
||||||
mode: "edit",
|
|
||||||
originalUrl: connection.http.url,
|
|
||||||
values: {
|
|
||||||
url: connection.http.url,
|
|
||||||
name: connection.displayName ?? "",
|
|
||||||
username: connection.http.username ?? "",
|
|
||||||
password: connection.http.password ?? "",
|
|
||||||
},
|
|
||||||
error: "",
|
|
||||||
status: global.servers.health[ServerConnection.key(connection)]?.healthy,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const submit = () => {
|
|
||||||
if (store.mode === "list" || request.isPending) return
|
|
||||||
setStore("error", "")
|
|
||||||
request.mutate()
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (store.mode !== "edit") return
|
|
||||||
if (editing()) return
|
|
||||||
reset()
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
state: {
|
|
||||||
mode: () => store.mode,
|
|
||||||
open: () => store.mode !== "list",
|
|
||||||
adding: () => store.mode === "add",
|
|
||||||
busy: () => request.isPending,
|
|
||||||
value: () => store.values.url,
|
|
||||||
name: () => store.values.name,
|
|
||||||
username: () => store.values.username,
|
|
||||||
password: () => store.values.password,
|
|
||||||
error: () => store.error,
|
|
||||||
status: () => store.status,
|
|
||||||
},
|
|
||||||
change: {
|
|
||||||
value: (value: string) => change("url", value),
|
|
||||||
name: (value: string) => change("name", value),
|
|
||||||
username: (value: string) => change("username", value),
|
|
||||||
password: (value: string) => change("password", value),
|
|
||||||
},
|
|
||||||
start: { add: startAdd, edit: startEdit },
|
|
||||||
reset,
|
|
||||||
submit,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ServerFormController = ReturnType<typeof useServerFormController>
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
import { ServerConnection } from "@/context/server"
|
|
||||||
import { createServerHealthPreview, replaceServerConnection, type ServerFormValues } from "./server-management"
|
|
||||||
|
|
||||||
function deferred<T>() {
|
|
||||||
let resolve!: (value: T) => void
|
|
||||||
const promise = new Promise<T>((done) => {
|
|
||||||
resolve = done
|
|
||||||
})
|
|
||||||
return { promise, resolve }
|
|
||||||
}
|
|
||||||
|
|
||||||
const values = (url: string): ServerFormValues => ({ url, name: "", username: "opencode", password: "" })
|
|
||||||
|
|
||||||
describe("createServerHealthPreview", () => {
|
|
||||||
test("ignores an older response that resolves after the latest response", async () => {
|
|
||||||
const first = deferred<{ healthy: boolean }>()
|
|
||||||
const second = deferred<{ healthy: boolean }>()
|
|
||||||
const requests = [first, second]
|
|
||||||
const status: Array<boolean | undefined> = []
|
|
||||||
const preview = createServerHealthPreview(() => requests.shift()!.promise)
|
|
||||||
|
|
||||||
const older = preview.preview(values("old.example.com"), (value) => status.push(value))
|
|
||||||
const latest = preview.preview(values("new.example.com"), (value) => status.push(value))
|
|
||||||
second.resolve({ healthy: true })
|
|
||||||
await latest
|
|
||||||
first.resolve({ healthy: false })
|
|
||||||
await older
|
|
||||||
|
|
||||||
expect(status).toEqual([undefined, undefined, true])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("an incomplete value invalidates an in-flight response", async () => {
|
|
||||||
const request = deferred<{ healthy: boolean }>()
|
|
||||||
const status: Array<boolean | undefined> = []
|
|
||||||
const preview = createServerHealthPreview(() => request.promise)
|
|
||||||
|
|
||||||
const pending = preview.preview(values("server.example.com"), (value) => status.push(value))
|
|
||||||
await preview.preview(values("server"), (value) => status.push(value))
|
|
||||||
request.resolve({ healthy: true })
|
|
||||||
await pending
|
|
||||||
|
|
||||||
expect(status).toEqual([undefined, undefined])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("cancellation prevents an in-flight response from updating status", async () => {
|
|
||||||
const request = deferred<{ healthy: boolean }>()
|
|
||||||
const status: Array<boolean | undefined> = []
|
|
||||||
const preview = createServerHealthPreview(() => request.promise)
|
|
||||||
|
|
||||||
const pending = preview.preview(values("server.example.com"), (value) => status.push(value))
|
|
||||||
preview.cancel()
|
|
||||||
request.resolve({ healthy: true })
|
|
||||||
await pending
|
|
||||||
|
|
||||||
expect(status).toEqual([undefined])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("replaceServerConnection", () => {
|
|
||||||
const original: ServerConnection.Http = { type: "http", http: { url: "https://old.example.com" } }
|
|
||||||
const next: ServerConnection.Http = { type: "http", http: { url: "https://new.example.com" } }
|
|
||||||
|
|
||||||
test("moves active selection after adding the replacement and removes the original", () => {
|
|
||||||
const calls: string[] = []
|
|
||||||
|
|
||||||
replaceServerConnection(ServerConnection.key(original), next, {
|
|
||||||
active: () => ServerConnection.key(original),
|
|
||||||
removeTabs: (key) => calls.push(`tabs:${key}`),
|
|
||||||
add: (server) => {
|
|
||||||
calls.push(`add:${ServerConnection.key(server)}`)
|
|
||||||
return server
|
|
||||||
},
|
|
||||||
setActive: (key) => calls.push(`active:${key}`),
|
|
||||||
remove: (key) => calls.push(`remove:${key}`),
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(calls).toEqual([
|
|
||||||
"tabs:https://old.example.com",
|
|
||||||
"add:https://new.example.com",
|
|
||||||
"active:https://new.example.com",
|
|
||||||
"remove:https://old.example.com",
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("keeps the original when the replacement cannot be added", () => {
|
|
||||||
const removed: ServerConnection.Key[] = []
|
|
||||||
|
|
||||||
replaceServerConnection(ServerConnection.key(original), next, {
|
|
||||||
active: () => ServerConnection.key(original),
|
|
||||||
removeTabs: () => {},
|
|
||||||
add: () => undefined,
|
|
||||||
setActive: () => {},
|
|
||||||
remove: (key) => removed.push(key),
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(removed).toEqual([])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import { normalizeServerUrl, ServerConnection } from "@/context/server"
|
|
||||||
import type { ServerHealth } from "@/utils/server-health"
|
|
||||||
|
|
||||||
export type ServerFormValues = {
|
|
||||||
url: string
|
|
||||||
name: string
|
|
||||||
username: string
|
|
||||||
password: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createServerHealthPreview(
|
|
||||||
check: (server: ServerConnection.HttpBase) => Promise<Pick<ServerHealth, "healthy">>,
|
|
||||||
) {
|
|
||||||
let generation = 0
|
|
||||||
|
|
||||||
const cancel = () => {
|
|
||||||
generation += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
const preview = async (values: ServerFormValues, setStatus: (value: boolean | undefined) => void) => {
|
|
||||||
const current = ++generation
|
|
||||||
setStatus(undefined)
|
|
||||||
const normalized = normalizeServerUrl(values.url)
|
|
||||||
if (!normalized) return
|
|
||||||
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
|
|
||||||
if (!host) return
|
|
||||||
if (!host.includes("localhost") && !host.startsWith("127.0.0.1") && !host.includes(".") && !host.includes(":"))
|
|
||||||
return
|
|
||||||
|
|
||||||
const http: ServerConnection.HttpBase = { url: normalized }
|
|
||||||
if (values.username) http.username = values.username
|
|
||||||
if (values.password) http.password = values.password
|
|
||||||
const result = await check(http)
|
|
||||||
if (current !== generation) return
|
|
||||||
setStatus(result.healthy)
|
|
||||||
}
|
|
||||||
|
|
||||||
return { cancel, preview }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function replaceServerConnection(
|
|
||||||
originalKey: ServerConnection.Key,
|
|
||||||
next: ServerConnection.Http,
|
|
||||||
operations: {
|
|
||||||
active: () => ServerConnection.Key | undefined
|
|
||||||
removeTabs: (key: ServerConnection.Key) => void
|
|
||||||
add: (server: ServerConnection.Http) => ServerConnection.Any | undefined
|
|
||||||
setActive: (key: ServerConnection.Key) => void
|
|
||||||
remove: (key: ServerConnection.Key) => void
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const active = operations.active()
|
|
||||||
operations.removeTabs(originalKey)
|
|
||||||
const added = operations.add(next)
|
|
||||||
if (!added) return
|
|
||||||
const nextActive = active === originalKey ? ServerConnection.key(added) : active
|
|
||||||
if (nextActive) operations.setActive(nextActive)
|
|
||||||
operations.remove(originalKey)
|
|
||||||
}
|
|
||||||
@@ -2,13 +2,13 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
|||||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||||
import { type Component, Show } from "solid-js"
|
import { type Component, Show } from "solid-js"
|
||||||
import type { ServerActionsController } from "@/components/server/server-management-controller"
|
import { useServerManagementController } from "@/components/dialog-select-server"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/server"
|
||||||
|
|
||||||
export const ServerRowMenu: Component<{
|
export const ServerRowMenu: Component<{
|
||||||
server: ServerConnection.Any
|
server: ServerConnection.Any
|
||||||
domain: ServerActionsController
|
controller: ReturnType<typeof useServerManagementController>
|
||||||
onEdit: (server: ServerConnection.Http) => void
|
onEdit: (server: ServerConnection.Http) => void
|
||||||
open?: boolean
|
open?: boolean
|
||||||
onOpenChange?: (open: boolean) => void
|
onOpenChange?: (open: boolean) => void
|
||||||
@@ -19,13 +19,13 @@ export const ServerRowMenu: Component<{
|
|||||||
<ServerRowMenuView
|
<ServerRowMenuView
|
||||||
server={props.server}
|
server={props.server}
|
||||||
labels={serverMenuLabels(language)}
|
labels={serverMenuLabels(language)}
|
||||||
canDefault={props.domain.defaults.available()}
|
canDefault={props.controller.canDefault()}
|
||||||
isDefault={props.domain.defaults.key() === key}
|
isDefault={props.controller.defaultKey() === key}
|
||||||
canRemove={props.domain.connection.canRemove(key)}
|
canRemove={props.controller.canRemove(key)}
|
||||||
onEdit={props.onEdit}
|
onEdit={props.onEdit}
|
||||||
onSetDefault={() => props.domain.defaults.set(key)}
|
onSetDefault={() => props.controller.setDefault(key)}
|
||||||
onRemoveDefault={() => props.domain.defaults.set(null)}
|
onRemoveDefault={() => props.controller.setDefault(null)}
|
||||||
onRemove={() => props.domain.connection.remove(key)}
|
onRemove={() => props.controller.handleRemove(key)}
|
||||||
open={props.open}
|
open={props.open}
|
||||||
onOpenChange={props.onOpenChange}
|
onOpenChange={props.onOpenChange}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,793 @@
|
|||||||
|
import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js"
|
||||||
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { Select } from "@opencode-ai/ui/select"
|
||||||
|
import { Switch } from "@opencode-ai/ui/switch"
|
||||||
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
|
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
||||||
|
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { useParams } from "@solidjs/router"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { usePermission } from "@/context/permission"
|
||||||
|
import { usePlatform, type DisplayBackend } from "@/context/platform"
|
||||||
|
import { useServerSync } from "@/context/server-sync"
|
||||||
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
|
import { useUpdaterAction } from "./updater-action"
|
||||||
|
import {
|
||||||
|
monoDefault,
|
||||||
|
monoFontFamily,
|
||||||
|
monoInput,
|
||||||
|
sansDefault,
|
||||||
|
sansFontFamily,
|
||||||
|
sansInput,
|
||||||
|
terminalDefault,
|
||||||
|
terminalFontFamily,
|
||||||
|
terminalInput,
|
||||||
|
useSettings,
|
||||||
|
} from "@/context/settings"
|
||||||
|
import { decode64 } from "@/utils/base64"
|
||||||
|
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
||||||
|
import { Link } from "./link"
|
||||||
|
import { SettingsList } from "./settings-list"
|
||||||
|
|
||||||
|
let demoSoundState = {
|
||||||
|
cleanup: undefined as (() => void) | undefined,
|
||||||
|
timeout: undefined as NodeJS.Timeout | undefined,
|
||||||
|
run: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
type ThemeOption = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ShellOption = {
|
||||||
|
path: string
|
||||||
|
name: string
|
||||||
|
acceptable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type ShellSelectOption = {
|
||||||
|
id: string
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// To prevent audio from overlapping/playing very quickly when navigating the settings menus,
|
||||||
|
// delay the playback by 100ms during quick selection changes and pause existing sounds.
|
||||||
|
const stopDemoSound = () => {
|
||||||
|
demoSoundState.run += 1
|
||||||
|
if (demoSoundState.cleanup) {
|
||||||
|
demoSoundState.cleanup()
|
||||||
|
}
|
||||||
|
clearTimeout(demoSoundState.timeout)
|
||||||
|
demoSoundState.cleanup = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const playDemoSound = (id: string | undefined) => {
|
||||||
|
stopDemoSound()
|
||||||
|
if (!id) return
|
||||||
|
|
||||||
|
const run = ++demoSoundState.run
|
||||||
|
demoSoundState.timeout = setTimeout(() => {
|
||||||
|
void playSoundById(id).then((cleanup) => {
|
||||||
|
if (demoSoundState.run !== run) {
|
||||||
|
cleanup?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
demoSoundState.cleanup = cleanup
|
||||||
|
})
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SettingsGeneral: Component = () => {
|
||||||
|
const theme = useTheme()
|
||||||
|
const language = useLanguage()
|
||||||
|
const permission = usePermission()
|
||||||
|
const platform = usePlatform()
|
||||||
|
const dialog = useDialog()
|
||||||
|
const params = useParams()
|
||||||
|
const settings = useSettings()
|
||||||
|
|
||||||
|
const updater = useUpdaterAction()
|
||||||
|
|
||||||
|
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
|
||||||
|
const dir = createMemo(() => decode64(params.dir))
|
||||||
|
const accepting = createMemo(() => {
|
||||||
|
const value = dir()
|
||||||
|
if (!value) return false
|
||||||
|
if (!params.id) return permission.isAutoAcceptingDirectory(value)
|
||||||
|
return permission.isAutoAccepting(params.id, value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleAccept = (checked: boolean) => {
|
||||||
|
const value = dir()
|
||||||
|
if (!value) return
|
||||||
|
|
||||||
|
if (!params.id) {
|
||||||
|
if (permission.isAutoAcceptingDirectory(value) === checked) return
|
||||||
|
permission.toggleAutoAcceptDirectory(value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checked) {
|
||||||
|
permission.enableAutoAccept(params.id, value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
permission.disableAutoAccept(params.id, value)
|
||||||
|
}
|
||||||
|
const desktop = createMemo(() => platform.platform === "desktop")
|
||||||
|
|
||||||
|
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
||||||
|
|
||||||
|
const serverSync = useServerSync()
|
||||||
|
const serverSdk = useServerSDK()
|
||||||
|
|
||||||
|
const [shells] = createResource(
|
||||||
|
async () => {
|
||||||
|
// TODO: Restore executable shell discovery; V2 shell.list only lists shell processes.
|
||||||
|
return [] as ShellOption[]
|
||||||
|
},
|
||||||
|
{ initialValue: [] as ShellOption[] },
|
||||||
|
)
|
||||||
|
|
||||||
|
const [displayBackend, { refetch: refetchDisplayBackend }] = createResource(
|
||||||
|
() => (linux() && platform.getDisplayBackend ? true : false),
|
||||||
|
() => Promise.resolve(platform.getDisplayBackend?.() ?? null).catch(() => null as DisplayBackend | null),
|
||||||
|
{ initialValue: null as DisplayBackend | null },
|
||||||
|
)
|
||||||
|
|
||||||
|
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
|
||||||
|
() => (desktop() && platform.getPinchZoomEnabled ? true : false),
|
||||||
|
() => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false),
|
||||||
|
{ initialValue: false },
|
||||||
|
)
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
void theme.loadThemes()
|
||||||
|
})
|
||||||
|
|
||||||
|
const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
|
||||||
|
const currentShell = createMemo(() => serverSync().data.config.shell ?? "")
|
||||||
|
|
||||||
|
const shellOptions = createMemo<ShellSelectOption[]>(() => {
|
||||||
|
const list = shells.latest
|
||||||
|
const current = serverSync().data.config.shell
|
||||||
|
|
||||||
|
const nameCounts = new Map<string, number>()
|
||||||
|
for (const s of list) {
|
||||||
|
nameCounts.set(s.name, (nameCounts.get(s.name) || 0) + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
autoOption,
|
||||||
|
...list.map((s) => {
|
||||||
|
const ambiguousName = (nameCounts.get(s.name) || 0) > 1
|
||||||
|
const text = ambiguousName ? s.path : s.name
|
||||||
|
const label = s.acceptable ? text : `${text} (${language.t("settings.general.row.shell.terminalOnly")})`
|
||||||
|
return {
|
||||||
|
id: s.path,
|
||||||
|
// Prefer name over path - "bash" is much cleaner than the explicit full route even when it may change due to PATH.
|
||||||
|
value: ambiguousName ? s.path : s.name,
|
||||||
|
label,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
if (current && !options.some((o) => o.value === current)) {
|
||||||
|
options.push({ id: current, value: current, label: current })
|
||||||
|
}
|
||||||
|
|
||||||
|
return options
|
||||||
|
})
|
||||||
|
|
||||||
|
const onDisplayBackendChange = (checked: boolean) => {
|
||||||
|
const update = platform.setDisplayBackend?.(checked ? "wayland" : "auto")
|
||||||
|
if (!update) return
|
||||||
|
void update.finally(() => {
|
||||||
|
void refetchDisplayBackend()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPinchZoomChange = (checked: boolean) => {
|
||||||
|
setPinchZoom(checked)
|
||||||
|
const update = platform.setPinchZoomEnabled?.(checked)
|
||||||
|
if (!update) return
|
||||||
|
void update.catch(() => setPinchZoom(!checked))
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
|
||||||
|
{ value: "system", label: language.t("theme.scheme.system") },
|
||||||
|
{ value: "light", label: language.t("theme.scheme.light") },
|
||||||
|
{ value: "dark", label: language.t("theme.scheme.dark") },
|
||||||
|
])
|
||||||
|
|
||||||
|
const languageOptions = createMemo(() =>
|
||||||
|
language.locales.map((locale) => ({
|
||||||
|
value: locale,
|
||||||
|
label: language.label(locale),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const noneSound = { id: "none", label: "sound.option.none" } as const
|
||||||
|
const soundOptions = [noneSound, ...SOUND_OPTIONS]
|
||||||
|
const mono = () => monoInput(settings.appearance.font())
|
||||||
|
const sans = () => sansInput(settings.appearance.uiFont())
|
||||||
|
const terminal = () => terminalInput(settings.appearance.terminalFont())
|
||||||
|
|
||||||
|
const soundSelectProps = (
|
||||||
|
enabled: () => boolean,
|
||||||
|
current: () => string,
|
||||||
|
setEnabled: (value: boolean) => void,
|
||||||
|
set: (id: string) => void,
|
||||||
|
) => ({
|
||||||
|
options: soundOptions,
|
||||||
|
current: enabled() ? (soundOptions.find((o) => o.id === current()) ?? noneSound) : noneSound,
|
||||||
|
value: (o: (typeof soundOptions)[number]) => o.id,
|
||||||
|
label: (o: (typeof soundOptions)[number]) => language.t(o.label),
|
||||||
|
onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
|
||||||
|
if (!option) return
|
||||||
|
playDemoSound(option.id === "none" ? undefined : option.id)
|
||||||
|
},
|
||||||
|
onSelect: (option: (typeof soundOptions)[number] | undefined) => {
|
||||||
|
if (!option) return
|
||||||
|
if (option.id === "none") {
|
||||||
|
setEnabled(false)
|
||||||
|
stopDemoSound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setEnabled(true)
|
||||||
|
set(option.id)
|
||||||
|
playDemoSound(option.id)
|
||||||
|
},
|
||||||
|
variant: "secondary" as const,
|
||||||
|
size: "small" as const,
|
||||||
|
triggerVariant: "settings" as const,
|
||||||
|
})
|
||||||
|
|
||||||
|
const InterfaceSection = () => (
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<SettingsList>
|
||||||
|
<SettingsRow
|
||||||
|
title={
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
{language.t("settings.general.row.newInterface.title")}
|
||||||
|
<Tag variant="accent">{language.t("settings.general.row.newInterface.badge")}</Tag>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
description={language.t("settings.general.row.newInterface.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-new-layout-designs">
|
||||||
|
<Switch
|
||||||
|
checked={settings.general.newLayoutDesigns()}
|
||||||
|
onChange={(checked) => {
|
||||||
|
settings.general.setNewLayoutDesigns(checked)
|
||||||
|
if (!checked) return
|
||||||
|
void import("@/components/settings-v2").then((module) => {
|
||||||
|
void dialog.show(() => <module.DialogSettings />)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
</SettingsList>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const InterfaceNoticeSection = () => (
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<SettingsList>
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.row.newInterfaceNotice.title")}
|
||||||
|
description={language.t("settings.general.row.newInterfaceNotice.description")}
|
||||||
|
>
|
||||||
|
<Button size="small" variant="ghost" onClick={settings.general.dismissNewInterfaceNotice}>
|
||||||
|
{language.t("settings.general.row.newInterfaceNotice.dismiss")}
|
||||||
|
</Button>
|
||||||
|
</SettingsRow>
|
||||||
|
</SettingsList>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const GeneralSection = () => (
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<SettingsList>
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.row.language.title")}
|
||||||
|
description={language.t("settings.general.row.language.description")}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
data-action="settings-language"
|
||||||
|
options={languageOptions()}
|
||||||
|
current={languageOptions().find((o) => o.value === language.locale())}
|
||||||
|
value={(o) => o.value}
|
||||||
|
label={(o) => o.label}
|
||||||
|
onSelect={(option) => option && language.setLocale(option.value)}
|
||||||
|
variant="secondary"
|
||||||
|
size="small"
|
||||||
|
triggerVariant="settings"
|
||||||
|
/>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("command.permissions.autoaccept.enable")}
|
||||||
|
description={language.t("toast.permissions.autoaccept.on.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-auto-accept-permissions">
|
||||||
|
<Switch checked={accepting()} disabled={!dir()} onChange={toggleAccept} />
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.row.shell.title")}
|
||||||
|
description={language.t("settings.general.row.shell.description")}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
data-action="settings-shell"
|
||||||
|
disabled
|
||||||
|
options={shellOptions()}
|
||||||
|
current={shellOptions().find((o) => o.value === currentShell()) ?? autoOption}
|
||||||
|
value={(o) => o.id}
|
||||||
|
label={(o) => o.label}
|
||||||
|
onSelect={(option) => {
|
||||||
|
if (!option) return
|
||||||
|
if (option.value === currentShell()) return
|
||||||
|
// TODO: Restore config writes when the V2 client exposes a config API.
|
||||||
|
// void serverSync().updateConfig({ shell: option.value })
|
||||||
|
}}
|
||||||
|
variant="secondary"
|
||||||
|
size="small"
|
||||||
|
triggerVariant="settings"
|
||||||
|
triggerStyle={{ "min-width": "180px" }}
|
||||||
|
/>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.row.reasoningSummaries.title")}
|
||||||
|
description={language.t("settings.general.row.reasoningSummaries.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-feed-reasoning-summaries">
|
||||||
|
<Switch
|
||||||
|
checked={settings.general.showReasoningSummaries()}
|
||||||
|
onChange={(checked) => settings.general.setShowReasoningSummaries(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.row.shellToolPartsExpanded.title")}
|
||||||
|
description={language.t("settings.general.row.shellToolPartsExpanded.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-feed-shell-tool-parts-expanded">
|
||||||
|
<Switch
|
||||||
|
checked={settings.general.shellToolPartsExpanded()}
|
||||||
|
onChange={(checked) => settings.general.setShellToolPartsExpanded(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.row.editToolPartsExpanded.title")}
|
||||||
|
description={language.t("settings.general.row.editToolPartsExpanded.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-feed-edit-tool-parts-expanded">
|
||||||
|
<Switch
|
||||||
|
checked={settings.general.editToolPartsExpanded()}
|
||||||
|
onChange={(checked) => settings.general.setEditToolPartsExpanded(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
</SettingsList>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const AdvancedSection = () => (
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.advanced")}</h3>
|
||||||
|
|
||||||
|
<SettingsList>
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.row.showFileTree.title")}
|
||||||
|
description={language.t("settings.general.row.showFileTree.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-show-file-tree">
|
||||||
|
<Switch
|
||||||
|
checked={settings.general.showFileTree()}
|
||||||
|
onChange={(checked) => settings.general.setShowFileTree(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.row.showNavigation.title")}
|
||||||
|
description={language.t("settings.general.row.showNavigation.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-show-navigation">
|
||||||
|
<Switch
|
||||||
|
checked={settings.general.showNavigation()}
|
||||||
|
onChange={(checked) => settings.general.setShowNavigation(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.row.showSearch.title")}
|
||||||
|
description={language.t("settings.general.row.showSearch.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-show-search">
|
||||||
|
<Switch
|
||||||
|
checked={settings.general.showSearch()}
|
||||||
|
onChange={(checked) => settings.general.setShowSearch(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.row.showStatus.title")}
|
||||||
|
description={language.t("settings.general.row.showStatus.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-show-status">
|
||||||
|
<Switch
|
||||||
|
checked={settings.general.showStatus()}
|
||||||
|
onChange={(checked) => settings.general.setShowStatus(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.row.showCustomAgents.title")}
|
||||||
|
description={language.t("settings.general.row.showCustomAgents.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-show-custom-agents">
|
||||||
|
<Switch
|
||||||
|
checked={settings.general.showCustomAgents()}
|
||||||
|
onChange={(checked) => settings.general.setShowCustomAgents(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
</SettingsList>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const AppearanceSection = () => (
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3>
|
||||||
|
|
||||||
|
<SettingsList>
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.row.colorScheme.title")}
|
||||||
|
description={language.t("settings.general.row.colorScheme.description")}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
data-action="settings-color-scheme"
|
||||||
|
options={colorSchemeOptions()}
|
||||||
|
current={colorSchemeOptions().find((o) => o.value === theme.colorScheme())}
|
||||||
|
value={(o) => o.value}
|
||||||
|
label={(o) => o.label}
|
||||||
|
onSelect={(option) => option && theme.setColorScheme(option.value)}
|
||||||
|
variant="secondary"
|
||||||
|
size="small"
|
||||||
|
triggerVariant="settings"
|
||||||
|
triggerStyle={{ "min-width": "220px" }}
|
||||||
|
/>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.row.theme.title")}
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
{language.t("settings.general.row.theme.description")}{" "}
|
||||||
|
<Link href="https://opencode.ai/docs/themes/">{language.t("common.learnMore")}</Link>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
data-action="settings-theme"
|
||||||
|
options={themeOptions()}
|
||||||
|
current={themeOptions().find((o) => o.id === theme.themeId())}
|
||||||
|
value={(o) => o.id}
|
||||||
|
label={(o) => o.name}
|
||||||
|
onSelect={(option) => {
|
||||||
|
if (!option) return
|
||||||
|
theme.setTheme(option.id)
|
||||||
|
}}
|
||||||
|
variant="secondary"
|
||||||
|
size="small"
|
||||||
|
triggerVariant="settings"
|
||||||
|
/>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.row.uiFont.title")}
|
||||||
|
description={language.t("settings.general.row.uiFont.description")}
|
||||||
|
>
|
||||||
|
<div class="w-full sm:w-[220px]">
|
||||||
|
<TextField
|
||||||
|
data-action="settings-ui-font"
|
||||||
|
label={language.t("settings.general.row.uiFont.title")}
|
||||||
|
hideLabel
|
||||||
|
type="text"
|
||||||
|
value={sans()}
|
||||||
|
onChange={(value) => settings.appearance.setUIFont(value)}
|
||||||
|
placeholder={sansDefault}
|
||||||
|
spellcheck={false}
|
||||||
|
autocorrect="off"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
class="text-12-regular"
|
||||||
|
style={{ "font-family": sansFontFamily(settings.appearance.uiFont()) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.row.font.title")}
|
||||||
|
description={language.t("settings.general.row.font.description")}
|
||||||
|
>
|
||||||
|
<div class="w-full sm:w-[220px]">
|
||||||
|
<TextField
|
||||||
|
data-action="settings-code-font"
|
||||||
|
label={language.t("settings.general.row.font.title")}
|
||||||
|
hideLabel
|
||||||
|
type="text"
|
||||||
|
value={mono()}
|
||||||
|
onChange={(value) => settings.appearance.setFont(value)}
|
||||||
|
placeholder={monoDefault}
|
||||||
|
spellcheck={false}
|
||||||
|
autocorrect="off"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
class="text-12-regular"
|
||||||
|
style={{ "font-family": monoFontFamily(settings.appearance.font()) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.row.terminalFont.title")}
|
||||||
|
description={language.t("settings.general.row.terminalFont.description")}
|
||||||
|
>
|
||||||
|
<div class="w-full sm:w-[220px]">
|
||||||
|
<TextField
|
||||||
|
data-action="settings-terminal-font"
|
||||||
|
label={language.t("settings.general.row.terminalFont.title")}
|
||||||
|
hideLabel
|
||||||
|
type="text"
|
||||||
|
value={terminal()}
|
||||||
|
onChange={(value) => settings.appearance.setTerminalFont(value)}
|
||||||
|
placeholder={terminalDefault}
|
||||||
|
spellcheck={false}
|
||||||
|
autocorrect="off"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
class="text-12-regular"
|
||||||
|
style={{ "font-family": terminalFontFamily(settings.appearance.terminalFont()) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
</SettingsList>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const NotificationsSection = () => (
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.notifications")}</h3>
|
||||||
|
|
||||||
|
<SettingsList>
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.notifications.agent.title")}
|
||||||
|
description={language.t("settings.general.notifications.agent.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-notifications-agent">
|
||||||
|
<Switch
|
||||||
|
checked={settings.notifications.agent()}
|
||||||
|
onChange={(checked) => settings.notifications.setAgent(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.notifications.permissions.title")}
|
||||||
|
description={language.t("settings.general.notifications.permissions.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-notifications-permissions">
|
||||||
|
<Switch
|
||||||
|
checked={settings.notifications.permissions()}
|
||||||
|
onChange={(checked) => settings.notifications.setPermissions(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.notifications.errors.title")}
|
||||||
|
description={language.t("settings.general.notifications.errors.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-notifications-errors">
|
||||||
|
<Switch
|
||||||
|
checked={settings.notifications.errors()}
|
||||||
|
onChange={(checked) => settings.notifications.setErrors(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
</SettingsList>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const SoundsSection = () => (
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.sounds")}</h3>
|
||||||
|
|
||||||
|
<SettingsList>
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.sounds.agent.title")}
|
||||||
|
description={language.t("settings.general.sounds.agent.description")}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
data-action="settings-sounds-agent"
|
||||||
|
{...soundSelectProps(
|
||||||
|
() => settings.sounds.agentEnabled(),
|
||||||
|
() => settings.sounds.agent(),
|
||||||
|
(value) => settings.sounds.setAgentEnabled(value),
|
||||||
|
(id) => settings.sounds.setAgent(id),
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.sounds.permissions.title")}
|
||||||
|
description={language.t("settings.general.sounds.permissions.description")}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
data-action="settings-sounds-permissions"
|
||||||
|
{...soundSelectProps(
|
||||||
|
() => settings.sounds.permissionsEnabled(),
|
||||||
|
() => settings.sounds.permissions(),
|
||||||
|
(value) => settings.sounds.setPermissionsEnabled(value),
|
||||||
|
(id) => settings.sounds.setPermissions(id),
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.sounds.errors.title")}
|
||||||
|
description={language.t("settings.general.sounds.errors.description")}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
data-action="settings-sounds-errors"
|
||||||
|
{...soundSelectProps(
|
||||||
|
() => settings.sounds.errorsEnabled(),
|
||||||
|
() => settings.sounds.errors(),
|
||||||
|
(value) => settings.sounds.setErrorsEnabled(value),
|
||||||
|
(id) => settings.sounds.setErrors(id),
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingsRow>
|
||||||
|
</SettingsList>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const UpdatesSection = () => (
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3>
|
||||||
|
|
||||||
|
<SettingsList>
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.row.releaseNotes.title")}
|
||||||
|
description={language.t("settings.general.row.releaseNotes.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-release-notes">
|
||||||
|
<Switch
|
||||||
|
checked={settings.general.releaseNotes()}
|
||||||
|
onChange={(checked) => settings.general.setReleaseNotes(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.updates.row.check.title")}
|
||||||
|
description={language.t("settings.updates.row.check.description")}
|
||||||
|
>
|
||||||
|
<Button size="small" variant="secondary" disabled={!updater.action().run} onClick={updater.run}>
|
||||||
|
{language.t(updater.action().label)}
|
||||||
|
</Button>
|
||||||
|
</SettingsRow>
|
||||||
|
</SettingsList>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const DisplaySection = () => (
|
||||||
|
<Show when={desktop()}>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3>
|
||||||
|
|
||||||
|
<SettingsList>
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.row.pinchZoom.title")}
|
||||||
|
description={language.t("settings.general.row.pinchZoom.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-pinch-zoom">
|
||||||
|
<Switch checked={pinchZoom.latest} onChange={onPinchZoomChange} />
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<Show when={linux()}>
|
||||||
|
<SettingsRow
|
||||||
|
title={
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span>{language.t("settings.general.row.wayland.title")}</span>
|
||||||
|
<Tooltip value={language.t("settings.general.row.wayland.tooltip")} placement="top">
|
||||||
|
<span class="text-text-weak">
|
||||||
|
<Icon name="help" size="small" />
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
description={language.t("settings.general.row.wayland.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-wayland">
|
||||||
|
<Switch checked={displayBackend.latest === "wayland"} onChange={onDisplayBackendChange} />
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
</Show>
|
||||||
|
</SettingsList>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||||
|
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||||
|
<div class="flex flex-col gap-1 pt-6 pb-8">
|
||||||
|
<h2 class="text-16-medium text-text-strong">{language.t("settings.tab.general")}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-8 w-full">
|
||||||
|
<Show when={settings.general.layoutTransitionAvailable()}>
|
||||||
|
<InterfaceSection />
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={settings.general.newInterfaceNoticeVisible()}>
|
||||||
|
<InterfaceNoticeSection />
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<GeneralSection />
|
||||||
|
|
||||||
|
<AppearanceSection />
|
||||||
|
|
||||||
|
<NotificationsSection />
|
||||||
|
|
||||||
|
<SoundsSection />
|
||||||
|
|
||||||
|
<UpdatesSection />
|
||||||
|
|
||||||
|
<DisplaySection />
|
||||||
|
|
||||||
|
<Show when={desktop()}>
|
||||||
|
<AdvancedSection />
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SettingsRowProps {
|
||||||
|
title: string | JSX.Element
|
||||||
|
description: string | JSX.Element
|
||||||
|
children: JSX.Element
|
||||||
|
}
|
||||||
|
|
||||||
|
const SettingsRow: Component<SettingsRowProps> = (props) => {
|
||||||
|
return (
|
||||||
|
<div class="flex flex-wrap items-center gap-4 py-3 border-b border-border-weak-base last:border-none sm:flex-nowrap">
|
||||||
|
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
<span class="text-14-medium text-text-strong">{props.title}</span>
|
||||||
|
<span class="text-12-regular text-text-weak">{props.description}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex w-full justify-end sm:w-auto sm:shrink-0">{props.children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
||||||
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
|
import { Switch } from "@opencode-ai/ui/switch"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
|
import { type Component, For, Show } from "solid-js"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useModels } from "@/context/models"
|
||||||
|
import { popularProviders } from "@/hooks/use-providers"
|
||||||
|
import { SettingsList } from "./settings-list"
|
||||||
|
import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker"
|
||||||
|
|
||||||
|
type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
|
||||||
|
|
||||||
|
const ListLoadingState: Component<{ label: string }> = (props) => {
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<span class="text-14-regular text-text-weak">{props.label}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ListEmptyState: Component<{ message: string; filter: string }> = (props) => {
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<span class="text-14-regular text-text-weak">{props.message}</span>
|
||||||
|
<Show when={props.filter}>
|
||||||
|
<span class="text-14-regular text-text-strong mt-1">"{props.filter}"</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SettingsModels: Component = () => {
|
||||||
|
return (
|
||||||
|
<SettingsServerScope>
|
||||||
|
<SettingsModelsContent />
|
||||||
|
</SettingsServerScope>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const SettingsModelsContent: Component = () => {
|
||||||
|
const language = useLanguage()
|
||||||
|
const models = useModels()
|
||||||
|
|
||||||
|
const list = useFilteredList<ModelItem>({
|
||||||
|
items: (_filter) => models.list(),
|
||||||
|
key: (x) => `${x.provider.id}:${x.id}`,
|
||||||
|
filterKeys: ["provider.name", "name", "id"],
|
||||||
|
sortBy: (a, b) => a.name.localeCompare(b.name),
|
||||||
|
groupBy: (x) => x.provider.id,
|
||||||
|
sortGroupsBy: (a, b) => {
|
||||||
|
const aIndex = popularProviders.indexOf(a.category)
|
||||||
|
const bIndex = popularProviders.indexOf(b.category)
|
||||||
|
const aPopular = aIndex >= 0
|
||||||
|
const bPopular = bIndex >= 0
|
||||||
|
|
||||||
|
if (aPopular && !bPopular) return -1
|
||||||
|
if (!aPopular && bPopular) return 1
|
||||||
|
if (aPopular && bPopular) return aIndex - bIndex
|
||||||
|
|
||||||
|
const aName = a.items[0].provider.name
|
||||||
|
const bName = b.items[0].provider.name
|
||||||
|
return aName.localeCompare(bName)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||||
|
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||||
|
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<h2 class="text-16-medium text-text-strong">{language.t("settings.models.title")}</h2>
|
||||||
|
<SettingsServerPicker />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
|
||||||
|
<Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
|
||||||
|
<TextField
|
||||||
|
variant="ghost"
|
||||||
|
type="text"
|
||||||
|
value={list.filter()}
|
||||||
|
onChange={list.onInput}
|
||||||
|
placeholder={language.t("dialog.model.search.placeholder")}
|
||||||
|
spellcheck={false}
|
||||||
|
autocorrect="off"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<Show when={list.filter()}>
|
||||||
|
<IconButton icon="circle-x" variant="ghost" onClick={list.clear} />
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-8 max-w-[720px]">
|
||||||
|
<Show
|
||||||
|
when={!list.grouped.loading}
|
||||||
|
fallback={
|
||||||
|
<ListLoadingState label={`${language.t("common.loading")}${language.t("common.loading.ellipsis")}`} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={list.flat().length > 0}
|
||||||
|
fallback={<ListEmptyState message={language.t("dialog.model.empty")} filter={list.filter()} />}
|
||||||
|
>
|
||||||
|
<For each={list.grouped.latest}>
|
||||||
|
{(group) => (
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div class="flex items-center gap-2 pb-2">
|
||||||
|
<ProviderIcon id={group.category} class="size-5 shrink-0 icon-strong-base" />
|
||||||
|
<span class="text-14-medium text-text-strong">{group.items[0].provider.name}</span>
|
||||||
|
</div>
|
||||||
|
<SettingsList>
|
||||||
|
<For each={group.items}>
|
||||||
|
{(item) => {
|
||||||
|
const key = { providerID: item.provider.id, modelID: item.id }
|
||||||
|
return (
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<span class="text-14-regular text-text-strong truncate block">{item.name}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<Switch
|
||||||
|
checked={models.visible(key)}
|
||||||
|
onChange={(checked) => {
|
||||||
|
models.setVisibility(key, checked)
|
||||||
|
}}
|
||||||
|
hideLabel
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Switch>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</SettingsList>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
|
import { Tag } from "@opencode-ai/ui/tag"
|
||||||
|
import { showToast } from "@/utils/toast"
|
||||||
|
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||||
|
import { createMemo, type Component, For, Show } from "solid-js"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
|
import { useServerSync } from "@/context/server-sync"
|
||||||
|
import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider"
|
||||||
|
import { DialogCustomProvider } from "./dialog-custom-provider"
|
||||||
|
import { SettingsList } from "./settings-list"
|
||||||
|
import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker"
|
||||||
|
|
||||||
|
type ProviderSource = "env" | "api" | "config" | "custom"
|
||||||
|
type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number]
|
||||||
|
|
||||||
|
const PROVIDER_NOTES = [
|
||||||
|
{ match: (id: string) => id === "opencode", key: "dialog.provider.opencode.note" },
|
||||||
|
{ match: (id: string) => id === "opencode-go", key: "dialog.provider.opencodeGo.tagline" },
|
||||||
|
{ match: (id: string) => id === "anthropic", key: "dialog.provider.anthropic.note" },
|
||||||
|
{ match: (id: string) => id.startsWith("github-copilot"), key: "dialog.provider.copilot.note" },
|
||||||
|
{ match: (id: string) => id === "openai", key: "dialog.provider.openai.note" },
|
||||||
|
{ match: (id: string) => id === "google", key: "dialog.provider.google.note" },
|
||||||
|
{ match: (id: string) => id === "openrouter", key: "dialog.provider.openrouter.note" },
|
||||||
|
{ match: (id: string) => id === "vercel", key: "dialog.provider.vercel.note" },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const SettingsProviders: Component<{ onBack?: () => void }> = (props) => {
|
||||||
|
return (
|
||||||
|
<SettingsServerScope>
|
||||||
|
<SettingsProvidersContent onBack={props.onBack} />
|
||||||
|
</SettingsServerScope>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) => {
|
||||||
|
const dialog = useDialog()
|
||||||
|
const language = useLanguage()
|
||||||
|
const serverSDK = useServerSDK()
|
||||||
|
const serverSync = useServerSync()
|
||||||
|
const providers = useProviders(() => undefined)
|
||||||
|
const providerConnect = useProviderConnectController({ onBack: props.onBack })
|
||||||
|
|
||||||
|
const connect = (provider?: string) => {
|
||||||
|
providerConnect.select(provider)
|
||||||
|
void dialog.show(() => <DialogConnectProvider controller={providerConnect} />)
|
||||||
|
}
|
||||||
|
|
||||||
|
const connected = createMemo(() => {
|
||||||
|
return providers
|
||||||
|
.connected()
|
||||||
|
.filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input))
|
||||||
|
})
|
||||||
|
|
||||||
|
const popular = createMemo(() => {
|
||||||
|
const connectedIDs = new Set(connected().map((p) => p.id))
|
||||||
|
const items = providers
|
||||||
|
.popular()
|
||||||
|
.filter((p) => !connectedIDs.has(p.id))
|
||||||
|
.slice()
|
||||||
|
items.sort((a, b) => popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id))
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
|
||||||
|
const source = (item: ProviderItem): ProviderSource | undefined => {
|
||||||
|
if (!("source" in item)) return
|
||||||
|
const value = item.source
|
||||||
|
if (value === "env" || value === "api" || value === "config" || value === "custom") return value
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = (item: ProviderItem) => {
|
||||||
|
const current = source(item)
|
||||||
|
if (current === "env") return language.t("settings.providers.tag.environment")
|
||||||
|
if (current === "api") return language.t("provider.connect.method.apiKey")
|
||||||
|
if (current === "config") {
|
||||||
|
if (isConfigCustom(item.id)) return language.t("settings.providers.tag.custom")
|
||||||
|
return language.t("settings.providers.tag.config")
|
||||||
|
}
|
||||||
|
if (current === "custom") return language.t("settings.providers.tag.custom")
|
||||||
|
return language.t("settings.providers.tag.other")
|
||||||
|
}
|
||||||
|
|
||||||
|
const canDisconnect = (item: ProviderItem) => source(item) !== "env" && !isConfigCustom(item.id)
|
||||||
|
|
||||||
|
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
||||||
|
|
||||||
|
const isConfigCustom = (providerID: string) => {
|
||||||
|
const provider = serverSync().data.config.provider?.[providerID]
|
||||||
|
if (!provider) return false
|
||||||
|
if (provider.npm !== "@ai-sdk/openai-compatible") return false
|
||||||
|
if (!provider.models || Object.keys(provider.models).length === 0) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const disableProvider = async (providerID: string, name: string) => {
|
||||||
|
return
|
||||||
|
const before = serverSync().data.config.disabled_providers ?? []
|
||||||
|
const next = before.includes(providerID) ? before : [...before, providerID]
|
||||||
|
serverSync().set("config", "disabled_providers", next)
|
||||||
|
|
||||||
|
await serverSync()
|
||||||
|
.updateConfig({ disabled_providers: next })
|
||||||
|
.then(() => {
|
||||||
|
showToast({
|
||||||
|
variant: "success",
|
||||||
|
icon: "circle-check",
|
||||||
|
title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }),
|
||||||
|
description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
serverSync().set("config", "disabled_providers", before)
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const disconnect = async (providerID: string, name: string) => {
|
||||||
|
await serverSDK()
|
||||||
|
.api.integration.get({ integrationID: providerID })
|
||||||
|
.then(async (integration) => {
|
||||||
|
const credentials = integration.data?.connections.filter((item) => item.type === "credential") ?? []
|
||||||
|
if (credentials.length === 0) throw new Error(`No removable credentials found for ${name}`)
|
||||||
|
await Promise.all(
|
||||||
|
credentials.map((credential) => serverSDK().api.credential.remove({ credentialID: credential.id })),
|
||||||
|
)
|
||||||
|
showToast({
|
||||||
|
variant: "success",
|
||||||
|
icon: "circle-check",
|
||||||
|
title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }),
|
||||||
|
description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||||
|
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||||
|
<div class="flex items-center justify-between gap-4 pt-6 pb-8 max-w-[720px]">
|
||||||
|
<h2 class="text-16-medium text-text-strong">{language.t("settings.providers.title")}</h2>
|
||||||
|
<SettingsServerPicker />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-8 max-w-[720px]">
|
||||||
|
<div class="flex flex-col gap-1" data-component="connected-providers-section">
|
||||||
|
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.providers.section.connected")}</h3>
|
||||||
|
<SettingsList>
|
||||||
|
<Show
|
||||||
|
when={connected().length > 0}
|
||||||
|
fallback={
|
||||||
|
<div class="py-4 text-14-regular text-text-weak">
|
||||||
|
{language.t("settings.providers.connected.empty")}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<For each={connected()}>
|
||||||
|
{(item) => (
|
||||||
|
<div class="group flex flex-wrap items-center justify-between gap-4 min-h-16 py-3 border-b border-border-weak-base last:border-none">
|
||||||
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
|
<ProviderIcon id={item.id} class="size-5 shrink-0 icon-strong-base" />
|
||||||
|
<span class="text-14-medium text-text-strong truncate">{item.name}</span>
|
||||||
|
<Tag>{type(item)}</Tag>
|
||||||
|
</div>
|
||||||
|
<Show
|
||||||
|
when={canDisconnect(item)}
|
||||||
|
fallback={
|
||||||
|
<span class="text-14-regular text-text-base opacity-0 group-hover:opacity-100 transition-opacity duration-200 pr-3 cursor-default">
|
||||||
|
{language.t("settings.providers.connected.environmentDescription")}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button size="large" variant="ghost" onClick={() => void disconnect(item.id, item.name)}>
|
||||||
|
{language.t("common.disconnect")}
|
||||||
|
</Button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
</SettingsList>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.providers.section.popular")}</h3>
|
||||||
|
<SettingsList>
|
||||||
|
<For each={popular()}>
|
||||||
|
{(item) => (
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-4 min-h-16 py-3 border-b border-border-weak-base last:border-none">
|
||||||
|
<div class="flex flex-col min-w-0">
|
||||||
|
<div class="flex items-center gap-x-3">
|
||||||
|
<ProviderIcon id={item.id} class="size-5 shrink-0 icon-strong-base" />
|
||||||
|
<span class="text-14-medium text-text-strong">{item.name}</span>
|
||||||
|
<Show when={item.id === "opencode"}>
|
||||||
|
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
||||||
|
</Show>
|
||||||
|
<Show when={item.id === "opencode-go"}>
|
||||||
|
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<Show when={note(item.id)}>
|
||||||
|
{(key) => <span class="text-12-regular text-text-weak pl-8">{language.t(key())}</span>}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<Button size="large" variant="secondary" icon="plus-small" onClick={() => connect(item.id)}>
|
||||||
|
{language.t("common.connect")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
|
||||||
|
<Show when={false}>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between gap-4 min-h-16 border-b border-border-weak-base last:border-none flex-wrap py-3"
|
||||||
|
data-component="custom-provider-section"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col min-w-0">
|
||||||
|
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||||
|
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
|
||||||
|
<span class="text-14-medium text-text-strong">{language.t("provider.custom.title")}</span>
|
||||||
|
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
|
||||||
|
</div>
|
||||||
|
<span class="text-12-regular text-text-weak pl-8">
|
||||||
|
{language.t("settings.providers.custom.description")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="large"
|
||||||
|
variant="secondary"
|
||||||
|
icon="plus-small"
|
||||||
|
onClick={() => {
|
||||||
|
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{language.t("common.connect")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</SettingsList>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
class="px-0 py-0 mt-5 text-14-medium text-text-interactive-base text-left justify-start hover:bg-transparent active:bg-transparent"
|
||||||
|
onClick={() => connect()}
|
||||||
|
>
|
||||||
|
{language.t("dialog.provider.viewAll")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { QueryClientProvider } from "@tanstack/solid-query"
|
||||||
|
import { createMemo, For, type ParentProps, Show } from "solid-js"
|
||||||
|
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
||||||
|
import { ModelsProvider } from "@/context/models"
|
||||||
|
import { ServerConnection } from "@/context/server"
|
||||||
|
import { ServerSDKProvider } from "@/context/server-sdk"
|
||||||
|
import { ServerSyncProvider } from "@/context/server-sync"
|
||||||
|
import { useGlobal } from "@/context/global"
|
||||||
|
import { useSettings } from "@/context/settings"
|
||||||
|
|
||||||
|
export function SettingsServerScope(props: ParentProps) {
|
||||||
|
const global = useGlobal()
|
||||||
|
const settings = useSettings()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={settings.general.newLayoutDesigns()} fallback={props.children}>
|
||||||
|
<Show when={global.settings.server.selected()}>
|
||||||
|
{(server) => <SettingsServerDataProviders server={server()}>{props.children}</SettingsServerDataProviders>}
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsServerDataProviders(props: ParentProps<{ server: ServerConnection.Any }>) {
|
||||||
|
const global = useGlobal()
|
||||||
|
const serverCtx = () => global.ensureServerCtx(props.server)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={serverCtx().queryClient}>
|
||||||
|
<ServerSDKProvider server={() => props.server}>
|
||||||
|
<ServerSyncProvider>
|
||||||
|
<ModelsProvider>{props.children}</ModelsProvider>
|
||||||
|
</ServerSyncProvider>
|
||||||
|
</ServerSDKProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsServerPicker() {
|
||||||
|
const global = useGlobal()
|
||||||
|
const settings = useSettings()
|
||||||
|
const selected = createMemo(() =>
|
||||||
|
settings.general.newLayoutDesigns() ? global.settings.server.selected() : undefined,
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={selected()}>
|
||||||
|
{(conn) => (
|
||||||
|
<DropdownMenu gutter={4} placement="bottom-end">
|
||||||
|
<DropdownMenu.Trigger
|
||||||
|
as={Button}
|
||||||
|
variant="secondary"
|
||||||
|
size="large"
|
||||||
|
class="h-8 max-w-[260px] gap-2 px-2 py-1.5 data-[expanded]:bg-surface-base-active"
|
||||||
|
>
|
||||||
|
<ServerHealthIndicator health={global.servers.health[ServerConnection.key(conn())]} />
|
||||||
|
<ServerRow
|
||||||
|
conn={conn()}
|
||||||
|
status={global.servers.health[ServerConnection.key(conn())]}
|
||||||
|
class="flex items-center gap-2 min-w-0 flex-1"
|
||||||
|
nameClass="text-14-regular text-text-base truncate"
|
||||||
|
versionClass="hidden"
|
||||||
|
/>
|
||||||
|
<Icon name="chevron-down" size="small" class="text-icon-weak shrink-0" />
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Portal>
|
||||||
|
<DropdownMenu.Content class="w-[320px] mt-1 [&_[data-slot=dropdown-menu-radio-item]]:pl-2 [&_[data-slot=dropdown-menu-radio-item]]:pr-2">
|
||||||
|
<DropdownMenu.RadioGroup
|
||||||
|
value={global.settings.server.key}
|
||||||
|
onChange={(key) => {
|
||||||
|
if (typeof key === "string") global.settings.server.set(ServerConnection.Key.make(key))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<For each={global.servers.list()}>
|
||||||
|
{(item) => {
|
||||||
|
const key = ServerConnection.key(item)
|
||||||
|
const blocked = () => global.servers.health[key]?.healthy === false
|
||||||
|
return (
|
||||||
|
<DropdownMenu.RadioItem value={key} disabled={blocked()}>
|
||||||
|
<ServerHealthIndicator health={global.servers.health[key]} />
|
||||||
|
<ServerRow
|
||||||
|
conn={item}
|
||||||
|
dimmed={blocked()}
|
||||||
|
status={global.servers.health[key]}
|
||||||
|
class="flex items-center gap-2 min-w-0 flex-1"
|
||||||
|
nameClass="text-14-regular text-text-base truncate"
|
||||||
|
versionClass="text-12-regular text-text-weak truncate"
|
||||||
|
/>
|
||||||
|
<DropdownMenu.ItemIndicator>
|
||||||
|
<Icon name="check-small" size="small" class="text-icon-weak" />
|
||||||
|
</DropdownMenu.ItemIndicator>
|
||||||
|
</DropdownMenu.RadioItem>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</DropdownMenu.RadioGroup>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Portal>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Show, type Component } from "solid-js"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { ServerConnectionForm, ServerConnectionList, useServerManagementController } from "./dialog-select-server"
|
||||||
|
|
||||||
|
export const SettingsServers: Component = () => {
|
||||||
|
const language = useLanguage()
|
||||||
|
const controller = useServerManagementController()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||||
|
<div class="flex flex-col flex-1 min-h-0 max-w-[720px]">
|
||||||
|
<Show
|
||||||
|
when={controller.isFormMode()}
|
||||||
|
fallback={
|
||||||
|
<>
|
||||||
|
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||||
|
<div class="flex flex-col gap-1 pt-6 pb-8">
|
||||||
|
<h2 class="text-16-medium text-text-strong">{language.t("status.popover.tab.servers")}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ServerConnectionList controller={controller} />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="flex flex-1 min-h-0 flex-col gap-4 pt-6">
|
||||||
|
<div class="text-16-medium text-text-strong">{controller.formTitle()}</div>
|
||||||
|
<ServerConnectionForm controller={controller} />
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|||||||
import { type Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"
|
import { type Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { type ServerConnection } from "@/context/server"
|
import { type ServerConnection } from "@/context/server"
|
||||||
import { useServerFormController } from "../server/server-management-controller"
|
import { useServerManagementController } from "../dialog-select-server"
|
||||||
import "./settings-v2.css"
|
import "./settings-v2.css"
|
||||||
|
|
||||||
export const DialogServerV2: Component<{
|
export const DialogServerV2: Component<{
|
||||||
@@ -15,39 +15,39 @@ export const DialogServerV2: Component<{
|
|||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const form = useServerFormController({
|
const controller = useServerManagementController({
|
||||||
onSelect: () => dialog.close(),
|
onSelect: () => dialog.close(),
|
||||||
navigateOnAdd: false,
|
navigateOnAdd: false,
|
||||||
})
|
})
|
||||||
const [opened, setOpened] = createSignal(false)
|
const [opened, setOpened] = createSignal(false)
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (props.mode === "add") form.start.add()
|
if (props.mode === "add") controller.startAdd()
|
||||||
if (props.mode === "edit" && props.server) form.start.edit(props.server)
|
if (props.mode === "edit" && props.server) controller.startEdit(props.server)
|
||||||
setOpened(true)
|
setOpened(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
form.reset()
|
controller.resetForm()
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!opened()) return
|
if (!opened()) return
|
||||||
if (form.state.open()) return
|
if (controller.isFormMode()) return
|
||||||
dialog.close()
|
dialog.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
const keyDown = (event: KeyboardEvent) => {
|
const keyDown = (event: KeyboardEvent) => {
|
||||||
if (event.key !== "Enter" || event.isComposing) return
|
if (event.key !== "Enter" || event.isComposing) return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
form.submit()
|
controller.submitForm()
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = () =>
|
const title = () =>
|
||||||
props.mode === "add" ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")
|
props.mode === "add" ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")
|
||||||
|
|
||||||
const submitLabel = () => {
|
const submitLabel = () => {
|
||||||
if (form.state.busy()) return language.t("dialog.server.add.checking")
|
if (controller.formBusy()) return language.t("dialog.server.add.checking")
|
||||||
if (props.mode === "add") return language.t("dialog.server.add.button")
|
if (props.mode === "add") return language.t("dialog.server.add.button")
|
||||||
return language.t("common.save")
|
return language.t("common.save")
|
||||||
}
|
}
|
||||||
@@ -66,16 +66,16 @@ export const DialogServerV2: Component<{
|
|||||||
type="text"
|
type="text"
|
||||||
appearance="large"
|
appearance="large"
|
||||||
class="!w-full self-stretch"
|
class="!w-full self-stretch"
|
||||||
value={form.state.value()}
|
value={controller.formValue()}
|
||||||
placeholder={language.t("dialog.server.add.placeholder")}
|
placeholder={language.t("dialog.server.add.placeholder")}
|
||||||
invalid={!!form.state.error()}
|
invalid={!!controller.formError()}
|
||||||
disabled={form.state.busy()}
|
disabled={controller.formBusy()}
|
||||||
autofocus
|
autofocus
|
||||||
onInput={(event) => form.change.value(event.currentTarget.value)}
|
onInput={(event) => controller.handleFormChange()(event.currentTarget.value)}
|
||||||
onKeyDown={keyDown}
|
onKeyDown={keyDown}
|
||||||
/>
|
/>
|
||||||
<Show when={form.state.error()}>
|
<Show when={controller.formError()}>
|
||||||
<span class="settings-v2-server-dialog-error">{form.state.error()}</span>
|
<span class="settings-v2-server-dialog-error">{controller.formError()}</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||||
@@ -84,10 +84,10 @@ export const DialogServerV2: Component<{
|
|||||||
type="text"
|
type="text"
|
||||||
appearance="large"
|
appearance="large"
|
||||||
class="!w-full self-stretch"
|
class="!w-full self-stretch"
|
||||||
value={form.state.name()}
|
value={controller.formName()}
|
||||||
placeholder={language.t("dialog.server.add.namePlaceholder")}
|
placeholder={language.t("dialog.server.add.namePlaceholder")}
|
||||||
disabled={form.state.busy()}
|
disabled={controller.formBusy()}
|
||||||
onInput={(event) => form.change.name(event.currentTarget.value)}
|
onInput={(event) => controller.handleFormNameChange()(event.currentTarget.value)}
|
||||||
onKeyDown={keyDown}
|
onKeyDown={keyDown}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,10 +98,10 @@ export const DialogServerV2: Component<{
|
|||||||
type="text"
|
type="text"
|
||||||
appearance="large"
|
appearance="large"
|
||||||
class="!w-full self-stretch"
|
class="!w-full self-stretch"
|
||||||
value={form.state.username()}
|
value={controller.formUsername()}
|
||||||
placeholder={language.t("dialog.server.add.usernamePlaceholder")}
|
placeholder={language.t("dialog.server.add.usernamePlaceholder")}
|
||||||
disabled={form.state.busy()}
|
disabled={controller.formBusy()}
|
||||||
onInput={(event) => form.change.username(event.currentTarget.value)}
|
onInput={(event) => controller.handleFormUsernameChange()(event.currentTarget.value)}
|
||||||
onKeyDown={keyDown}
|
onKeyDown={keyDown}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -111,10 +111,10 @@ export const DialogServerV2: Component<{
|
|||||||
type="password"
|
type="password"
|
||||||
appearance="large"
|
appearance="large"
|
||||||
class="!w-full self-stretch"
|
class="!w-full self-stretch"
|
||||||
value={form.state.password()}
|
value={controller.formPassword()}
|
||||||
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
|
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
|
||||||
disabled={form.state.busy()}
|
disabled={controller.formBusy()}
|
||||||
onInput={(event) => form.change.password(event.currentTarget.value)}
|
onInput={(event) => controller.handleFormPasswordChange()(event.currentTarget.value)}
|
||||||
onKeyDown={keyDown}
|
onKeyDown={keyDown}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -122,10 +122,10 @@ export const DialogServerV2: Component<{
|
|||||||
</div>
|
</div>
|
||||||
</DialogBody>
|
</DialogBody>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<ButtonV2 variant="neutral" disabled={form.state.busy()} onClick={() => dialog.close()}>
|
<ButtonV2 variant="neutral" disabled={controller.formBusy()} onClick={() => dialog.close()}>
|
||||||
{language.t("common.cancel")}
|
{language.t("common.cancel")}
|
||||||
</ButtonV2>
|
</ButtonV2>
|
||||||
<ButtonV2 variant="contrast" disabled={form.state.busy()} onClick={form.submit}>
|
<ButtonV2 variant="contrast" disabled={controller.formBusy()} onClick={controller.submitForm}>
|
||||||
{submitLabel()}
|
{submitLabel()}
|
||||||
</ButtonV2>
|
</ButtonV2>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
|||||||
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
@@ -26,6 +27,7 @@ import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
|||||||
import { Link } from "../link"
|
import { Link } from "../link"
|
||||||
import { SettingsListV2 } from "./parts/list"
|
import { SettingsListV2 } from "./parts/list"
|
||||||
import { SettingsRowV2 } from "./parts/row"
|
import { SettingsRowV2 } from "./parts/row"
|
||||||
|
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
|
||||||
import "./settings-v2.css"
|
import "./settings-v2.css"
|
||||||
|
|
||||||
let demoSoundState = {
|
let demoSoundState = {
|
||||||
@@ -85,6 +87,7 @@ export const SettingsGeneralV2: Component<{
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const permission = usePermission()
|
const permission = usePermission()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
|
const dialog = useDialog()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const mobile = createMediaQuery("(max-width: 767px)")
|
const mobile = createMediaQuery("(max-width: 767px)")
|
||||||
@@ -222,6 +225,31 @@ export const SettingsGeneralV2: Component<{
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const InterfaceSection = () => (
|
||||||
|
<LayoutTransitionToggle
|
||||||
|
title={language.t("settings.general.row.newInterface.title")}
|
||||||
|
badge={language.t("settings.general.row.newInterface.badge")}
|
||||||
|
description={language.t("settings.general.row.newInterface.description")}
|
||||||
|
checked={settings.general.newLayoutDesigns()}
|
||||||
|
onChange={(checked) => {
|
||||||
|
settings.general.setNewLayoutDesigns(checked)
|
||||||
|
if (checked) return
|
||||||
|
void import("@/components/dialog-settings").then((module) => {
|
||||||
|
void dialog.show(() => <module.DialogSettings />)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
const InterfaceNoticeSection = () => (
|
||||||
|
<LayoutRetirementNotice
|
||||||
|
title={language.t("settings.general.row.newInterfaceNotice.title")}
|
||||||
|
description={language.t("settings.general.row.newInterfaceNotice.description")}
|
||||||
|
dismiss={language.t("settings.general.row.newInterfaceNotice.dismiss")}
|
||||||
|
onDismiss={settings.general.dismissNewInterfaceNotice}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
const GeneralSection = () => (
|
const GeneralSection = () => (
|
||||||
<div class="settings-v2-section">
|
<div class="settings-v2-section">
|
||||||
<SettingsListV2>
|
<SettingsListV2>
|
||||||
@@ -661,6 +689,14 @@ export const SettingsGeneralV2: Component<{
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-v2-tab-body">
|
<div class="settings-v2-tab-body">
|
||||||
|
<Show when={settings.general.layoutTransitionAvailable()}>
|
||||||
|
<InterfaceSection />
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={settings.general.newInterfaceNoticeVisible()}>
|
||||||
|
<InterfaceNoticeSection />
|
||||||
|
</Show>
|
||||||
|
|
||||||
<GeneralSection />
|
<GeneralSection />
|
||||||
|
|
||||||
<AppearanceSection />
|
<AppearanceSection />
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { Show } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
|
||||||
|
|
||||||
|
const copy = {
|
||||||
|
title: "New layout",
|
||||||
|
badge: "New",
|
||||||
|
description: "Use the new tabs and home layout. Switch between layouts for a limited time.",
|
||||||
|
noticeTitle: "You're now using new layout",
|
||||||
|
noticeDescription: "The previous layout is no longer available",
|
||||||
|
dismiss: "Dismiss",
|
||||||
|
}
|
||||||
|
|
||||||
|
function Frame(props) {
|
||||||
|
return <div class="w-[640px] max-w-full">{props.children}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToggleExample(props) {
|
||||||
|
const [state, setState] = createStore({ checked: props.checked })
|
||||||
|
return (
|
||||||
|
<Frame>
|
||||||
|
<LayoutTransitionToggle
|
||||||
|
title={copy.title}
|
||||||
|
badge={copy.badge}
|
||||||
|
description={copy.description}
|
||||||
|
checked={state.checked}
|
||||||
|
onChange={(checked) => setState("checked", checked)}
|
||||||
|
/>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NoticeExample() {
|
||||||
|
const [state, setState] = createStore({ dismissed: false })
|
||||||
|
return (
|
||||||
|
<Frame>
|
||||||
|
<Show when={!state.dismissed} fallback={<span class="text-v2-text-text-muted">Notice dismissed</span>}>
|
||||||
|
<LayoutRetirementNotice
|
||||||
|
title={copy.noticeTitle}
|
||||||
|
description={copy.noticeDescription}
|
||||||
|
dismiss={copy.dismiss}
|
||||||
|
onDismiss={() => setState("dismissed", true)}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: "App/Settings/Layout transition",
|
||||||
|
id: "app-settings-layout-transition",
|
||||||
|
component: LayoutTransitionToggle,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NewLayoutEnabled = {
|
||||||
|
render: () => <ToggleExample checked />,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PreviousLayoutEnabled = {
|
||||||
|
render: () => <ToggleExample checked={false} />,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PreviousLayoutRetired = {
|
||||||
|
render: () => <NoticeExample />,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AllStates = {
|
||||||
|
render: () => (
|
||||||
|
<div class="flex flex-col gap-8">
|
||||||
|
<ToggleExample checked />
|
||||||
|
<ToggleExample checked={false} />
|
||||||
|
<NoticeExample />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
||||||
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||||
|
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||||
|
import { SettingsListV2 } from "./parts/list"
|
||||||
|
import { SettingsRowV2 } from "./parts/row"
|
||||||
|
|
||||||
|
export function LayoutTransitionToggle(props: {
|
||||||
|
title: string
|
||||||
|
badge: string
|
||||||
|
description: string
|
||||||
|
checked: boolean
|
||||||
|
onChange: (checked: boolean) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div class="settings-v2-section">
|
||||||
|
<div class="settings-v2-interface-feature">
|
||||||
|
<SettingsListV2>
|
||||||
|
<SettingsRowV2
|
||||||
|
title={
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
{props.title}
|
||||||
|
<Tag variant="accent">{props.badge}</Tag>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
description={props.description}
|
||||||
|
>
|
||||||
|
<div data-action="settings-new-layout-designs">
|
||||||
|
<Switch checked={props.checked} onChange={props.onChange} />
|
||||||
|
</div>
|
||||||
|
</SettingsRowV2>
|
||||||
|
</SettingsListV2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LayoutRetirementNotice(props: {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
dismiss: string
|
||||||
|
onDismiss: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div class="settings-v2-section">
|
||||||
|
<SettingsListV2>
|
||||||
|
<SettingsRowV2 title={props.title} description={props.description}>
|
||||||
|
<ButtonV2 size="small" variant="ghost-muted" onClick={props.onDismiss}>
|
||||||
|
{props.dismiss}
|
||||||
|
</ButtonV2>
|
||||||
|
</SettingsRowV2>
|
||||||
|
</SettingsListV2>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import { ServerRowMenu } from "@/components/server/server-row-menu"
|
|||||||
import { ServerHealthIndicator } from "@/components/server/server-row"
|
import { ServerHealthIndicator } from "@/components/server/server-row"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { ServerConnection, serverName } from "@/context/server"
|
import { ServerConnection, serverName } from "@/context/server"
|
||||||
import { useServerCollectionController } from "../server/server-management-controller"
|
import { useServerManagementController } from "../dialog-select-server"
|
||||||
import { DialogServerV2 } from "./dialog-server-v2"
|
import { DialogServerV2 } from "./dialog-server-v2"
|
||||||
import { SettingsListV2 } from "./parts/list"
|
import { SettingsListV2 } from "./parts/list"
|
||||||
import { AddServerMenu, isWslServer, useFilteredWslServers, WslServerSettings } from "@/wsl/settings"
|
import { AddServerMenu, isWslServer, useFilteredWslServers, WslServerSettings } from "@/wsl/settings"
|
||||||
@@ -19,16 +19,16 @@ import "./settings-v2.css"
|
|||||||
export const SettingsServersV2: Component = () => {
|
export const SettingsServersV2: Component = () => {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const domain = useServerCollectionController()
|
const controller = useServerManagementController()
|
||||||
const [store, setStore] = createStore({ filter: "" })
|
const [store, setStore] = createStore({ filter: "" })
|
||||||
const wslServers = useFilteredWslServers(() => store.filter)
|
const wslServers = useFilteredWslServers(() => store.filter)
|
||||||
|
|
||||||
const showSearch = createMemo(
|
const showSearch = createMemo(
|
||||||
() => domain.collection.items().filter((item) => !isWslServer(item)).length + wslServers().length > 1,
|
() => controller.sortedItems().filter((item) => !isWslServer(item)).length + wslServers().length > 1,
|
||||||
)
|
)
|
||||||
|
|
||||||
const filtered = createMemo(() => {
|
const filtered = createMemo(() => {
|
||||||
const items = domain.collection.items().filter((item) => !isWslServer(item))
|
const items = controller.sortedItems().filter((item) => !isWslServer(item))
|
||||||
const query = store.filter.trim()
|
const query = store.filter.trim()
|
||||||
if (!query) return items
|
if (!query) return items
|
||||||
return fuzzysort
|
return fuzzysort
|
||||||
@@ -39,11 +39,11 @@ export const SettingsServersV2: Component = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const openAdd = () => {
|
const openAdd = () => {
|
||||||
void dialog.push(() => <DialogServerV2 mode="add" />)
|
dialog.push(() => <DialogServerV2 mode="add" />)
|
||||||
}
|
}
|
||||||
|
|
||||||
const openEdit = (server: ServerConnection.Http) => {
|
const openEdit = (server: ServerConnection.Http) => {
|
||||||
void dialog.push(() => <DialogServerV2 mode="edit" server={server} />)
|
dialog.push(() => <DialogServerV2 mode="edit" server={server} />)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -97,12 +97,12 @@ export const SettingsServersV2: Component = () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SettingsListV2>
|
<SettingsListV2>
|
||||||
<WslServerSettings domain={domain} servers={wslServers} />
|
<WslServerSettings controller={controller} servers={wslServers} />
|
||||||
<For each={filtered()}>
|
<For each={filtered()}>
|
||||||
{(item) => {
|
{(item) => {
|
||||||
const key = ServerConnection.key(item)
|
const key = ServerConnection.key(item)
|
||||||
const health = () => domain.collection.health()[key]
|
const health = () => controller.status()[key]
|
||||||
const isDefault = () => domain.defaults.key() === key
|
const isDefault = () => controller.defaultKey() === key
|
||||||
return (
|
return (
|
||||||
<div class="settings-v2-servers-row">
|
<div class="settings-v2-servers-row">
|
||||||
<div class="settings-v2-servers-lead">
|
<div class="settings-v2-servers-lead">
|
||||||
@@ -122,10 +122,10 @@ export const SettingsServersV2: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-v2-servers-actions">
|
<div class="settings-v2-servers-actions">
|
||||||
<Show when={domain.defaults.available() && isDefault()}>
|
<Show when={controller.canDefault() && isDefault()}>
|
||||||
<Tag>{language.t("dialog.server.status.default")}</Tag>
|
<Tag>{language.t("dialog.server.status.default")}</Tag>
|
||||||
</Show>
|
</Show>
|
||||||
<ServerRowMenu server={item} domain={domain} onEdit={openEdit} />
|
<ServerRowMenu server={item} controller={controller} onEdit={openEdit} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -426,7 +426,8 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
|||||||
"bg-icon-success-base": status() === "connected",
|
"bg-icon-success-base": status() === "connected",
|
||||||
"bg-icon-critical-base": status() === "failed",
|
"bg-icon-critical-base": status() === "failed",
|
||||||
"bg-border-weak-base": status() === "disabled",
|
"bg-border-weak-base": status() === "disabled",
|
||||||
"bg-icon-warning-base": status() === "needs_auth",
|
"bg-icon-warning-base":
|
||||||
|
status() === "needs_auth" || status() === "needs_client_registration",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span class="flex flex-col min-w-0 flex-1">
|
<span class="flex flex-col min-w-0 flex-1">
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ describe("hasNonBlockingServiceIssue", () => {
|
|||||||
test("detects MCP failures that do not block chatting", () => {
|
test("detects MCP failures that do not block chatting", () => {
|
||||||
expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true)
|
expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true)
|
||||||
expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true)
|
expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true)
|
||||||
|
expect(hasNonBlockingServiceIssue({ mcp: ["needs_client_registration"], lsp: [] })).toBe(true)
|
||||||
expect(hasNonBlockingServiceIssue({ mcp: ["connected", "pending", "disabled"], lsp: [] })).toBe(false)
|
expect(hasNonBlockingServiceIssue({ mcp: ["connected", "pending", "disabled"], lsp: [] })).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -47,6 +48,7 @@ describe("hasNonBlockingServiceIssue", () => {
|
|||||||
describe("hasServiceNeedingAttention", () => {
|
describe("hasServiceNeedingAttention", () => {
|
||||||
test("detects MCP states that need user attention", () => {
|
test("detects MCP states that need user attention", () => {
|
||||||
expect(hasServiceNeedingAttention({ mcp: ["needs_auth"] })).toBe(true)
|
expect(hasServiceNeedingAttention({ mcp: ["needs_auth"] })).toBe(true)
|
||||||
|
expect(hasServiceNeedingAttention({ mcp: ["needs_client_registration"] })).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("ignores states that do not need user attention", () => {
|
test("ignores states that do not need user attention", () => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { LspStatus } from "@/types"
|
|||||||
import type { McpServer } from "@opencode-ai/client/promise"
|
import type { McpServer } from "@opencode-ai/client/promise"
|
||||||
|
|
||||||
export function hasServiceNeedingAttention(input: { mcp: Array<McpServer["status"]["status"]> }) {
|
export function hasServiceNeedingAttention(input: { mcp: Array<McpServer["status"]["status"]> }) {
|
||||||
return input.mcp.some((status) => status === "needs_auth")
|
return input.mcp.some((status) => status === "needs_auth" || status === "needs_client_registration")
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasNonBlockingServiceIssue(input: {
|
export function hasNonBlockingServiceIssue(input: {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export async function toggleMcp(input: {
|
|||||||
needs_auth: input.authenticate,
|
needs_auth: input.authenticate,
|
||||||
disabled: input.connect,
|
disabled: input.connect,
|
||||||
failed: input.connect,
|
failed: input.connect,
|
||||||
|
needs_client_registration: input.connect,
|
||||||
}[input.status]()
|
}[input.status]()
|
||||||
await input.refresh()
|
await input.refresh()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import {
|
||||||
|
hasExistingWebState,
|
||||||
|
isAppUpgrade,
|
||||||
|
layoutTransitionState,
|
||||||
|
maximumSunsetTimeout,
|
||||||
|
newLayoutDesignsDefault,
|
||||||
|
nextSunsetCheckDelay,
|
||||||
|
resolveNewLayoutDesigns,
|
||||||
|
shouldDisplayTabsToast,
|
||||||
|
shouldEnableNewLayout,
|
||||||
|
} from "./settings"
|
||||||
|
|
||||||
|
describe("layout transition", () => {
|
||||||
|
test("blank profiles default to the new layout", () => {
|
||||||
|
expect(newLayoutDesignsDefault).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("hides the transition until a sunset is scheduled", () => {
|
||||||
|
expect(layoutTransitionState(false, true, false, false)).toEqual({ available: false, notice: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("existing profiles can switch before sunset", () => {
|
||||||
|
expect(layoutTransitionState(true, true, false, false)).toEqual({ available: true, notice: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("classifies web profiles from existing settings or a recorded version", () => {
|
||||||
|
expect(hasExistingWebState("{}", undefined)).toBe(true)
|
||||||
|
expect(hasExistingWebState(null, "1.17.19")).toBe(true)
|
||||||
|
expect(hasExistingWebState(null, undefined)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves explicit and default layout preferences", () => {
|
||||||
|
expect(resolveNewLayoutDesigns(false, false, true)).toBe(false)
|
||||||
|
expect(resolveNewLayoutDesigns(false, undefined, false)).toBe(false)
|
||||||
|
expect(resolveNewLayoutDesigns(false, undefined, true)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("sunset replaces the toggle with a dismissible notice", () => {
|
||||||
|
expect(layoutTransitionState(true, true, true, false)).toEqual({ available: false, notice: true })
|
||||||
|
expect(layoutTransitionState(true, true, true, true)).toEqual({ available: false, notice: false })
|
||||||
|
expect(resolveNewLayoutDesigns(true, false)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("caps checks for sunsets beyond the browser timeout limit", () => {
|
||||||
|
expect(nextSunsetCheckDelay(maximumSunsetTimeout + 1_000, 0)).toBe(maximumSunsetTimeout)
|
||||||
|
expect(nextSunsetCheckDelay(10_000, 9_000)).toBe(1_000)
|
||||||
|
expect(nextSunsetCheckDelay(9_000, 10_000)).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("enables the new layout when upgrading from 1.17.19 or earlier", () => {
|
||||||
|
expect(shouldEnableNewLayout("v1.17.19", "1.17.20")).toBe(true)
|
||||||
|
expect(shouldEnableNewLayout("1.16.9", "2.0.0")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("enables the new layout when no previous version was recorded", () => {
|
||||||
|
expect(shouldEnableNewLayout(undefined, "1.17.20")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("detects upgrades only when a previous version is older", () => {
|
||||||
|
expect(isAppUpgrade("1.17.19", "1.17.20")).toBe(true)
|
||||||
|
expect(isAppUpgrade(undefined, "1.17.20")).toBe(false)
|
||||||
|
expect(isAppUpgrade("1.17.20", "1.17.20")).toBe(false)
|
||||||
|
expect(isAppUpgrade("1.17.21", "1.17.20")).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("shows the tabs toast for upgrades and existing installs without a recorded version", () => {
|
||||||
|
expect(shouldDisplayTabsToast("1.17.19", "1.17.20", false)).toBe(true)
|
||||||
|
expect(shouldDisplayTabsToast(undefined, "1.17.20", true)).toBe(true)
|
||||||
|
expect(shouldDisplayTabsToast(undefined, "1.17.20", false)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not enable the new layout without a qualifying upgrade", () => {
|
||||||
|
expect(shouldEnableNewLayout("1.17.19", "1.17.19")).toBe(false)
|
||||||
|
expect(shouldEnableNewLayout("1.17.20", "1.17.21")).toBe(false)
|
||||||
|
expect(shouldEnableNewLayout(undefined, "1.17.19")).toBe(false)
|
||||||
|
expect(shouldEnableNewLayout("dev", "1.17.20")).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { createStore, reconcile } from "solid-js/store"
|
import { createStore, reconcile } from "solid-js/store"
|
||||||
import { createEffect, createMemo } from "solid-js"
|
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { persisted } from "@/utils/persist"
|
import { persisted } from "@/utils/persist"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
|
|
||||||
export interface NotificationSettings {
|
export interface NotificationSettings {
|
||||||
agent: boolean
|
agent: boolean
|
||||||
@@ -33,6 +34,10 @@ export interface Settings {
|
|||||||
editToolPartsExpanded: boolean
|
editToolPartsExpanded: boolean
|
||||||
showCustomAgents: boolean
|
showCustomAgents: boolean
|
||||||
mobileTitlebarPosition: "top" | "bottom"
|
mobileTitlebarPosition: "top" | "bottom"
|
||||||
|
newLayoutDesigns?: boolean
|
||||||
|
layoutTransitionEligible?: boolean
|
||||||
|
newInterfaceNoticeDismissed?: boolean
|
||||||
|
shouldDisplayTabsToast?: boolean
|
||||||
}
|
}
|
||||||
appearance: {
|
appearance: {
|
||||||
fontSize: number
|
fontSize: number
|
||||||
@@ -51,6 +56,75 @@ export interface Settings {
|
|||||||
export const monoDefault = "System Mono"
|
export const monoDefault = "System Mono"
|
||||||
export const sansDefault = "System Sans"
|
export const sansDefault = "System Sans"
|
||||||
export const terminalDefault = "JetBrainsMono Nerd Font Mono"
|
export const terminalDefault = "JetBrainsMono Nerd Font Mono"
|
||||||
|
const legacyNewLayoutDesignsDefault = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||||
|
export const newLayoutDesignsDefault = true
|
||||||
|
// Existing users can switch layouts until local midnight on this date. Set new Date(YYYY, M-1, D) to show.
|
||||||
|
export const oldInterfaceSunset = new Date(2026, 8, 14)
|
||||||
|
const newLayoutDesignsUpgradeCutoff = "1.17.19"
|
||||||
|
|
||||||
|
function compareVersions(a: string, b: string) {
|
||||||
|
const parse = (version: string) => {
|
||||||
|
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/i.exec(version.trim())
|
||||||
|
if (!match) return
|
||||||
|
return match.slice(1).map(Number)
|
||||||
|
}
|
||||||
|
const left = parse(a)
|
||||||
|
const right = parse(b)
|
||||||
|
if (!left || !right) return
|
||||||
|
const index = left.findIndex((part, index) => part !== right[index])
|
||||||
|
return index === -1 ? 0 : left[index]! - right[index]!
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAppUpgrade(previous: string | undefined, current: string | undefined) {
|
||||||
|
if (!previous || !current) return false
|
||||||
|
const comparison = compareVersions(current, previous)
|
||||||
|
return comparison !== undefined && comparison > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldDisplayTabsToast(
|
||||||
|
previous: string | undefined,
|
||||||
|
current: string | undefined,
|
||||||
|
existingInstall: boolean,
|
||||||
|
) {
|
||||||
|
return isAppUpgrade(previous, current) || (!previous && existingInstall)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasExistingWebState(settings: Promise<string> | string | null, previousVersion: string | undefined) {
|
||||||
|
return settings !== null || previousVersion !== undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldEnableNewLayout(previous: string | undefined, current: string | undefined) {
|
||||||
|
if (!current) return false
|
||||||
|
const currentComparison = compareVersions(current, newLayoutDesignsUpgradeCutoff)
|
||||||
|
if (!previous) return currentComparison !== undefined && currentComparison > 0
|
||||||
|
if (!isAppUpgrade(previous, current)) return false
|
||||||
|
const previousComparison = compareVersions(previous, newLayoutDesignsUpgradeCutoff)
|
||||||
|
return (
|
||||||
|
previousComparison !== undefined &&
|
||||||
|
currentComparison !== undefined &&
|
||||||
|
previousComparison <= 0 &&
|
||||||
|
currentComparison > 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function layoutTransitionState(scheduled: boolean, eligible: boolean, retired: boolean, dismissed: boolean) {
|
||||||
|
return {
|
||||||
|
available: scheduled && eligible && !retired,
|
||||||
|
notice: scheduled && eligible && retired && !dismissed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const maximumSunsetTimeout = 2_147_483_647
|
||||||
|
|
||||||
|
export function nextSunsetCheckDelay(sunset: number, now: number) {
|
||||||
|
return Math.min(Math.max(0, sunset - now), maximumSunsetTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveNewLayoutDesigns(retired: boolean, preference: boolean | undefined, fallback = true) {
|
||||||
|
if (retired) return true
|
||||||
|
return preference ?? fallback
|
||||||
|
}
|
||||||
|
|
||||||
const monoFallback =
|
const monoFallback =
|
||||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
||||||
const sansFallback = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
|
const sansFallback = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
|
||||||
@@ -149,7 +223,17 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
name: "Settings",
|
name: "Settings",
|
||||||
gate: false,
|
gate: false,
|
||||||
init: () => {
|
init: () => {
|
||||||
const [store, setStore, , ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
|
const platform = usePlatform()
|
||||||
|
const [store, setStore, settingsInit, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
|
||||||
|
const [launch, setLaunch, , launchReady] = persisted(
|
||||||
|
"app-version.v1",
|
||||||
|
createStore<{ version?: string }>({ version: undefined }),
|
||||||
|
)
|
||||||
|
const [launchState, setLaunchState] = createStore({
|
||||||
|
classified: false,
|
||||||
|
migrationApplied: false,
|
||||||
|
previous: undefined as string | undefined,
|
||||||
|
})
|
||||||
const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
|
const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
|
||||||
const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
|
const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
|
||||||
const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
|
const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
|
||||||
@@ -157,6 +241,93 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
() => store.general?.showCustomAgents,
|
() => store.general?.showCustomAgents,
|
||||||
defaultSettings.general.showCustomAgents,
|
defaultSettings.general.showCustomAgents,
|
||||||
)
|
)
|
||||||
|
const sunset = oldInterfaceSunset
|
||||||
|
const [oldInterfaceRetired, setOldInterfaceRetired] = createSignal(sunset ? Date.now() >= sunset.getTime() : false)
|
||||||
|
const layoutTransitionClassified = createMemo(() => typeof store.general?.layoutTransitionEligible === "boolean")
|
||||||
|
const layoutTransitionEligible = withFallback(() => store.general?.layoutTransitionEligible, false)
|
||||||
|
const newInterfaceNoticeDismissed = withFallback(() => store.general?.newInterfaceNoticeDismissed, false)
|
||||||
|
const layoutUpgrade = createMemo(() =>
|
||||||
|
launchState.classified && !launchState.migrationApplied
|
||||||
|
? shouldEnableNewLayout(launchState.previous, platform.version)
|
||||||
|
: false,
|
||||||
|
)
|
||||||
|
const layoutTransition = createMemo(() =>
|
||||||
|
layoutTransitionState(!!sunset, layoutTransitionEligible(), oldInterfaceRetired(), newInterfaceNoticeDismissed()),
|
||||||
|
)
|
||||||
|
const newLayoutDesigns = createMemo(() => {
|
||||||
|
if (layoutUpgrade()) return true
|
||||||
|
if (!ready() && !oldInterfaceRetired()) return legacyNewLayoutDesignsDefault
|
||||||
|
if (!layoutTransitionClassified()) {
|
||||||
|
return resolveNewLayoutDesigns(
|
||||||
|
oldInterfaceRetired(),
|
||||||
|
store.general?.newLayoutDesigns,
|
||||||
|
legacyNewLayoutDesignsDefault,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return resolveNewLayoutDesigns(
|
||||||
|
oldInterfaceRetired(),
|
||||||
|
store.general?.newLayoutDesigns,
|
||||||
|
layoutTransitionEligible() ? legacyNewLayoutDesignsDefault : newLayoutDesignsDefault,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const visible = (preference: () => boolean) => createMemo(() => !newLayoutDesigns() || preference())
|
||||||
|
|
||||||
|
if (sunset && !oldInterfaceRetired()) {
|
||||||
|
const timeout = { current: undefined as ReturnType<typeof setTimeout> | undefined }
|
||||||
|
const checkSunset = () => {
|
||||||
|
if (Date.now() >= sunset.getTime()) {
|
||||||
|
setOldInterfaceRetired(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
timeout.current = setTimeout(checkSunset, nextSunsetCheckDelay(sunset.getTime(), Date.now()))
|
||||||
|
}
|
||||||
|
checkSunset()
|
||||||
|
onCleanup(() => {
|
||||||
|
if (timeout.current !== undefined) clearTimeout(timeout.current)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!launchReady() || launchState.classified) return
|
||||||
|
setLaunchState({
|
||||||
|
classified: true,
|
||||||
|
previous: launch.version,
|
||||||
|
})
|
||||||
|
if (!platform.version || launch.version === platform.version) return
|
||||||
|
setLaunch("version", platform.version)
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!ready() || !launchState.classified || platform.platform !== "web") return
|
||||||
|
if (layoutTransitionClassified()) return
|
||||||
|
setStore("general", "layoutTransitionEligible", hasExistingWebState(settingsInit, launchState.previous))
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!ready() || !launchState.classified || launchState.migrationApplied) return
|
||||||
|
if (layoutUpgrade() && store.general?.newLayoutDesigns !== true) {
|
||||||
|
setStore("general", "newLayoutDesigns", true)
|
||||||
|
}
|
||||||
|
setLaunchState("migrationApplied", true)
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!ready() || !launchState.classified) return
|
||||||
|
if (typeof store.general?.shouldDisplayTabsToast === "boolean") return
|
||||||
|
if (!launchState.previous && !layoutTransitionClassified()) return
|
||||||
|
setStore(
|
||||||
|
"general",
|
||||||
|
"shouldDisplayTabsToast",
|
||||||
|
shouldDisplayTabsToast(launchState.previous, platform.version, layoutTransitionEligible()),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!ready() || !oldInterfaceRetired()) return
|
||||||
|
if (store.general?.newLayoutDesigns === true) return
|
||||||
|
setStore("general", "newLayoutDesigns", true)
|
||||||
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (typeof document === "undefined") return
|
if (typeof document === "undefined") return
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
@@ -242,13 +413,34 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
setMobileTitlebarPosition(value: "top" | "bottom") {
|
setMobileTitlebarPosition(value: "top" | "bottom") {
|
||||||
setStore("general", "mobileTitlebarPosition", value)
|
setStore("general", "mobileTitlebarPosition", value)
|
||||||
},
|
},
|
||||||
newLayoutDesigns: () => true,
|
newLayoutDesigns,
|
||||||
|
setNewLayoutDesigns(value: boolean) {
|
||||||
|
const next = oldInterfaceRetired() ? true : value
|
||||||
|
if (newLayoutDesigns() === next) return
|
||||||
|
setStore("general", "newLayoutDesigns", next)
|
||||||
|
if (typeof window !== "undefined") setTimeout(() => window.location.reload())
|
||||||
|
},
|
||||||
|
layoutTransitionClassified,
|
||||||
|
setOldLayoutEligible(eligible: boolean) {
|
||||||
|
const current = store.general?.layoutTransitionEligible
|
||||||
|
if (typeof current === "boolean") return
|
||||||
|
setStore("general", "layoutTransitionEligible", eligible)
|
||||||
|
},
|
||||||
|
layoutTransitionAvailable: createMemo(() => ready() && layoutTransition().available),
|
||||||
|
newInterfaceNoticeVisible: createMemo(() => ready() && layoutTransition().notice),
|
||||||
|
dismissNewInterfaceNotice() {
|
||||||
|
setStore("general", "newInterfaceNoticeDismissed", true)
|
||||||
|
},
|
||||||
|
shouldDisplayTabsToast: withFallback(() => store.general?.shouldDisplayTabsToast, false),
|
||||||
|
dismissTabsToast() {
|
||||||
|
setStore("general", "shouldDisplayTabsToast", false)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
visibility: {
|
visibility: {
|
||||||
fileTree: showFileTree,
|
fileTree: visible(showFileTree),
|
||||||
search: showSearch,
|
search: visible(showSearch),
|
||||||
status: showStatus,
|
status: visible(showStatus),
|
||||||
customAgents: showCustomAgents,
|
customAgents: visible(showCustomAgents),
|
||||||
},
|
},
|
||||||
appearance: {
|
appearance: {
|
||||||
fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize),
|
fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize),
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { createHomeSessionSearchController } from "./home/home-session-search-co
|
|||||||
import { createHomeSessionsController } from "./home/home-sessions-controller"
|
import { createHomeSessionsController } from "./home/home-sessions-controller"
|
||||||
import { HomeSessions } from "./home/home-sessions"
|
import { HomeSessions } from "./home/home-sessions"
|
||||||
|
|
||||||
export function Home() {
|
export function NewHome() {
|
||||||
const home = createHomeController()
|
const home = createHomeController()
|
||||||
const projects = createHomeProjectsController(home)
|
const projects = createHomeProjectsController(home)
|
||||||
const sessions = createHomeSessionsController(home)
|
const sessions = createHomeSessionsController(home)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||||
import { useServerActionsController } from "@/components/server/server-management-controller"
|
import { useServerManagementController } from "@/components/dialog-select-server"
|
||||||
import { useSettingsCommand } from "@/components/settings-dialog"
|
import { useSettingsCommand } from "@/components/settings-dialog"
|
||||||
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
|
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
|
||||||
import { type LocalProject } from "@/context/layout"
|
import { type LocalProject } from "@/context/layout"
|
||||||
@@ -22,7 +22,7 @@ export function createHomeProjectsController(home: HomeController) {
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const notification = useNotification()
|
const notification = useNotification()
|
||||||
const openSettings = useSettingsCommand()
|
const openSettings = useSettingsCommand()
|
||||||
const serverManagement = useServerActionsController()
|
const serverManagement = useServerManagementController({ navigateOnAdd: false })
|
||||||
const [_state, setState, _, ready] = persisted(
|
const [_state, setState, _, ready] = persisted(
|
||||||
Persist.global("home.servers", ["home.servers.v1"]),
|
Persist.global("home.servers", ["home.servers.v1"]),
|
||||||
createStore({ collapsed: {} as Record<string, boolean> }),
|
createStore({ collapsed: {} as Record<string, boolean> }),
|
||||||
@@ -56,12 +56,12 @@ export function createHomeProjectsController(home: HomeController) {
|
|||||||
const key = ServerConnection.key(conn)
|
const key = ServerConnection.key(conn)
|
||||||
setState("collapsed", key, !state().collapsed[key])
|
setState("collapsed", key, !state().collapsed[key])
|
||||||
},
|
},
|
||||||
canDefault: serverManagement.defaults.available,
|
canDefault: serverManagement.canDefault,
|
||||||
defaultKey: serverManagement.defaults.key,
|
defaultKey: serverManagement.defaultKey,
|
||||||
setDefault: (conn: ServerConnection.Any | undefined) =>
|
setDefault: (conn: ServerConnection.Any | undefined) =>
|
||||||
serverManagement.defaults.set(conn ? ServerConnection.key(conn) : null),
|
serverManagement.setDefault(conn ? ServerConnection.key(conn) : null),
|
||||||
canRemove: (conn: ServerConnection.Any) => serverManagement.connection.canRemove(ServerConnection.key(conn)),
|
canRemove: (conn: ServerConnection.Any) => serverManagement.canRemove(ServerConnection.key(conn)),
|
||||||
remove: (conn: ServerConnection.Any) => serverManagement.connection.remove(ServerConnection.key(conn)),
|
remove: (conn: ServerConnection.Any) => serverManagement.handleRemove(ServerConnection.key(conn)),
|
||||||
edit: (conn: ServerConnection.Http) => dialog.show(() => <DialogServerV2 mode="edit" server={conn} />),
|
edit: (conn: ServerConnection.Http) => dialog.show(() => <DialogServerV2 mode="edit" server={conn} />),
|
||||||
focus: home.selection.focusServer,
|
focus: home.selection.focusServer,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { DialogSelectServer } from "@/components/dialog-select-server"
|
||||||
|
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||||
|
import { useGlobal } from "@/context/global"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { type ServerConnection, useServer } from "@/context/server"
|
||||||
|
import { useServerSync } from "@/context/server-sync"
|
||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { Logo } from "@opencode-ai/ui/logo"
|
||||||
|
import { useNavigate } from "@solidjs/router"
|
||||||
|
import { DateTime } from "luxon"
|
||||||
|
import { createMemo, For, Match, Switch } from "solid-js"
|
||||||
|
|
||||||
|
export function LegacyHome() {
|
||||||
|
const sync = useServerSync()
|
||||||
|
const pickDirectory = useDirectoryPicker()
|
||||||
|
const dialog = useDialog()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const global = useGlobal()
|
||||||
|
const server = useServer()
|
||||||
|
const language = useLanguage()
|
||||||
|
const homedir = createMemo(() => sync().data.path.home)
|
||||||
|
const serverUnreachable = createMemo(() => global.servers.health[server.key]?.healthy === false)
|
||||||
|
const recent = createMemo(() => {
|
||||||
|
return sync()
|
||||||
|
.data.project.slice()
|
||||||
|
.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
|
||||||
|
.slice(0, 5)
|
||||||
|
})
|
||||||
|
|
||||||
|
const serverDotClass = createMemo(() => {
|
||||||
|
const healthy = global.servers.health[server.key]?.healthy
|
||||||
|
if (healthy === true) return "bg-icon-success-base"
|
||||||
|
if (healthy === false) return "bg-icon-critical-base"
|
||||||
|
return "bg-border-weak-base"
|
||||||
|
})
|
||||||
|
|
||||||
|
function openProject(conn: ServerConnection.Any, directory: string) {
|
||||||
|
const serverCtx = global.ensureServerCtx(conn)
|
||||||
|
serverCtx.projects.open(directory)
|
||||||
|
serverCtx.projects.touch(directory)
|
||||||
|
navigate(`/${base64Encode(directory)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseProject() {
|
||||||
|
if (serverUnreachable()) return
|
||||||
|
const conn = server.current
|
||||||
|
if (!conn) return
|
||||||
|
|
||||||
|
const resolve = (result: string | string[] | null) => {
|
||||||
|
if (Array.isArray(result)) {
|
||||||
|
result.forEach((directory) => openProject(conn, directory))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (result) openProject(conn, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
pickDirectory({
|
||||||
|
server: conn,
|
||||||
|
title: language.t("command.project.open"),
|
||||||
|
multiple: true,
|
||||||
|
onSelect: resolve,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="mx-auto mt-55 w-full md:w-auto px-4">
|
||||||
|
<Logo class="md:w-xl opacity-12" />
|
||||||
|
<Button
|
||||||
|
size="large"
|
||||||
|
variant="ghost"
|
||||||
|
class="mt-4 mx-auto text-14-regular text-text-weak"
|
||||||
|
onClick={() => dialog.show(() => <DialogSelectServer />)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"size-2 rounded-full": true,
|
||||||
|
[serverDotClass()]: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{server.name}
|
||||||
|
</Button>
|
||||||
|
<Switch>
|
||||||
|
<Match when={sync().data.project.length > 0}>
|
||||||
|
<div class="mt-20 w-full flex flex-col gap-4">
|
||||||
|
<div class="flex gap-2 items-center justify-between pl-3">
|
||||||
|
<div class="text-14-medium text-text-strong">{language.t("home.recentProjects")}</div>
|
||||||
|
<Button
|
||||||
|
icon="folder-add-left"
|
||||||
|
size="normal"
|
||||||
|
class="pl-2 pr-3"
|
||||||
|
disabled={serverUnreachable()}
|
||||||
|
onClick={chooseProject}
|
||||||
|
>
|
||||||
|
{language.t("command.project.open")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<ul class="flex flex-col gap-2">
|
||||||
|
<For each={recent()}>
|
||||||
|
{(project) => (
|
||||||
|
<Button
|
||||||
|
size="large"
|
||||||
|
variant="ghost"
|
||||||
|
class="text-14-mono text-left justify-between px-3"
|
||||||
|
onClick={() => openProject(server.current!, project.worktree)}
|
||||||
|
>
|
||||||
|
{project.worktree.replace(homedir(), "~")}
|
||||||
|
<div class="text-14-regular text-text-weak">
|
||||||
|
{DateTime.fromMillis(project.time.updated ?? project.time.created).toRelative()}
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</Match>
|
||||||
|
<Match when={!sync().ready}>
|
||||||
|
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
|
||||||
|
<div class="text-12-regular text-text-weak">{language.t("common.loading")}</div>
|
||||||
|
<Button class="px-3" disabled={serverUnreachable()} onClick={chooseProject}>
|
||||||
|
{language.t("command.project.open")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Match>
|
||||||
|
<Match when={true}>
|
||||||
|
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
|
||||||
|
<Icon name="folder-add-left" size="large" />
|
||||||
|
<div class="flex flex-col gap-1 items-center justify-center">
|
||||||
|
<div class="text-14-medium text-text-strong">{language.t("home.empty.title")}</div>
|
||||||
|
<div class="text-12-regular text-text-weak">{language.t("home.empty.description")}</div>
|
||||||
|
</div>
|
||||||
|
<Button class="px-3 mt-1" disabled={serverUnreachable()} onClick={chooseProject}>
|
||||||
|
{language.t("command.project.open")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { createEffect, Suspense, type ParentProps } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { useNavigate } from "@solidjs/router"
|
||||||
|
import { DebugBar } from "@/components/debug-bar"
|
||||||
|
import { TabsInfoPopup } from "@/components/help-button"
|
||||||
|
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { setNavigate } from "@/utils/notification-click"
|
||||||
|
import { setV2Toast, ToastRegion } from "@/utils/toast"
|
||||||
|
|
||||||
|
export default function NewLayout(props: ParentProps) {
|
||||||
|
const platform = usePlatform()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
setNavigate(navigate)
|
||||||
|
const [state, setState] = createStore({ debugTools: true })
|
||||||
|
|
||||||
|
createEffect(() => setV2Toast(true))
|
||||||
|
|
||||||
|
const update: TitlebarUpdate = {
|
||||||
|
version: () => {
|
||||||
|
const state = platform.updater?.state()
|
||||||
|
if (state?.status !== "ready") return
|
||||||
|
return state.version
|
||||||
|
},
|
||||||
|
installing: () => platform.updater?.state().status === "installing",
|
||||||
|
install: () => void platform.updater?.install(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text"
|
||||||
|
style={{
|
||||||
|
"padding-top": "env(safe-area-inset-top, 0px)",
|
||||||
|
"padding-bottom": "env(safe-area-inset-bottom, 0px)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Titlebar
|
||||||
|
update={update}
|
||||||
|
debugTools={
|
||||||
|
import.meta.env.DEV
|
||||||
|
? { visible: state.debugTools, toggle: () => setState("debugTools", (value) => !value) }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
|
||||||
|
<Suspense>{props.children}</Suspense>
|
||||||
|
</main>
|
||||||
|
{import.meta.env.DEV && state.debugTools && <DebugBar inline />}
|
||||||
|
<TabsInfoPopup />
|
||||||
|
<ToastRegion v2 />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+2388
-22
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,126 @@
|
|||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { onCleanup, Show, type Accessor } from "solid-js"
|
||||||
|
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||||
|
|
||||||
|
export function createInlineEditorController() {
|
||||||
|
// This controller intentionally supports one active inline editor at a time.
|
||||||
|
const [editor, setEditor] = createStore({
|
||||||
|
active: "" as string,
|
||||||
|
value: "",
|
||||||
|
})
|
||||||
|
|
||||||
|
const editorOpen = (id: string) => editor.active === id
|
||||||
|
const editorValue = () => editor.value
|
||||||
|
const openEditor = (id: string, value: string) => {
|
||||||
|
if (!id) return
|
||||||
|
setEditor({ active: id, value })
|
||||||
|
}
|
||||||
|
const closeEditor = () => setEditor({ active: "", value: "" })
|
||||||
|
|
||||||
|
const saveEditor = (callback: (next: string) => void) => {
|
||||||
|
const next = editor.value.trim()
|
||||||
|
if (!next) {
|
||||||
|
closeEditor()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
closeEditor()
|
||||||
|
callback(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const editorKeyDown = (event: KeyboardEvent, callback: (next: string) => void) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault()
|
||||||
|
saveEditor(callback)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key !== "Escape") return
|
||||||
|
event.preventDefault()
|
||||||
|
closeEditor()
|
||||||
|
}
|
||||||
|
|
||||||
|
const InlineEditor = (props: {
|
||||||
|
id: string
|
||||||
|
value: Accessor<string>
|
||||||
|
onSave: (next: string) => void
|
||||||
|
class?: string
|
||||||
|
displayClass?: string
|
||||||
|
editing?: boolean
|
||||||
|
stopPropagation?: boolean
|
||||||
|
openOnDblClick?: boolean
|
||||||
|
}) => {
|
||||||
|
let frame: number | undefined
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (frame === undefined) return
|
||||||
|
cancelAnimationFrame(frame)
|
||||||
|
})
|
||||||
|
|
||||||
|
const isEditing = () => props.editing ?? editorOpen(props.id)
|
||||||
|
const stopEvents = () => props.stopPropagation ?? false
|
||||||
|
const allowDblClick = () => props.openOnDblClick ?? true
|
||||||
|
const stopPropagation = (event: Event) => {
|
||||||
|
if (!stopEvents()) return
|
||||||
|
event.stopPropagation()
|
||||||
|
}
|
||||||
|
const handleDblClick = (event: MouseEvent) => {
|
||||||
|
if (!allowDblClick()) return
|
||||||
|
stopPropagation(event)
|
||||||
|
openEditor(props.id, props.value())
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show
|
||||||
|
when={isEditing()}
|
||||||
|
fallback={
|
||||||
|
<span
|
||||||
|
class={props.displayClass ?? props.class}
|
||||||
|
onDblClick={handleDblClick}
|
||||||
|
onPointerDown={stopPropagation}
|
||||||
|
onMouseDown={stopPropagation}
|
||||||
|
onClick={stopPropagation}
|
||||||
|
onTouchStart={stopPropagation}
|
||||||
|
>
|
||||||
|
{props.value()}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<InlineInput
|
||||||
|
ref={(el) => {
|
||||||
|
if (frame !== undefined) cancelAnimationFrame(frame)
|
||||||
|
frame = requestAnimationFrame(() => {
|
||||||
|
frame = undefined
|
||||||
|
if (!el.isConnected) return
|
||||||
|
el.focus()
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
value={editorValue()}
|
||||||
|
class={props.class}
|
||||||
|
onInput={(event) => setEditor("value", event.currentTarget.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
editorKeyDown(event, props.onSave)
|
||||||
|
}}
|
||||||
|
onBlur={closeEditor}
|
||||||
|
onPointerDown={stopPropagation}
|
||||||
|
onClick={stopPropagation}
|
||||||
|
onDblClick={stopPropagation}
|
||||||
|
onMouseDown={stopPropagation}
|
||||||
|
onMouseUp={stopPropagation}
|
||||||
|
onTouchStart={stopPropagation}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
editor,
|
||||||
|
editorOpen,
|
||||||
|
editorValue,
|
||||||
|
openEditor,
|
||||||
|
closeEditor,
|
||||||
|
saveEditor,
|
||||||
|
editorKeyDown,
|
||||||
|
setEditor,
|
||||||
|
InlineEditor,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||||
|
import { Avatar } from "@opencode-ai/ui/avatar"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
|
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||||
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
|
import { A, useParams } from "@solidjs/router"
|
||||||
|
import { type Accessor, createMemo, For, type JSX, Match, Show, Switch } from "solid-js"
|
||||||
|
import { useServerSync } from "@/context/server-sync"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
|
||||||
|
import { useNotification } from "@/context/notification"
|
||||||
|
import { usePermission } from "@/context/permission"
|
||||||
|
import { messageAgentColor } from "@/utils/agent"
|
||||||
|
import { sessionLabel } from "@/utils/session-title"
|
||||||
|
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
|
||||||
|
import { childSessionOnPath, getProjectAvatarSource, hasProjectPermissions } from "./helpers"
|
||||||
|
|
||||||
|
export const ProjectIcon = (props: {
|
||||||
|
project: LocalProject
|
||||||
|
class?: string
|
||||||
|
notify?: boolean
|
||||||
|
working?: boolean
|
||||||
|
}): JSX.Element => {
|
||||||
|
const serverSync = useServerSync()
|
||||||
|
const notification = useNotification()
|
||||||
|
const permission = usePermission()
|
||||||
|
const dirs = createMemo(() => [props.project.worktree, ...(props.project.sandboxes ?? [])])
|
||||||
|
const unseenCount = createMemo(() =>
|
||||||
|
dirs().reduce((total, directory) => total + notification.project.unseenCount(directory), 0),
|
||||||
|
)
|
||||||
|
const hasError = createMemo(() => dirs().some((directory) => notification.project.unseenHasError(directory)))
|
||||||
|
const hasPermissions = createMemo(() =>
|
||||||
|
dirs().some((directory) => {
|
||||||
|
return hasProjectPermissions(serverSync().session.data.permission, (item) => {
|
||||||
|
if (serverSync().session.get(item.sessionID)?.location.directory !== directory) return false
|
||||||
|
return !permission.autoResponds(item, directory)
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const notify = createMemo(() => props.notify && (hasPermissions() || unseenCount() > 0))
|
||||||
|
const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class={`relative size-8 shrink-0 rounded ${props.class ?? ""}`}>
|
||||||
|
<div class="size-full rounded overflow-clip">
|
||||||
|
<Avatar
|
||||||
|
fallback={name()}
|
||||||
|
src={getProjectAvatarSource(props.project.id, props.project.icon)}
|
||||||
|
{...getAvatarColors(props.project.icon?.color)}
|
||||||
|
class="size-full rounded"
|
||||||
|
classList={{ "badge-mask": notify() }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Show when={notify()}>
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"absolute top-px right-px size-1.5 rounded-full z-10": true,
|
||||||
|
"bg-surface-warning-strong": hasPermissions(),
|
||||||
|
"bg-icon-critical-base": !hasPermissions() && hasError(),
|
||||||
|
"bg-text-interactive-base": !hasPermissions() && !hasError(),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
<Show when={props.working}>
|
||||||
|
<div class="absolute bottom-px right-px size-3 rounded-full bg-background-base z-10 flex items-center justify-center">
|
||||||
|
<Spinner class="size-[9px]" />
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionItemProps = {
|
||||||
|
session: SessionInfo
|
||||||
|
list: SessionInfo[]
|
||||||
|
navList?: Accessor<SessionInfo[]>
|
||||||
|
slug: string
|
||||||
|
mobile?: boolean
|
||||||
|
dense?: boolean
|
||||||
|
showTooltip?: boolean
|
||||||
|
showChild?: boolean
|
||||||
|
level?: number
|
||||||
|
sidebarExpanded: Accessor<boolean>
|
||||||
|
clearHoverProjectSoon: () => void
|
||||||
|
prefetchSession: (session: SessionInfo, priority?: "high" | "low") => void
|
||||||
|
archiveSession: (session: SessionInfo) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const SessionRow = (props: {
|
||||||
|
session: SessionInfo
|
||||||
|
slug: string
|
||||||
|
mobile?: boolean
|
||||||
|
dense?: boolean
|
||||||
|
tint: Accessor<string | undefined>
|
||||||
|
isWorking: Accessor<boolean>
|
||||||
|
hasPermissions: Accessor<boolean>
|
||||||
|
hasError: Accessor<boolean>
|
||||||
|
unseenCount: Accessor<number>
|
||||||
|
clearHoverProjectSoon: () => void
|
||||||
|
sidebarOpened: Accessor<boolean>
|
||||||
|
warmPress: () => void
|
||||||
|
warmFocus: () => void
|
||||||
|
}): JSX.Element => {
|
||||||
|
const title = () => sessionLabel(props.session)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<A
|
||||||
|
href={`/${props.slug}/session/${props.session.id}`}
|
||||||
|
class={`flex items-center gap-2 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
|
||||||
|
onPointerDown={props.warmPress}
|
||||||
|
onFocus={props.warmFocus}
|
||||||
|
onClick={() => {
|
||||||
|
if (props.sidebarOpened()) return
|
||||||
|
props.clearHoverProjectSoon()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Show when={props.isWorking() || props.hasPermissions() || props.hasError() || props.unseenCount() > 0}>
|
||||||
|
<div
|
||||||
|
class="shrink-0 size-6 flex items-center justify-center"
|
||||||
|
style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
|
||||||
|
>
|
||||||
|
<Switch>
|
||||||
|
<Match when={props.isWorking()}>
|
||||||
|
<Spinner class="size-[15px]" />
|
||||||
|
</Match>
|
||||||
|
<Match when={props.hasPermissions()}>
|
||||||
|
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
|
||||||
|
</Match>
|
||||||
|
<Match when={props.hasError()}>
|
||||||
|
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
|
||||||
|
</Match>
|
||||||
|
<Match when={props.unseenCount() > 0}>
|
||||||
|
<div class="size-1.5 rounded-full bg-text-interactive-base" />
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{title()}</span>
|
||||||
|
</A>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
||||||
|
const params = useParams()
|
||||||
|
const layout = useLayout()
|
||||||
|
const language = useLanguage()
|
||||||
|
const notification = useNotification()
|
||||||
|
const permission = usePermission()
|
||||||
|
const serverSync = useServerSync()
|
||||||
|
const unseenCount = createMemo(() => notification.session.unseenCount(props.session.id))
|
||||||
|
const hasError = createMemo(() => notification.session.unseenHasError(props.session.id))
|
||||||
|
const [sessionStore] = serverSync().child(props.session.location.directory)
|
||||||
|
const hasPermissions = createMemo(() => {
|
||||||
|
return !!sessionPermissionRequest(
|
||||||
|
sessionStore.session,
|
||||||
|
serverSync().session.data.permission,
|
||||||
|
props.session.id,
|
||||||
|
(item) => {
|
||||||
|
return !permission.autoResponds(item, props.session.location.directory)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const isWorking = createMemo(() => {
|
||||||
|
if (hasPermissions()) return false
|
||||||
|
return serverSync().session.data.session_working(props.session.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
const tint = createMemo(() =>
|
||||||
|
messageAgentColor(serverSync().session.data.message[props.session.id], sessionStore.agent),
|
||||||
|
)
|
||||||
|
const tooltip = createMemo(() => props.showTooltip ?? (props.mobile || !props.sidebarExpanded()))
|
||||||
|
const currentChild = createMemo(() => {
|
||||||
|
if (!props.showChild) return
|
||||||
|
return childSessionOnPath(sessionStore.session, props.session.id, params.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
const warm = (span: number, priority: "high" | "low") => {
|
||||||
|
const nav = props.navList?.()
|
||||||
|
const list = nav?.some(
|
||||||
|
(item) => item.id === props.session.id && item.location.directory === props.session.location.directory,
|
||||||
|
)
|
||||||
|
? nav
|
||||||
|
: props.list
|
||||||
|
|
||||||
|
props.prefetchSession(props.session, priority)
|
||||||
|
|
||||||
|
const idx = list.findIndex(
|
||||||
|
(item) => item.id === props.session.id && item.location.directory === props.session.location.directory,
|
||||||
|
)
|
||||||
|
if (idx === -1) return
|
||||||
|
|
||||||
|
for (let step = 1; step <= span; step++) {
|
||||||
|
const next = list[idx + step]
|
||||||
|
if (next) props.prefetchSession(next, step === 1 ? "high" : priority)
|
||||||
|
|
||||||
|
const prev = list[idx - step]
|
||||||
|
if (prev) props.prefetchSession(prev, step === 1 ? "high" : priority)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = (
|
||||||
|
<SessionRow
|
||||||
|
session={props.session}
|
||||||
|
slug={props.slug}
|
||||||
|
mobile={props.mobile}
|
||||||
|
dense={props.dense}
|
||||||
|
tint={tint}
|
||||||
|
isWorking={isWorking}
|
||||||
|
hasPermissions={hasPermissions}
|
||||||
|
hasError={hasError}
|
||||||
|
unseenCount={unseenCount}
|
||||||
|
clearHoverProjectSoon={props.clearHoverProjectSoon}
|
||||||
|
sidebarOpened={layout.sidebar.opened}
|
||||||
|
warmPress={() => warm(2, "high")}
|
||||||
|
warmFocus={() => warm(2, "high")}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
data-session-id={props.session.id}
|
||||||
|
class="group/session relative w-full min-w-0 rounded-md cursor-default pr-3 transition-colors hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
|
||||||
|
style={{ "padding-left": `${8 + (props.level ?? 0) * 16}px` }}
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 items-center gap-1">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<Show
|
||||||
|
when={!tooltip()}
|
||||||
|
fallback={
|
||||||
|
<Tooltip
|
||||||
|
placement={props.mobile ? "bottom" : "right"}
|
||||||
|
value={sessionLabel(props.session)}
|
||||||
|
gutter={10}
|
||||||
|
class="min-w-0 w-full"
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TODO: Restore the archive action when the V2 client exposes session archive. */}
|
||||||
|
<Show when={false}>
|
||||||
|
<div
|
||||||
|
class="shrink-0 overflow-hidden transition-[width,opacity]"
|
||||||
|
classList={{
|
||||||
|
"w-6 opacity-100 pointer-events-auto": !!props.mobile,
|
||||||
|
"w-0 opacity-0 pointer-events-none": !props.mobile,
|
||||||
|
"group-hover/session:w-6 group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true,
|
||||||
|
"group-focus-within/session:w-6 group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tooltip value={language.t("common.archive")} placement="top">
|
||||||
|
<IconButton
|
||||||
|
icon="archive"
|
||||||
|
variant="ghost"
|
||||||
|
class="size-6 rounded-md"
|
||||||
|
aria-label={language.t("common.archive")}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
void props.archiveSession(props.session)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={currentChild()} keyed>
|
||||||
|
{(child) => (
|
||||||
|
<div class="w-full">
|
||||||
|
<SessionItem {...props} session={child} level={(props.level ?? 0) + 1} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NewSessionItem = (props: {
|
||||||
|
slug: string
|
||||||
|
mobile?: boolean
|
||||||
|
dense?: boolean
|
||||||
|
sidebarExpanded: Accessor<boolean>
|
||||||
|
clearHoverProjectSoon: () => void
|
||||||
|
}): JSX.Element => {
|
||||||
|
const layout = useLayout()
|
||||||
|
const language = useLanguage()
|
||||||
|
const label = language.t("command.session.new")
|
||||||
|
const tooltip = () => props.mobile || !props.sidebarExpanded()
|
||||||
|
const item = (
|
||||||
|
<A
|
||||||
|
href={`/${props.slug}/session`}
|
||||||
|
end
|
||||||
|
class={`flex items-center gap-2 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (layout.sidebar.opened()) return
|
||||||
|
props.clearHoverProjectSoon()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="shrink-0 size-6 flex items-center justify-center">
|
||||||
|
<IconV2 name="edit" size="small" class="text-icon-weak" />
|
||||||
|
</div>
|
||||||
|
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{label}</span>
|
||||||
|
</A>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="group/session relative w-full min-w-0 rounded-md cursor-default transition-colors pl-2 pr-3 hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active">
|
||||||
|
<Show
|
||||||
|
when={!tooltip()}
|
||||||
|
fallback={
|
||||||
|
<Tooltip placement={props.mobile ? "bottom" : "right"} value={label} gutter={10} class="min-w-0 w-full">
|
||||||
|
{item}
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SessionSkeleton = (props: { count?: number }): JSX.Element => {
|
||||||
|
const items = Array.from({ length: props.count ?? 4 }, (_, index) => index)
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<For each={items}>
|
||||||
|
{() => <div class="h-8 w-full rounded-md bg-surface-raised-base opacity-60 animate-pulse" />}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
import { createMemo, For, Show, type Accessor, type JSX } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
import { ContextMenu } from "@opencode-ai/ui/context-menu"
|
||||||
|
import { HoverCard } from "@opencode-ai/ui/hover-card"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { createSortable } from "@thisbeyond/solid-dnd"
|
||||||
|
import { useLayout, type LocalProject } from "@/context/layout"
|
||||||
|
import { useServerSync } from "@/context/server-sync"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useNotification } from "@/context/notification"
|
||||||
|
import { ProjectIcon, SessionItem, type SessionItemProps } from "./sidebar-items"
|
||||||
|
import { displayName, sortedRootSessions } from "./helpers"
|
||||||
|
|
||||||
|
export type ProjectSidebarContext = {
|
||||||
|
currentDir: Accessor<string>
|
||||||
|
currentProject: Accessor<LocalProject | undefined>
|
||||||
|
sidebarOpened: Accessor<boolean>
|
||||||
|
sidebarHovering: Accessor<boolean>
|
||||||
|
hoverProject: Accessor<string | undefined>
|
||||||
|
onProjectMouseEnter: (worktree: string, event: MouseEvent) => void
|
||||||
|
onProjectMouseLeave: (worktree: string) => void
|
||||||
|
onProjectFocus: (worktree: string) => void
|
||||||
|
onHoverOpenChanged: (worktree: string, hovered: boolean) => void
|
||||||
|
navigateToProject: (directory: string) => void
|
||||||
|
openSidebar: () => void
|
||||||
|
closeProject: (directory: string) => void
|
||||||
|
showEditProjectDialog: (project: LocalProject) => void
|
||||||
|
toggleProjectWorkspaces: (project: LocalProject) => void
|
||||||
|
workspacesEnabled: (project: LocalProject) => boolean
|
||||||
|
workspaceIds: (project: LocalProject) => string[]
|
||||||
|
workspaceLabel: (directory: string, branch?: string, projectId?: string) => string
|
||||||
|
sessionProps: Omit<SessionItemProps, "session" | "list" | "slug" | "mobile" | "dense">
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProjectDragOverlay = (props: {
|
||||||
|
projects: Accessor<LocalProject[]>
|
||||||
|
activeProject: Accessor<string | undefined>
|
||||||
|
}): JSX.Element => {
|
||||||
|
const project = createMemo(() => props.projects().find((p) => p.worktree === props.activeProject()))
|
||||||
|
return (
|
||||||
|
<Show when={project()}>
|
||||||
|
{(p) => (
|
||||||
|
<div class="bg-background-base rounded-xl p-1">
|
||||||
|
<ProjectIcon project={p()} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProjectTile = (props: {
|
||||||
|
project: LocalProject
|
||||||
|
mobile?: boolean
|
||||||
|
sidebarHovering: Accessor<boolean>
|
||||||
|
selected: Accessor<boolean>
|
||||||
|
active: Accessor<boolean>
|
||||||
|
isWorking: Accessor<boolean>
|
||||||
|
overlay: Accessor<boolean>
|
||||||
|
suppressHover: Accessor<boolean>
|
||||||
|
dirs: Accessor<string[]>
|
||||||
|
onProjectMouseEnter: (worktree: string, event: MouseEvent) => void
|
||||||
|
onProjectMouseLeave: (worktree: string) => void
|
||||||
|
onProjectFocus: (worktree: string) => void
|
||||||
|
navigateToProject: (directory: string) => void
|
||||||
|
showEditProjectDialog: (project: LocalProject) => void
|
||||||
|
toggleProjectWorkspaces: (project: LocalProject) => void
|
||||||
|
workspacesEnabled: (project: LocalProject) => boolean
|
||||||
|
closeProject: (directory: string) => void
|
||||||
|
setMenu: (value: boolean) => void
|
||||||
|
setOpen: (value: boolean) => void
|
||||||
|
setSuppressHover: (value: boolean) => void
|
||||||
|
language: ReturnType<typeof useLanguage>
|
||||||
|
}): JSX.Element => {
|
||||||
|
const notification = useNotification()
|
||||||
|
const layout = useLayout()
|
||||||
|
const unseenCount = createMemo(() =>
|
||||||
|
props.dirs().reduce((total, directory) => total + notification.project.unseenCount(directory), 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
const clear = () =>
|
||||||
|
props
|
||||||
|
.dirs()
|
||||||
|
.filter((directory) => notification.project.unseenCount(directory) > 0)
|
||||||
|
.forEach((directory) => notification.project.markViewed(directory))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContextMenu
|
||||||
|
modal={!props.sidebarHovering()}
|
||||||
|
onOpenChange={(value) => {
|
||||||
|
props.setMenu(value)
|
||||||
|
props.setSuppressHover(value)
|
||||||
|
if (value) props.setOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ContextMenu.Trigger
|
||||||
|
as="button"
|
||||||
|
type="button"
|
||||||
|
aria-label={displayName(props.project)}
|
||||||
|
data-action="project-switch"
|
||||||
|
data-project={base64Encode(props.project.worktree)}
|
||||||
|
classList={{
|
||||||
|
"flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true,
|
||||||
|
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": props.selected(),
|
||||||
|
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
|
||||||
|
!props.selected() && !props.active(),
|
||||||
|
"bg-surface-base-hover border border-border-weak-base": !props.selected() && props.active(),
|
||||||
|
}}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
if (event.button === 0 && !event.ctrlKey) {
|
||||||
|
props.setOpen(false)
|
||||||
|
props.setSuppressHover(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!props.overlay()) return
|
||||||
|
if (event.button !== 2 && !(event.button === 0 && event.ctrlKey)) return
|
||||||
|
props.setOpen(false)
|
||||||
|
props.setSuppressHover(true)
|
||||||
|
event.preventDefault()
|
||||||
|
}}
|
||||||
|
onMouseEnter={(event: MouseEvent) => {
|
||||||
|
if (!props.overlay()) return
|
||||||
|
if (props.suppressHover()) return
|
||||||
|
props.onProjectMouseEnter(props.project.worktree, event)
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
if (props.suppressHover()) props.setSuppressHover(false)
|
||||||
|
if (!props.overlay()) return
|
||||||
|
props.onProjectMouseLeave(props.project.worktree)
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
if (!props.overlay()) return
|
||||||
|
if (props.suppressHover()) return
|
||||||
|
props.onProjectFocus(props.project.worktree)
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
props.setOpen(false)
|
||||||
|
if (props.selected()) {
|
||||||
|
layout.sidebar.toggle()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
props.navigateToProject(props.project.worktree)
|
||||||
|
}}
|
||||||
|
onBlur={() => props.setOpen(false)}
|
||||||
|
>
|
||||||
|
<ProjectIcon project={props.project} notify working={props.isWorking()} />
|
||||||
|
</ContextMenu.Trigger>
|
||||||
|
<ContextMenu.Portal>
|
||||||
|
<ContextMenu.Content>
|
||||||
|
<ContextMenu.Item onSelect={() => props.showEditProjectDialog(props.project)}>
|
||||||
|
<ContextMenu.ItemLabel>{props.language.t("common.edit")}</ContextMenu.ItemLabel>
|
||||||
|
</ContextMenu.Item>
|
||||||
|
<ContextMenu.Item
|
||||||
|
data-action="project-workspaces-toggle"
|
||||||
|
data-project={base64Encode(props.project.worktree)}
|
||||||
|
disabled={props.project.vcs !== "git" && !props.workspacesEnabled(props.project)}
|
||||||
|
onSelect={() => props.toggleProjectWorkspaces(props.project)}
|
||||||
|
>
|
||||||
|
<ContextMenu.ItemLabel>
|
||||||
|
{props.workspacesEnabled(props.project)
|
||||||
|
? props.language.t("sidebar.workspaces.disable")
|
||||||
|
: props.language.t("sidebar.workspaces.enable")}
|
||||||
|
</ContextMenu.ItemLabel>
|
||||||
|
</ContextMenu.Item>
|
||||||
|
<ContextMenu.Item
|
||||||
|
data-action="project-clear-notifications"
|
||||||
|
data-project={base64Encode(props.project.worktree)}
|
||||||
|
disabled={unseenCount() === 0}
|
||||||
|
onSelect={clear}
|
||||||
|
>
|
||||||
|
<ContextMenu.ItemLabel>{props.language.t("sidebar.project.clearNotifications")}</ContextMenu.ItemLabel>
|
||||||
|
</ContextMenu.Item>
|
||||||
|
<ContextMenu.Separator />
|
||||||
|
<ContextMenu.Item
|
||||||
|
data-action="project-close-menu"
|
||||||
|
data-project={base64Encode(props.project.worktree)}
|
||||||
|
onSelect={() => props.closeProject(props.project.worktree)}
|
||||||
|
>
|
||||||
|
<ContextMenu.ItemLabel>{props.language.t("common.close")}</ContextMenu.ItemLabel>
|
||||||
|
</ContextMenu.Item>
|
||||||
|
</ContextMenu.Content>
|
||||||
|
</ContextMenu.Portal>
|
||||||
|
</ContextMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProjectPreviewPanel = (props: {
|
||||||
|
project: LocalProject
|
||||||
|
mobile?: boolean
|
||||||
|
selected: Accessor<boolean>
|
||||||
|
workspaceEnabled: Accessor<boolean>
|
||||||
|
workspaces: Accessor<string[]>
|
||||||
|
label: (directory: string) => string
|
||||||
|
projectSessions: Accessor<ReturnType<typeof sortedRootSessions>>
|
||||||
|
workspaceSessions: (directory: string) => ReturnType<typeof sortedRootSessions>
|
||||||
|
ctx: ProjectSidebarContext
|
||||||
|
language: ReturnType<typeof useLanguage>
|
||||||
|
}): JSX.Element => (
|
||||||
|
<div class="-m-3 p-2 flex flex-col w-72">
|
||||||
|
<div class="px-4 pt-2 pb-1 flex items-center gap-2">
|
||||||
|
<div class="text-14-medium text-text-strong truncate grow">{displayName(props.project)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="px-4 pb-2 text-12-medium text-text-weak">{props.language.t("sidebar.project.recentSessions")}</div>
|
||||||
|
<div class="px-2 pb-2 flex flex-col gap-2">
|
||||||
|
<Show
|
||||||
|
when={props.workspaceEnabled()}
|
||||||
|
fallback={
|
||||||
|
<For each={props.projectSessions().slice(0, 2)}>
|
||||||
|
{(session) => (
|
||||||
|
<SessionItem
|
||||||
|
{...props.ctx.sessionProps}
|
||||||
|
session={session}
|
||||||
|
list={props.projectSessions()}
|
||||||
|
slug={base64Encode(props.project.worktree)}
|
||||||
|
dense
|
||||||
|
showTooltip
|
||||||
|
mobile={props.mobile}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<For each={props.workspaces()}>
|
||||||
|
{(directory) => {
|
||||||
|
const sessions = createMemo(() => props.workspaceSessions(directory))
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div class="px-2 py-0.5 flex items-center gap-1 min-w-0">
|
||||||
|
<div class="shrink-0 size-6 flex items-center justify-center">
|
||||||
|
<Icon name="branch" size="small" class="text-icon-base" />
|
||||||
|
</div>
|
||||||
|
<span class="truncate text-14-medium text-text-base">{props.label(directory)}</span>
|
||||||
|
</div>
|
||||||
|
<For each={sessions().slice(0, 2)}>
|
||||||
|
{(session) => (
|
||||||
|
<SessionItem
|
||||||
|
{...props.ctx.sessionProps}
|
||||||
|
session={session}
|
||||||
|
list={sessions()}
|
||||||
|
slug={base64Encode(directory)}
|
||||||
|
dense
|
||||||
|
showTooltip
|
||||||
|
mobile={props.mobile}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<div class="px-2 py-2 border-t border-border-weak-base">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent"
|
||||||
|
onClick={() => {
|
||||||
|
props.ctx.openSidebar()
|
||||||
|
props.ctx.onHoverOpenChanged(props.project.worktree, false)
|
||||||
|
if (props.selected()) return
|
||||||
|
props.ctx.navigateToProject(props.project.worktree)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.language.t("sidebar.project.viewAllSessions")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const SortableProject = (props: {
|
||||||
|
project: LocalProject
|
||||||
|
mobile?: boolean
|
||||||
|
ctx: ProjectSidebarContext
|
||||||
|
sortNow: Accessor<number>
|
||||||
|
}): JSX.Element => {
|
||||||
|
const serverSync = useServerSync()
|
||||||
|
const language = useLanguage()
|
||||||
|
const sortable = createSortable(props.project.worktree)
|
||||||
|
const selected = createMemo(() => props.ctx.currentProject()?.worktree === props.project.worktree)
|
||||||
|
const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2))
|
||||||
|
const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project))
|
||||||
|
const dirs = createMemo(() => props.ctx.workspaceIds(props.project))
|
||||||
|
const [state, setState] = createStore({
|
||||||
|
menu: false,
|
||||||
|
suppressHover: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const isHoverProject = () => props.ctx.hoverProject() === props.project.worktree
|
||||||
|
const preview = createMemo(() => !props.mobile && props.ctx.sidebarOpened())
|
||||||
|
const overlay = createMemo(() => !props.mobile && !props.ctx.sidebarOpened())
|
||||||
|
const active = createMemo(() => state.menu || (preview() ? isHoverProject() : overlay() && isHoverProject()))
|
||||||
|
|
||||||
|
const hoverOpen = () => isHoverProject() && preview() && !selected() && !state.menu
|
||||||
|
|
||||||
|
const label = (directory: string) => {
|
||||||
|
const [data] = serverSync().child(directory, { bootstrap: false })
|
||||||
|
const kind =
|
||||||
|
directory === props.project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")
|
||||||
|
const name = props.ctx.workspaceLabel(directory, data.vcs?.branch, props.project.id)
|
||||||
|
return `${kind} : ${name}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectStore = createMemo(() => serverSync().child(props.project.worktree, { bootstrap: false })[0])
|
||||||
|
const isWorking = createMemo(() =>
|
||||||
|
dirs().some((directory) => {
|
||||||
|
return Object.keys(serverSync().session.data.session_status).some((id) => {
|
||||||
|
if (serverSync().session.get(id)?.location.directory !== directory) return false
|
||||||
|
return serverSync().session.data.session_working(id)
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow()))
|
||||||
|
const workspaceSessions = (directory: string) => {
|
||||||
|
const [data] = serverSync().child(directory, { bootstrap: false })
|
||||||
|
return sortedRootSessions(data, props.sortNow())
|
||||||
|
}
|
||||||
|
const tile = () => (
|
||||||
|
<ProjectTile
|
||||||
|
project={props.project}
|
||||||
|
mobile={props.mobile}
|
||||||
|
sidebarHovering={props.ctx.sidebarHovering}
|
||||||
|
selected={selected}
|
||||||
|
active={active}
|
||||||
|
isWorking={isWorking}
|
||||||
|
overlay={overlay}
|
||||||
|
suppressHover={() => state.suppressHover}
|
||||||
|
dirs={dirs}
|
||||||
|
onProjectMouseEnter={props.ctx.onProjectMouseEnter}
|
||||||
|
onProjectMouseLeave={props.ctx.onProjectMouseLeave}
|
||||||
|
onProjectFocus={props.ctx.onProjectFocus}
|
||||||
|
navigateToProject={props.ctx.navigateToProject}
|
||||||
|
showEditProjectDialog={props.ctx.showEditProjectDialog}
|
||||||
|
toggleProjectWorkspaces={props.ctx.toggleProjectWorkspaces}
|
||||||
|
workspacesEnabled={props.ctx.workspacesEnabled}
|
||||||
|
closeProject={props.ctx.closeProject}
|
||||||
|
setMenu={(value) => setState("menu", value)}
|
||||||
|
setOpen={(value) => props.ctx.onHoverOpenChanged(props.project.worktree, value)}
|
||||||
|
setSuppressHover={(value) => setState("suppressHover", value)}
|
||||||
|
language={language}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
// @ts-ignore
|
||||||
|
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
|
||||||
|
<Show when={preview() && !selected()} fallback={tile()}>
|
||||||
|
<HoverCard
|
||||||
|
open={!state.suppressHover && hoverOpen() && !state.menu}
|
||||||
|
openDelay={0}
|
||||||
|
closeDelay={0}
|
||||||
|
placement="right-start"
|
||||||
|
gutter={6}
|
||||||
|
trigger={tile()}
|
||||||
|
onOpenChange={(value) => {
|
||||||
|
if (state.menu) return
|
||||||
|
if (value && state.suppressHover) return
|
||||||
|
props.ctx.onHoverOpenChanged(props.project.worktree, value)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ProjectPreviewPanel
|
||||||
|
project={props.project}
|
||||||
|
mobile={props.mobile}
|
||||||
|
selected={selected}
|
||||||
|
workspaceEnabled={workspaceEnabled}
|
||||||
|
workspaces={workspaces}
|
||||||
|
label={label}
|
||||||
|
projectSessions={projectSessions}
|
||||||
|
workspaceSessions={workspaceSessions}
|
||||||
|
ctx={props.ctx}
|
||||||
|
language={language}
|
||||||
|
/>
|
||||||
|
</HoverCard>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js"
|
||||||
|
import {
|
||||||
|
DragDropProvider,
|
||||||
|
DragDropSensors,
|
||||||
|
DragOverlay,
|
||||||
|
SortableProvider,
|
||||||
|
closestCenter,
|
||||||
|
type DragEvent,
|
||||||
|
} from "@thisbeyond/solid-dnd"
|
||||||
|
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
|
||||||
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
|
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||||
|
import { type LocalProject } from "@/context/layout"
|
||||||
|
|
||||||
|
export const SidebarContent = (props: {
|
||||||
|
mobile?: boolean
|
||||||
|
opened: Accessor<boolean>
|
||||||
|
aimMove: (event: MouseEvent) => void
|
||||||
|
projects: Accessor<LocalProject[]>
|
||||||
|
renderProject: (project: LocalProject) => JSX.Element
|
||||||
|
handleDragStart: (event: unknown) => void
|
||||||
|
handleDragEnd: () => void
|
||||||
|
handleDragOver: (event: DragEvent) => void
|
||||||
|
openProjectLabel: JSX.Element
|
||||||
|
openProjectKeybind: Accessor<string | undefined>
|
||||||
|
onOpenProject: () => void
|
||||||
|
renderProjectOverlay: () => JSX.Element
|
||||||
|
settingsLabel: Accessor<string>
|
||||||
|
settingsKeybind: Accessor<string | undefined>
|
||||||
|
onOpenSettings: () => void
|
||||||
|
helpLabel: Accessor<string>
|
||||||
|
onOpenHelp: () => void
|
||||||
|
renderPanel: () => JSX.Element
|
||||||
|
}): JSX.Element => {
|
||||||
|
const expanded = createMemo(() => !!props.mobile || props.opened())
|
||||||
|
const placement = () => (props.mobile ? "bottom" : "right")
|
||||||
|
let panel: HTMLDivElement | undefined
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const el = panel
|
||||||
|
if (!el) return
|
||||||
|
if (expanded()) {
|
||||||
|
el.removeAttribute("inert")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
el.setAttribute("inert", "")
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex h-full w-full min-w-0 overflow-hidden">
|
||||||
|
<div
|
||||||
|
data-component="sidebar-rail"
|
||||||
|
class="w-16 shrink-0 bg-background-base flex flex-col items-center overflow-hidden"
|
||||||
|
onMouseMove={props.aimMove}
|
||||||
|
>
|
||||||
|
<div class="flex-1 min-h-0 w-full">
|
||||||
|
<DragDropProvider
|
||||||
|
onDragStart={props.handleDragStart}
|
||||||
|
onDragEnd={props.handleDragEnd}
|
||||||
|
onDragOver={props.handleDragOver}
|
||||||
|
collisionDetector={closestCenter}
|
||||||
|
>
|
||||||
|
<DragDropSensors />
|
||||||
|
<ConstrainDragXAxis />
|
||||||
|
<div class="h-full w-full flex flex-col items-center gap-3 px-3 py-3 overflow-y-auto no-scrollbar">
|
||||||
|
<SortableProvider ids={props.projects().map((p) => p.worktree)}>
|
||||||
|
<For each={props.projects()}>{(project) => props.renderProject(project)}</For>
|
||||||
|
</SortableProvider>
|
||||||
|
<Tooltip
|
||||||
|
placement={placement()}
|
||||||
|
value={
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span>{props.openProjectLabel}</span>
|
||||||
|
<Show when={!props.mobile && !!props.openProjectKeybind()}>
|
||||||
|
<span class="text-icon-base text-12-medium">{props.openProjectKeybind()}</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
icon="plus"
|
||||||
|
variant="ghost"
|
||||||
|
size="large"
|
||||||
|
onClick={props.onOpenProject}
|
||||||
|
aria-label={typeof props.openProjectLabel === "string" ? props.openProjectLabel : undefined}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<DragOverlay>{props.renderProjectOverlay()}</DragOverlay>
|
||||||
|
</DragDropProvider>
|
||||||
|
</div>
|
||||||
|
<div class="shrink-0 w-full pt-3 pb-6 flex flex-col items-center gap-2">
|
||||||
|
<TooltipKeybind placement={placement()} title={props.settingsLabel()} keybind={props.settingsKeybind() ?? ""}>
|
||||||
|
<IconButton
|
||||||
|
icon="settings-gear"
|
||||||
|
variant="ghost"
|
||||||
|
size="large"
|
||||||
|
onClick={props.onOpenSettings}
|
||||||
|
aria-label={props.settingsLabel()}
|
||||||
|
/>
|
||||||
|
</TooltipKeybind>
|
||||||
|
<Tooltip placement={placement()} value={props.helpLabel()}>
|
||||||
|
<IconButton
|
||||||
|
icon="help"
|
||||||
|
variant="ghost"
|
||||||
|
size="large"
|
||||||
|
onClick={props.onOpenHelp}
|
||||||
|
aria-label={props.helpLabel()}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={(el) => {
|
||||||
|
panel = el
|
||||||
|
}}
|
||||||
|
classList={{ "flex-1 flex h-full min-h-0 min-w-0 overflow-hidden": true, "pointer-events-none": !expanded() }}
|
||||||
|
aria-hidden={!expanded()}
|
||||||
|
>
|
||||||
|
{props.renderPanel()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,489 @@
|
|||||||
|
import { useNavigate, useParams } from "@solidjs/router"
|
||||||
|
import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { createSortable } from "@thisbeyond/solid-dnd"
|
||||||
|
import { createMediaQuery } from "@solid-primitives/media"
|
||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
import { Collapsible } from "@opencode-ai/ui/collapsible"
|
||||||
|
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
|
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||||
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
|
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||||
|
import { type LocalProject } from "@/context/layout"
|
||||||
|
import { useServerSync, useQueryOptions } from "@/context/server-sync"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { pathKey } from "@/utils/path-key"
|
||||||
|
import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items"
|
||||||
|
import { sortedRootSessions } from "./helpers"
|
||||||
|
import { useIsFetching } from "@tanstack/solid-query"
|
||||||
|
|
||||||
|
type InlineEditorComponent = (props: {
|
||||||
|
id: string
|
||||||
|
value: Accessor<string>
|
||||||
|
onSave: (next: string) => void
|
||||||
|
class?: string
|
||||||
|
displayClass?: string
|
||||||
|
editing?: boolean
|
||||||
|
stopPropagation?: boolean
|
||||||
|
openOnDblClick?: boolean
|
||||||
|
}) => JSX.Element
|
||||||
|
|
||||||
|
export type WorkspaceSidebarContext = {
|
||||||
|
currentDir: Accessor<string>
|
||||||
|
navList: Accessor<SessionInfo[]>
|
||||||
|
sidebarExpanded: Accessor<boolean>
|
||||||
|
sidebarHovering: Accessor<boolean>
|
||||||
|
clearHoverProjectSoon: () => void
|
||||||
|
prefetchSession: (session: SessionInfo, priority?: "high" | "low") => void
|
||||||
|
archiveSession: (session: SessionInfo) => Promise<void>
|
||||||
|
workspaceName: (directory: string, projectId?: string, branch?: string) => string | undefined
|
||||||
|
renameWorkspace: (directory: string, next: string, projectId?: string, branch?: string) => void
|
||||||
|
editorOpen: (id: string) => boolean
|
||||||
|
openEditor: (id: string, value: string) => void
|
||||||
|
closeEditor: () => void
|
||||||
|
setEditor: (key: "value", value: string) => void
|
||||||
|
InlineEditor: InlineEditorComponent
|
||||||
|
isBusy: (directory: string) => boolean
|
||||||
|
workspaceExpanded: (directory: string, local: boolean) => boolean
|
||||||
|
setWorkspaceExpanded: (directory: string, value: boolean) => void
|
||||||
|
showResetWorkspaceDialog: (root: string, directory: string) => void
|
||||||
|
showDeleteWorkspaceDialog: (root: string, directory: string) => void
|
||||||
|
setScrollContainerRef: (el: HTMLDivElement | undefined, mobile?: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WorkspaceDragOverlay = (props: {
|
||||||
|
sidebarProject: Accessor<LocalProject | undefined>
|
||||||
|
activeWorkspace: Accessor<string | undefined>
|
||||||
|
workspaceLabel: (directory: string, branch?: string, projectId?: string) => string
|
||||||
|
}): JSX.Element => {
|
||||||
|
const serverSync = useServerSync()
|
||||||
|
const language = useLanguage()
|
||||||
|
const label = createMemo(() => {
|
||||||
|
const project = props.sidebarProject()
|
||||||
|
if (!project) return
|
||||||
|
const directory = props.activeWorkspace()
|
||||||
|
if (!directory) return
|
||||||
|
|
||||||
|
const [workspaceStore] = serverSync().child(directory, { bootstrap: false })
|
||||||
|
const kind =
|
||||||
|
directory === project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")
|
||||||
|
const name = props.workspaceLabel(directory, workspaceStore.vcs?.branch, project.id)
|
||||||
|
return `${kind} : ${name}`
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={label()}>
|
||||||
|
{(value) => <div class="bg-background-base rounded-md px-2 py-1 text-14-medium text-text-strong">{value()}</div>}
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const WorkspaceHeader = (props: {
|
||||||
|
local: Accessor<boolean>
|
||||||
|
busy: Accessor<boolean>
|
||||||
|
open: Accessor<boolean>
|
||||||
|
directory: string
|
||||||
|
language: ReturnType<typeof useLanguage>
|
||||||
|
branch: Accessor<string | undefined>
|
||||||
|
workspaceValue: Accessor<string>
|
||||||
|
workspaceEditActive: Accessor<boolean>
|
||||||
|
InlineEditor: WorkspaceSidebarContext["InlineEditor"]
|
||||||
|
renameWorkspace: WorkspaceSidebarContext["renameWorkspace"]
|
||||||
|
setEditor: WorkspaceSidebarContext["setEditor"]
|
||||||
|
projectId?: string
|
||||||
|
}): JSX.Element => (
|
||||||
|
<div class="flex items-center gap-1 min-w-0 flex-1">
|
||||||
|
<div class="flex items-center justify-center shrink-0 size-6">
|
||||||
|
<Show when={props.busy()} fallback={<Icon name="branch" size="small" />}>
|
||||||
|
<Spinner class="size-[15px]" />
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<span class="text-14-medium text-text-base shrink-0">
|
||||||
|
{props.local() ? props.language.t("workspace.type.local") : props.language.t("workspace.type.sandbox")} :
|
||||||
|
</span>
|
||||||
|
<Show
|
||||||
|
when={!props.local()}
|
||||||
|
fallback={
|
||||||
|
<span class="text-14-medium text-text-base min-w-0 truncate">
|
||||||
|
{props.branch() ?? getFilename(props.directory)}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<props.InlineEditor
|
||||||
|
id={`workspace:${props.directory}`}
|
||||||
|
value={props.workspaceValue}
|
||||||
|
onSave={(next) => {
|
||||||
|
const trimmed = next.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
props.renameWorkspace(props.directory, trimmed, props.projectId, props.branch())
|
||||||
|
props.setEditor("value", props.workspaceValue())
|
||||||
|
}}
|
||||||
|
class="text-14-medium text-text-base min-w-0 truncate"
|
||||||
|
displayClass="text-14-medium text-text-base min-w-0 truncate"
|
||||||
|
editing={props.workspaceEditActive()}
|
||||||
|
stopPropagation={false}
|
||||||
|
openOnDblClick={false}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
<div class="flex items-center justify-center shrink-0 overflow-hidden w-0 opacity-0 transition-all duration-200 group-hover/workspace:w-3.5 group-hover/workspace:opacity-100 group-focus-within/workspace:w-3.5 group-focus-within/workspace:opacity-100">
|
||||||
|
<Icon name={props.open() ? "chevron-down" : "chevron-right"} size="small" class="text-icon-base" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const WorkspaceActions = (props: {
|
||||||
|
directory: string
|
||||||
|
local: Accessor<boolean>
|
||||||
|
busy: Accessor<boolean>
|
||||||
|
menuOpen: Accessor<boolean>
|
||||||
|
pendingRename: Accessor<boolean>
|
||||||
|
setMenuOpen: (open: boolean) => void
|
||||||
|
setPendingRename: (value: boolean) => void
|
||||||
|
sidebarHovering: Accessor<boolean>
|
||||||
|
touch: Accessor<boolean>
|
||||||
|
language: ReturnType<typeof useLanguage>
|
||||||
|
workspaceValue: Accessor<string>
|
||||||
|
openEditor: WorkspaceSidebarContext["openEditor"]
|
||||||
|
showResetWorkspaceDialog: WorkspaceSidebarContext["showResetWorkspaceDialog"]
|
||||||
|
showDeleteWorkspaceDialog: WorkspaceSidebarContext["showDeleteWorkspaceDialog"]
|
||||||
|
root: string
|
||||||
|
clearHoverProjectSoon: WorkspaceSidebarContext["clearHoverProjectSoon"]
|
||||||
|
navigateToNewSession: () => void
|
||||||
|
}): JSX.Element => (
|
||||||
|
<div
|
||||||
|
class="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 transition-opacity"
|
||||||
|
classList={{
|
||||||
|
"opacity-100 pointer-events-auto": props.menuOpen(),
|
||||||
|
"opacity-0 pointer-events-none": !props.menuOpen(),
|
||||||
|
"group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto": true,
|
||||||
|
"group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto": true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu
|
||||||
|
modal={!props.sidebarHovering()}
|
||||||
|
open={props.menuOpen()}
|
||||||
|
onOpenChange={(open) => props.setMenuOpen(open)}
|
||||||
|
>
|
||||||
|
<Tooltip value={props.language.t("common.moreOptions")} placement="top">
|
||||||
|
<DropdownMenu.Trigger
|
||||||
|
as={IconButton}
|
||||||
|
icon="dot-grid"
|
||||||
|
variant="ghost"
|
||||||
|
class="size-6 rounded-md"
|
||||||
|
data-action="workspace-menu"
|
||||||
|
data-workspace={base64Encode(props.directory)}
|
||||||
|
aria-label={props.language.t("common.moreOptions")}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<DropdownMenu.Portal>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
onCloseAutoFocus={(event) => {
|
||||||
|
if (!props.pendingRename()) return
|
||||||
|
event.preventDefault()
|
||||||
|
props.setPendingRename(false)
|
||||||
|
props.openEditor(`workspace:${props.directory}`, props.workspaceValue())
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
disabled={props.local()}
|
||||||
|
onSelect={() => {
|
||||||
|
props.setPendingRename(true)
|
||||||
|
props.setMenuOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemLabel>{props.language.t("common.rename")}</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
// TODO: Restore reset when V2 exposes project-copy reset and instance disposal.
|
||||||
|
// onSelect={() => props.showResetWorkspaceDialog(props.root, props.directory)}
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemLabel>{props.language.t("common.reset")}</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
disabled={props.local() || props.busy()}
|
||||||
|
onSelect={() => props.showDeleteWorkspaceDialog(props.root, props.directory)}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemLabel>{props.language.t("common.delete")}</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Portal>
|
||||||
|
</DropdownMenu>
|
||||||
|
<Show when={!props.touch()}>
|
||||||
|
<Tooltip value={props.language.t("command.session.new")} placement="top">
|
||||||
|
<IconButtonV2
|
||||||
|
icon={<IconV2 name="edit" size="small" />}
|
||||||
|
variant="ghost"
|
||||||
|
size="small"
|
||||||
|
class="size-6 rounded-md opacity-0 pointer-events-none group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto"
|
||||||
|
data-action="workspace-new-session"
|
||||||
|
data-workspace={base64Encode(props.directory)}
|
||||||
|
aria-label={props.language.t("command.session.new")}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
props.clearHoverProjectSoon()
|
||||||
|
props.navigateToNewSession()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const WorkspaceSessionList = (props: {
|
||||||
|
slug: Accessor<string>
|
||||||
|
mobile?: boolean
|
||||||
|
ctx: WorkspaceSidebarContext
|
||||||
|
showNew: Accessor<boolean>
|
||||||
|
loading: Accessor<boolean>
|
||||||
|
sessions: Accessor<SessionInfo[]>
|
||||||
|
hasMore: Accessor<boolean>
|
||||||
|
loadMore: () => Promise<void>
|
||||||
|
language: ReturnType<typeof useLanguage>
|
||||||
|
}): JSX.Element => (
|
||||||
|
<nav class="flex flex-col gap-1">
|
||||||
|
<Show when={props.showNew()}>
|
||||||
|
<NewSessionItem
|
||||||
|
slug={props.slug()}
|
||||||
|
mobile={props.mobile}
|
||||||
|
sidebarExpanded={props.ctx.sidebarExpanded}
|
||||||
|
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
<Show when={props.loading()}>
|
||||||
|
<SessionSkeleton />
|
||||||
|
</Show>
|
||||||
|
<For each={props.sessions()}>
|
||||||
|
{(session) => (
|
||||||
|
<SessionItem
|
||||||
|
session={session}
|
||||||
|
list={props.sessions()}
|
||||||
|
navList={props.ctx.navList}
|
||||||
|
slug={props.slug()}
|
||||||
|
mobile={props.mobile}
|
||||||
|
showChild
|
||||||
|
sidebarExpanded={props.ctx.sidebarExpanded}
|
||||||
|
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
|
||||||
|
prefetchSession={props.ctx.prefetchSession}
|
||||||
|
archiveSession={props.ctx.archiveSession}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
<Show when={props.hasMore()}>
|
||||||
|
<div class="relative w-full py-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
class="flex w-full text-left justify-start text-14-regular text-text-weak pl-2 pr-10"
|
||||||
|
size="large"
|
||||||
|
onClick={(e: MouseEvent) => {
|
||||||
|
void props.loadMore()
|
||||||
|
;(e.currentTarget as HTMLButtonElement).blur()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.language.t("common.loadMore")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const SortableWorkspace = (props: {
|
||||||
|
ctx: WorkspaceSidebarContext
|
||||||
|
directory: string
|
||||||
|
project: LocalProject
|
||||||
|
sortNow: Accessor<number>
|
||||||
|
mobile?: boolean
|
||||||
|
}): JSX.Element => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const params = useParams()
|
||||||
|
const serverSync = useServerSync()
|
||||||
|
const queryOptions = useQueryOptions()
|
||||||
|
const language = useLanguage()
|
||||||
|
const sortable = createSortable(props.directory)
|
||||||
|
const [workspaceStore, setWorkspaceStore] = serverSync().child(props.directory, { bootstrap: false })
|
||||||
|
const [menu, setMenu] = createStore({
|
||||||
|
open: false,
|
||||||
|
pendingRename: false,
|
||||||
|
})
|
||||||
|
const slug = createMemo(() => base64Encode(props.directory))
|
||||||
|
const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow()))
|
||||||
|
const local = createMemo(() => props.directory === props.project.worktree)
|
||||||
|
const active = createMemo(() => pathKey(props.ctx.currentDir()) === pathKey(props.directory))
|
||||||
|
const workspaceValue = createMemo(() => {
|
||||||
|
const branch = workspaceStore.vcs?.branch
|
||||||
|
const name = branch ?? getFilename(props.directory)
|
||||||
|
return props.ctx.workspaceName(props.directory, props.project.id, branch) ?? name
|
||||||
|
})
|
||||||
|
const open = createMemo(() => props.ctx.workspaceExpanded(props.directory, local()))
|
||||||
|
const boot = createMemo(() => open() || active())
|
||||||
|
const count = createMemo(() => sessions()?.length ?? 0)
|
||||||
|
const hasMore = createMemo(() => workspaceStore.sessionTotal > count())
|
||||||
|
const fetching = useIsFetching(() => queryOptions().sessions(pathKey(props.directory)))
|
||||||
|
const busy = createMemo(() => props.ctx.isBusy(props.directory))
|
||||||
|
const loading = () => fetching() > 0 && count() === 0
|
||||||
|
const touch = createMediaQuery("(hover: none)")
|
||||||
|
const showNew = createMemo(() => !loading() && (touch() || count() === 0 || (active() && !params.id)))
|
||||||
|
const loadMore = async () => {
|
||||||
|
setWorkspaceStore("limit", (limit) => (limit ?? 0) + 5)
|
||||||
|
await serverSync().project.loadSessions(props.directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceEditActive = createMemo(() => props.ctx.editorOpen(`workspace:${props.directory}`))
|
||||||
|
const header = () => (
|
||||||
|
<WorkspaceHeader
|
||||||
|
local={local}
|
||||||
|
busy={busy}
|
||||||
|
open={open}
|
||||||
|
directory={props.directory}
|
||||||
|
language={language}
|
||||||
|
branch={() => workspaceStore.vcs?.branch}
|
||||||
|
workspaceValue={workspaceValue}
|
||||||
|
workspaceEditActive={workspaceEditActive}
|
||||||
|
InlineEditor={props.ctx.InlineEditor}
|
||||||
|
renameWorkspace={props.ctx.renameWorkspace}
|
||||||
|
setEditor={props.ctx.setEditor}
|
||||||
|
projectId={props.project.id}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
const openWrapper = (value: boolean) => {
|
||||||
|
props.ctx.setWorkspaceExpanded(props.directory, value)
|
||||||
|
if (value) return
|
||||||
|
if (props.ctx.editorOpen(`workspace:${props.directory}`)) props.ctx.closeEditor()
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!boot()) return
|
||||||
|
serverSync().child(props.directory, { bootstrap: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
// @ts-ignore
|
||||||
|
use:sortable
|
||||||
|
classList={{
|
||||||
|
"opacity-30": sortable.isActiveDraggable,
|
||||||
|
"opacity-50 pointer-events-none": busy(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Collapsible variant="ghost" open={open()} class="shrink-0" onOpenChange={openWrapper}>
|
||||||
|
<div class="py-1">
|
||||||
|
<div
|
||||||
|
class="group/workspace relative"
|
||||||
|
data-component="workspace-item"
|
||||||
|
data-workspace={base64Encode(props.directory)}
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<Show
|
||||||
|
when={workspaceEditActive()}
|
||||||
|
fallback={
|
||||||
|
<Collapsible.Trigger
|
||||||
|
class={`flex items-center justify-between w-full pl-2 py-1.5 rounded-md hover:bg-surface-raised-base-hover transition-[padding] duration-200 ${
|
||||||
|
menu.open ? "pr-16" : "pr-2"
|
||||||
|
} group-hover/workspace:pr-16 group-focus-within/workspace:pr-16`}
|
||||||
|
data-action="workspace-toggle"
|
||||||
|
data-workspace={base64Encode(props.directory)}
|
||||||
|
>
|
||||||
|
{header()}
|
||||||
|
</Collapsible.Trigger>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class={`flex items-center justify-between w-full pl-2 py-1.5 rounded-md transition-[padding] duration-200 ${
|
||||||
|
menu.open ? "pr-16" : "pr-2"
|
||||||
|
} group-hover/workspace:pr-16 group-focus-within/workspace:pr-16`}
|
||||||
|
>
|
||||||
|
{header()}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<WorkspaceActions
|
||||||
|
directory={props.directory}
|
||||||
|
local={local}
|
||||||
|
busy={busy}
|
||||||
|
menuOpen={() => menu.open}
|
||||||
|
pendingRename={() => menu.pendingRename}
|
||||||
|
setMenuOpen={(open) => setMenu("open", open)}
|
||||||
|
setPendingRename={(value) => setMenu("pendingRename", value)}
|
||||||
|
sidebarHovering={props.ctx.sidebarHovering}
|
||||||
|
touch={touch}
|
||||||
|
language={language}
|
||||||
|
workspaceValue={workspaceValue}
|
||||||
|
openEditor={props.ctx.openEditor}
|
||||||
|
showResetWorkspaceDialog={props.ctx.showResetWorkspaceDialog}
|
||||||
|
showDeleteWorkspaceDialog={props.ctx.showDeleteWorkspaceDialog}
|
||||||
|
root={props.project.worktree}
|
||||||
|
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
|
||||||
|
navigateToNewSession={() => navigate(`/${slug()}/session`)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Collapsible.Content>
|
||||||
|
<WorkspaceSessionList
|
||||||
|
slug={slug}
|
||||||
|
mobile={props.mobile}
|
||||||
|
ctx={props.ctx}
|
||||||
|
showNew={showNew}
|
||||||
|
loading={loading}
|
||||||
|
sessions={sessions}
|
||||||
|
hasMore={hasMore}
|
||||||
|
loadMore={loadMore}
|
||||||
|
language={language}
|
||||||
|
/>
|
||||||
|
</Collapsible.Content>
|
||||||
|
</Collapsible>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LocalWorkspace = (props: {
|
||||||
|
ctx: WorkspaceSidebarContext
|
||||||
|
project: LocalProject
|
||||||
|
sortNow: Accessor<number>
|
||||||
|
mobile?: boolean
|
||||||
|
}): JSX.Element => {
|
||||||
|
const serverSync = useServerSync()
|
||||||
|
const queryOptions = useQueryOptions()
|
||||||
|
const language = useLanguage()
|
||||||
|
const workspace = createMemo(() => {
|
||||||
|
const [store, setStore] = serverSync().child(props.project.worktree)
|
||||||
|
return { store, setStore }
|
||||||
|
})
|
||||||
|
const slug = createMemo(() => base64Encode(props.project.worktree))
|
||||||
|
const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow()))
|
||||||
|
const count = createMemo(() => sessions()?.length ?? 0)
|
||||||
|
const fetching = useIsFetching(() => queryOptions().sessions(pathKey(props.project.worktree)))
|
||||||
|
const hasMore = createMemo(() => workspace().store.sessionTotal > count())
|
||||||
|
const loading = () => fetching() > 0 && count() === 0
|
||||||
|
const loadMore = async () => {
|
||||||
|
workspace().setStore("limit", (limit) => (limit ?? 0) + 5)
|
||||||
|
await serverSync().project.loadSessions(props.project.worktree)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={(el) => props.ctx.setScrollContainerRef(el, props.mobile)}
|
||||||
|
class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar [overflow-anchor:none]"
|
||||||
|
>
|
||||||
|
<WorkspaceSessionList
|
||||||
|
slug={slug}
|
||||||
|
mobile={props.mobile}
|
||||||
|
ctx={props.ctx}
|
||||||
|
showNew={() => false}
|
||||||
|
loading={loading}
|
||||||
|
sessions={sessions}
|
||||||
|
hasMore={hasMore}
|
||||||
|
loadMore={loadMore}
|
||||||
|
language={language}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { For } from "solid-js"
|
|||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { DockShell } from "@opencode-ai/ui/dock-surface"
|
import { DockShell } from "@opencode-ai/ui/dock-surface"
|
||||||
import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock"
|
import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock"
|
||||||
import { SettingsProvider } from "@/context/settings"
|
import { SettingsProvider, useSettings } from "@/context/settings"
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: "Composer/Revert Dock",
|
title: "Composer/Revert Dock",
|
||||||
@@ -21,6 +21,9 @@ Real \`SessionRevertDock\` from app code, rendered above a mock composer card.
|
|||||||
The live composer overlaps the dock's bottom by 18px (\`session-composer-region-controller.ts\` \`lift()\`).
|
The live composer overlaps the dock's bottom by 18px (\`session-composer-region-controller.ts\` \`lift()\`).
|
||||||
The card below reproduces that overlap so the collapsed/expanded cutoff behavior can be verified in isolation.
|
The card below reproduces that overlap so the collapsed/expanded cutoff behavior can be verified in isolation.
|
||||||
|
|
||||||
|
### Layout split
|
||||||
|
Use the **Layout** button to toggle \`newLayoutDesigns\` and preview both the v2 dock and the legacy (v1) \`DockTray\` fallback.
|
||||||
|
|
||||||
### Notes
|
### Notes
|
||||||
- \`onRestore\` only mutates local story state, so nothing in the real session is affected.
|
- \`onRestore\` only mutates local story state, so nothing in the real session is affected.
|
||||||
- Click the header to expand/collapse. Click "Restore message" to remove a row.`,
|
- Click the header to expand/collapse. Click "Restore message" to remove a row.`,
|
||||||
@@ -50,9 +53,11 @@ const btn = (accent?: boolean) =>
|
|||||||
}) as const
|
}) as const
|
||||||
|
|
||||||
function Stage(props: { count: number }) {
|
function Stage(props: { count: number }) {
|
||||||
|
const settings = useSettings()
|
||||||
const seed = () => messages.slice(0, props.count).map((text, index) => ({ id: `rolled-${index}`, text }))
|
const seed = () => messages.slice(0, props.count).map((text, index) => ({ id: `rolled-${index}`, text }))
|
||||||
const [store, setStore] = createStore({ items: seed() })
|
const [store, setStore] = createStore({ items: seed() })
|
||||||
|
|
||||||
|
const v2 = () => settings.general.newLayoutDesigns()
|
||||||
const reset = () => setStore("items", seed())
|
const reset = () => setStore("items", seed())
|
||||||
const restore = (id: string) =>
|
const restore = (id: string) =>
|
||||||
setStore(
|
setStore(
|
||||||
@@ -66,15 +71,22 @@ function Stage(props: { count: number }) {
|
|||||||
<button style={btn()} onClick={reset}>
|
<button style={btn()} onClick={reset}>
|
||||||
Reset ({props.count})
|
Reset ({props.count})
|
||||||
</button>
|
</button>
|
||||||
|
<button style={btn(v2())} onClick={() => settings.general.setNewLayoutDesigns(!v2())}>
|
||||||
|
Layout: {v2() ? "v2" : "v1"}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Reproduce the real composer stack: dock + card overlapping the dock's bottom by lift() = 18px */}
|
{/* Reproduce the real composer stack: dock + card overlapping the dock's bottom by lift() = 18px */}
|
||||||
<div style={{ display: "flex", "flex-direction": "column" }}>
|
<div style={{ display: "flex", "flex-direction": "column" }}>
|
||||||
<SessionRevertDock items={store.items} onRestore={restore} />
|
<SessionRevertDock items={store.items} onRestore={restore} />
|
||||||
<DockShell
|
<DockShell
|
||||||
data-dock-border-underlay="v2"
|
data-dock-border-underlay={v2() ? "v2" : "legacy"}
|
||||||
style={{ position: "relative", "z-index": 70, "margin-top": "-18px" }}
|
style={{ position: "relative", "z-index": 70, "margin-top": "-18px" }}
|
||||||
class="min-h-24 w-full rounded-[12px] bg-v2-background-bg-base px-4 py-3 text-[13px] text-v2-text-text-faint"
|
classList={{
|
||||||
|
"min-h-24 w-full rounded-[12px] px-4 py-3 text-[13px]": true,
|
||||||
|
"bg-v2-background-bg-base text-v2-text-text-faint": v2(),
|
||||||
|
"text-text-weak": !v2(),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Ask anything...
|
Ask anything...
|
||||||
</DockShell>
|
</DockShell>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
|||||||
import { useMutation } from "@tanstack/solid-query"
|
import { useMutation } from "@tanstack/solid-query"
|
||||||
import fuzzysort from "fuzzysort"
|
import fuzzysort from "fuzzysort"
|
||||||
import { type Accessor, For, Show, createMemo } from "solid-js"
|
import { type Accessor, For, Show, createMemo } from "solid-js"
|
||||||
import type { ServerCollectionController } from "@/components/server/server-management-controller"
|
import type { useServerManagementController } from "@/components/dialog-select-server"
|
||||||
import { ServerHealthIndicator } from "@/components/server/server-row"
|
import { ServerHealthIndicator } 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"
|
||||||
@@ -17,6 +17,8 @@ import { DialogAddWslServer } from "./dialog-add-server"
|
|||||||
import { useWslServers } from "./context"
|
import { useWslServers } from "./context"
|
||||||
import { wslOpencodeAction, wslRuntimeRetryable } from "./settings-model"
|
import { wslOpencodeAction, wslRuntimeRetryable } from "./settings-model"
|
||||||
|
|
||||||
|
type Controller = ReturnType<typeof useServerManagementController>
|
||||||
|
|
||||||
export function isWslServer(server: ServerConnection.Any) {
|
export function isWslServer(server: ServerConnection.Any) {
|
||||||
return server.type === "sidecar" && server.variant === "wsl"
|
return server.type === "sidecar" && server.variant === "wsl"
|
||||||
}
|
}
|
||||||
@@ -26,7 +28,7 @@ export function AddServerMenu(props: { onAddServer: () => void }) {
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const openAddWsl = () => {
|
const openAddWsl = () => {
|
||||||
void dialog.push(() => <DialogAddWslServer />)
|
dialog.push(() => <DialogAddWslServer />)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Show
|
<Show
|
||||||
@@ -65,7 +67,7 @@ export function useFilteredWslServers(filter: Accessor<string>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function WslServerSettings(props: {
|
export function WslServerSettings(props: {
|
||||||
domain: Pick<ServerCollectionController, "collection" | "defaults" | "connection">
|
controller: Controller
|
||||||
servers: ReturnType<typeof useFilteredWslServers>
|
servers: ReturnType<typeof useFilteredWslServers>
|
||||||
}) {
|
}) {
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
@@ -84,7 +86,7 @@ export function WslServerSettings(props: {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const remove = (key: ServerConnection.Key) => {
|
const remove = (key: ServerConnection.Key) => {
|
||||||
request.mutate(() => props.domain.connection.remove(key))
|
request.mutate(() => props.controller.handleRemove(key))
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -98,7 +100,7 @@ export function WslServerSettings(props: {
|
|||||||
return (
|
return (
|
||||||
<div class="settings-v2-servers-row">
|
<div class="settings-v2-servers-row">
|
||||||
<div class="settings-v2-servers-lead">
|
<div class="settings-v2-servers-lead">
|
||||||
<ServerHealthIndicator health={props.domain.collection.health()[key]} />
|
<ServerHealthIndicator health={props.controller.status()[key]} />
|
||||||
<div class="settings-v2-servers-copy">
|
<div class="settings-v2-servers-copy">
|
||||||
<span class="flex min-w-0 items-center gap-1">
|
<span class="flex min-w-0 items-center gap-1">
|
||||||
<span class="settings-v2-servers-name">{item.config.distro}</span>
|
<span class="settings-v2-servers-name">{item.config.distro}</span>
|
||||||
@@ -112,7 +114,7 @@ export function WslServerSettings(props: {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-v2-servers-actions">
|
<div class="settings-v2-servers-actions">
|
||||||
<Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
|
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}>
|
||||||
<Tag>{language.t("dialog.server.status.default")}</Tag>
|
<Tag>{language.t("dialog.server.status.default")}</Tag>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={opencodeAction()}>
|
<Show when={opencodeAction()}>
|
||||||
@@ -143,13 +145,13 @@ export function WslServerSettings(props: {
|
|||||||
{language.t("wsl.server.retryStart")}
|
{language.t("wsl.server.retryStart")}
|
||||||
</MenuV2.Item>
|
</MenuV2.Item>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.domain.defaults.available() && props.domain.defaults.key() !== key}>
|
<Show when={props.controller.canDefault() && props.controller.defaultKey() !== key}>
|
||||||
<MenuV2.Item onSelect={() => props.domain.defaults.set(key)}>
|
<MenuV2.Item onSelect={() => props.controller.setDefault(key)}>
|
||||||
{language.t("dialog.server.menu.default")}
|
{language.t("dialog.server.menu.default")}
|
||||||
</MenuV2.Item>
|
</MenuV2.Item>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
|
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}>
|
||||||
<MenuV2.Item onSelect={() => props.domain.defaults.set(null)}>
|
<MenuV2.Item onSelect={() => props.controller.setDefault(null)}>
|
||||||
{language.t("dialog.server.menu.defaultRemove")}
|
{language.t("dialog.server.menu.defaultRemove")}
|
||||||
</MenuV2.Item>
|
</MenuV2.Item>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -140,32 +140,6 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
|||||||
description: "List all available models",
|
description: "List all available models",
|
||||||
params: ServerParams,
|
params: ServerParams,
|
||||||
}),
|
}),
|
||||||
Spec.make("export", {
|
|
||||||
description: "Export session data as JSON",
|
|
||||||
params: {
|
|
||||||
...ServerParams,
|
|
||||||
session: Flag.string("session").pipe(
|
|
||||||
Flag.withAlias("s"),
|
|
||||||
Flag.withDescription("Session ID to export to stdout"),
|
|
||||||
Flag.optional,
|
|
||||||
),
|
|
||||||
sanitize: Flag.boolean("sanitize").pipe(
|
|
||||||
Flag.withDescription("Redact sensitive transcript and file data"),
|
|
||||||
Flag.withDefault(false),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
Spec.make("import", {
|
|
||||||
description: "Import session data from a JSON file or URL",
|
|
||||||
params: {
|
|
||||||
...ServerParams,
|
|
||||||
file: Argument.string("file").pipe(Argument.withDescription("JSON file or URL to import")),
|
|
||||||
directory: Flag.string("directory").pipe(
|
|
||||||
Flag.withDescription("Directory in which to import the session"),
|
|
||||||
Flag.optional,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
Spec.make("mini", {
|
Spec.make("mini", {
|
||||||
description: "Start the minimal interactive interface",
|
description: "Start the minimal interactive interface",
|
||||||
params: {
|
params: {
|
||||||
|
|||||||
@@ -1,140 +0,0 @@
|
|||||||
import { OpenCode, type SessionInfo } from "@opencode-ai/client"
|
|
||||||
import { Service } from "@opencode-ai/client/effect/service"
|
|
||||||
import { Effect, Option } from "effect"
|
|
||||||
import { EOL, tmpdir } from "node:os"
|
|
||||||
import path from "node:path"
|
|
||||||
import { emitKeypressEvents, type Key } from "node:readline"
|
|
||||||
import { Commands } from "../commands"
|
|
||||||
import { Runtime } from "../../framework/runtime"
|
|
||||||
import { ServerConnection } from "../../services/server-connection"
|
|
||||||
|
|
||||||
export default Runtime.handler(
|
|
||||||
Commands.commands.export,
|
|
||||||
Effect.fn("cli.export")(function* (input) {
|
|
||||||
const server = yield* ServerConnection.resolve({
|
|
||||||
server: Option.getOrUndefined(input.server),
|
|
||||||
standalone: input.standalone,
|
|
||||||
})
|
|
||||||
const client = OpenCode.make({
|
|
||||||
baseUrl: server.endpoint.url,
|
|
||||||
headers: Service.headers(server.endpoint),
|
|
||||||
})
|
|
||||||
const requested = Option.getOrUndefined(input.session)
|
|
||||||
const selected = requested
|
|
||||||
? undefined
|
|
||||||
: yield* Effect.promise(async () => {
|
|
||||||
const location = await client.location.get({ location: { directory: process.cwd() } })
|
|
||||||
const page = await client.session.list({
|
|
||||||
directory: location.directory,
|
|
||||||
workspace: location.workspaceID,
|
|
||||||
parentID: null,
|
|
||||||
order: "desc",
|
|
||||||
limit: 50,
|
|
||||||
})
|
|
||||||
if (page.data.length === 0) {
|
|
||||||
process.stderr.write(`No sessions found${EOL}`)
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
return selectSession(page.data, input.sanitize)
|
|
||||||
})
|
|
||||||
const sessionID = requested ?? selected?.session.id
|
|
||||||
if (!sessionID) return
|
|
||||||
const data = yield* Effect.promise(() =>
|
|
||||||
client.session.export({ sessionID, sanitize: selected?.sanitize ?? input.sanitize }),
|
|
||||||
)
|
|
||||||
process.stdout.write(yield* Effect.promise(() => writeExport(data, sessionID, requested !== undefined)))
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
type Selection = { session: SessionInfo; sanitize: boolean }
|
|
||||||
|
|
||||||
function selectSession(sessions: SessionInfo[], initialSanitize: boolean) {
|
|
||||||
if (!process.stdin.isTTY) return Promise.reject(new Error("Session ID is required when stdin is not interactive"))
|
|
||||||
const input = process.stdin
|
|
||||||
const output = process.stderr
|
|
||||||
const wasRaw = input.isRaw
|
|
||||||
const wasPaused = input.isPaused()
|
|
||||||
const date = new Intl.DateTimeFormat(undefined, {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
hour: "numeric",
|
|
||||||
minute: "2-digit",
|
|
||||||
})
|
|
||||||
const columns = output.columns ?? 100
|
|
||||||
const titleWidth = Math.max(8, Math.min(48, columns - 34))
|
|
||||||
let selected = 0
|
|
||||||
let offset = 0
|
|
||||||
let sanitize = initialSanitize
|
|
||||||
let height = 0
|
|
||||||
|
|
||||||
const render = () => {
|
|
||||||
const visible = sessions.slice(offset, offset + 10)
|
|
||||||
const lines = [" \x1b[36mExport session\x1b[0m", ""]
|
|
||||||
lines.push(
|
|
||||||
...visible.map((session) => {
|
|
||||||
const index = sessions.indexOf(session)
|
|
||||||
const title = (session.title ?? "Untitled session").slice(0, titleWidth).padEnd(titleWidth)
|
|
||||||
const updated = date.format(session.time.updated).slice(0, 18).padEnd(18)
|
|
||||||
const row = `${index === selected ? ">" : " "} ${title} ${updated} ${session.id.slice(-8)}`
|
|
||||||
return index === selected ? `\x1b[1m${row}\x1b[0m` : row
|
|
||||||
}),
|
|
||||||
"",
|
|
||||||
` [${sanitize ? "x" : " "}] sanitize sensitive data`,
|
|
||||||
"",
|
|
||||||
" navigate \x1b[2mup/down\x1b[0m sanitize \x1b[2mspace\x1b[0m export \x1b[2menter\x1b[0m cancel \x1b[2mesc\x1b[0m",
|
|
||||||
)
|
|
||||||
if (height > 0) output.write(`\x1b[${height}F\x1b[J`)
|
|
||||||
output.write(lines.join(EOL) + EOL)
|
|
||||||
height = lines.length
|
|
||||||
}
|
|
||||||
const clear = () => {
|
|
||||||
if (height > 0) output.write(`\x1b[${height}F\x1b[J`)
|
|
||||||
output.write("\x1b[?25h")
|
|
||||||
input.removeListener("keypress", onKeypress)
|
|
||||||
input.setRawMode(wasRaw ?? false)
|
|
||||||
if (wasPaused) input.pause()
|
|
||||||
}
|
|
||||||
const onKeypress = (value: string | undefined, key: Key) => {
|
|
||||||
if (key.name === "up") {
|
|
||||||
selected = (selected - 1 + sessions.length) % sessions.length
|
|
||||||
if (selected === sessions.length - 1) offset = Math.max(0, sessions.length - 10)
|
|
||||||
if (selected < offset) offset = selected
|
|
||||||
}
|
|
||||||
if (key.name === "down") {
|
|
||||||
selected = (selected + 1) % sessions.length
|
|
||||||
if (selected === 0) offset = 0
|
|
||||||
if (selected >= offset + 10) offset = selected - 9
|
|
||||||
}
|
|
||||||
if (key.name === "space" || value === " ") sanitize = !sanitize
|
|
||||||
if (key.name === "return") return finish(sessions[selected])
|
|
||||||
if (key.name === "escape" || (key.ctrl && key.name === "c")) return cancel()
|
|
||||||
render()
|
|
||||||
}
|
|
||||||
const finish = (session: SessionInfo) => {
|
|
||||||
clear()
|
|
||||||
resolveSelection?.({ session, sanitize })
|
|
||||||
}
|
|
||||||
const cancel = () => {
|
|
||||||
clear()
|
|
||||||
resolveSelection?.()
|
|
||||||
}
|
|
||||||
let resolveSelection: ((selection?: Selection) => void) | undefined
|
|
||||||
|
|
||||||
emitKeypressEvents(input)
|
|
||||||
input.setRawMode(true)
|
|
||||||
input.resume()
|
|
||||||
input.on("keypress", onKeypress)
|
|
||||||
output.write("\x1b[?25l")
|
|
||||||
render()
|
|
||||||
return new Promise<Selection | undefined>((resolve) => {
|
|
||||||
resolveSelection = resolve
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function writeExport(data: unknown, sessionID: string, stdout: boolean) {
|
|
||||||
const json = JSON.stringify(data, null, 2) + EOL
|
|
||||||
if (stdout) return json
|
|
||||||
const file = path.join(tmpdir(), `opencode-session-${sessionID}-${crypto.randomUUID().slice(0, 8)}.json`)
|
|
||||||
await Bun.write(file, json)
|
|
||||||
return file + EOL
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { OpenCode } from "@opencode-ai/client"
|
|
||||||
import { Service } from "@opencode-ai/client/effect/service"
|
|
||||||
import { Session } from "@opencode-ai/schema/session"
|
|
||||||
import { SessionTransfer } from "@opencode-ai/schema/session-transfer"
|
|
||||||
import { Effect, Option, Schema } from "effect"
|
|
||||||
import { EOL } from "node:os"
|
|
||||||
import path from "node:path"
|
|
||||||
import { Commands } from "../commands"
|
|
||||||
import { Runtime } from "../../framework/runtime"
|
|
||||||
import { ServerConnection } from "../../services/server-connection"
|
|
||||||
|
|
||||||
export default Runtime.handler(
|
|
||||||
Commands.commands.import,
|
|
||||||
Effect.fn("cli.import")(function* (input) {
|
|
||||||
const text = yield* Effect.tryPromise({
|
|
||||||
try: () =>
|
|
||||||
input.file.startsWith("http://") || input.file.startsWith("https://")
|
|
||||||
? fetch(input.file).then((response) => {
|
|
||||||
if (!response.ok) throw new Error(`Failed to fetch session data: ${response.statusText}`)
|
|
||||||
return response.text()
|
|
||||||
})
|
|
||||||
: Bun.file(input.file).text(),
|
|
||||||
catch: (cause) => new Error(`Failed to read session data: ${cause instanceof Error ? cause.message : String(cause)}`),
|
|
||||||
})
|
|
||||||
const data = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(SessionTransfer.Data))(text)
|
|
||||||
const encoded = Schema.encodeSync(SessionTransfer.Data)(data)
|
|
||||||
const server = yield* ServerConnection.resolve({
|
|
||||||
server: Option.getOrUndefined(input.server),
|
|
||||||
standalone: input.standalone,
|
|
||||||
})
|
|
||||||
const client = OpenCode.make({
|
|
||||||
baseUrl: server.endpoint.url,
|
|
||||||
headers: Service.headers(server.endpoint),
|
|
||||||
})
|
|
||||||
const location = yield* Effect.promise(() =>
|
|
||||||
client.location.get({
|
|
||||||
location: { directory: path.resolve(Option.getOrElse(input.directory, () => process.cwd())) },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const response = yield* Effect.promise(() =>
|
|
||||||
fetch(new URL("/api/session/import", server.endpoint.url), {
|
|
||||||
method: "POST",
|
|
||||||
headers: { ...Service.headers(server.endpoint), "content-type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
...encoded,
|
|
||||||
location: { directory: location.directory, workspaceID: location.workspaceID },
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
if (response.status === 409) {
|
|
||||||
process.stderr.write(`Session already exists${EOL}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!response.ok) yield* Effect.fail(new Error(`Failed to import session: ${response.statusText}`))
|
|
||||||
const imported = yield* Schema.decodeUnknownEffect(
|
|
||||||
Schema.fromJsonString(Schema.Struct({ data: Session.Info })),
|
|
||||||
)(yield* Effect.promise(() => response.text()))
|
|
||||||
process.stdout.write(`Imported session: ${imported.data.id}${EOL}`)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
@@ -34,6 +34,7 @@ function icon(status: McpServer["status"]) {
|
|||||||
case "needs_auth":
|
case "needs_auth":
|
||||||
return "⚠"
|
return "⚠"
|
||||||
case "failed":
|
case "failed":
|
||||||
|
case "needs_client_registration":
|
||||||
return "✗"
|
return "✗"
|
||||||
default:
|
default:
|
||||||
return "○"
|
return "○"
|
||||||
@@ -44,6 +45,8 @@ function describe(status: McpServer["status"]) {
|
|||||||
switch (status.status) {
|
switch (status.status) {
|
||||||
case "needs_auth":
|
case "needs_auth":
|
||||||
return "needs authentication"
|
return "needs authentication"
|
||||||
|
case "needs_client_registration":
|
||||||
|
return `needs client registration: ${status.error}`
|
||||||
case "failed":
|
case "failed":
|
||||||
return `failed: ${status.error}`
|
return `failed: ${status.error}`
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -37,8 +37,6 @@ const Handlers = Runtime.handlers(Commands, {
|
|||||||
list: () => import("./commands/handlers/plugin/list"),
|
list: () => import("./commands/handlers/plugin/list"),
|
||||||
},
|
},
|
||||||
models: () => import("./commands/handlers/models"),
|
models: () => import("./commands/handlers/models"),
|
||||||
export: () => import("./commands/handlers/export"),
|
|
||||||
import: () => import("./commands/handlers/import"),
|
|
||||||
mini: () => import("./commands/handlers/mini"),
|
mini: () => import("./commands/handlers/mini"),
|
||||||
run: () => import("./commands/handlers/run"),
|
run: () => import("./commands/handlers/run"),
|
||||||
pair: () => import("./commands/handlers/pair"),
|
pair: () => import("./commands/handlers/pair"),
|
||||||
|
|||||||
@@ -1,210 +0,0 @@
|
|||||||
import { expect, test } from "bun:test"
|
|
||||||
import fs from "node:fs/promises"
|
|
||||||
import os from "node:os"
|
|
||||||
import path from "node:path"
|
|
||||||
import { OPENCODE_VERSION } from "../src/version"
|
|
||||||
import { writeExport } from "../src/commands/handlers/export"
|
|
||||||
|
|
||||||
const info = {
|
|
||||||
id: "ses_export_test",
|
|
||||||
projectID: "global",
|
|
||||||
cost: 0,
|
|
||||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
||||||
time: { created: 1, updated: 2 },
|
|
||||||
title: "Exported session",
|
|
||||||
location: { directory: "/project" },
|
|
||||||
}
|
|
||||||
const transfer = {
|
|
||||||
info,
|
|
||||||
messages: [
|
|
||||||
{ id: "msg_first", type: "user", text: "First", time: { created: 1 } },
|
|
||||||
{ id: "msg_second", type: "user", text: "Second", time: { created: 2 } },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
const sanitizedTransfer = {
|
|
||||||
info: {
|
|
||||||
...info,
|
|
||||||
title: "[redacted:session-title:ses_export_test]",
|
|
||||||
location: { directory: "/[redacted:session-directory:ses_export_test]" },
|
|
||||||
},
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
id: "msg_first",
|
|
||||||
type: "user",
|
|
||||||
text: "[redacted:text:msg_first]",
|
|
||||||
time: { created: 1 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "msg_second",
|
|
||||||
type: "user",
|
|
||||||
text: "[redacted:text:msg_second]",
|
|
||||||
time: { created: 2 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
const health = () => Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
|
|
||||||
|
|
||||||
function run(args: string[], stdin?: string) {
|
|
||||||
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
|
|
||||||
cwd: path.join(import.meta.dir, ".."),
|
|
||||||
stdin: stdin === undefined ? undefined : new Blob([stdin]),
|
|
||||||
stdout: "pipe",
|
|
||||||
stderr: "pipe",
|
|
||||||
})
|
|
||||||
return Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited])
|
|
||||||
}
|
|
||||||
|
|
||||||
test("export is raw by default and supports explicit sanitization", async () => {
|
|
||||||
const sanitization: string[] = []
|
|
||||||
const server = Bun.serve({
|
|
||||||
port: 0,
|
|
||||||
fetch(request) {
|
|
||||||
const url = new URL(request.url)
|
|
||||||
if (url.pathname === "/api/health") return health()
|
|
||||||
if (url.pathname === `/api/session/${info.id}`) return Response.json({ data: info })
|
|
||||||
if (url.pathname === `/api/session/${info.id}/export`) {
|
|
||||||
sanitization.push(url.searchParams.get("sanitize") ?? "")
|
|
||||||
return Response.json({ data: url.searchParams.get("sanitize") === "true" ? sanitizedTransfer : transfer })
|
|
||||||
}
|
|
||||||
return new Response("Not found", { status: 404 })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [stdout, , exitCode] = await run(["export", "-s", info.id, "--server", server.url.toString()])
|
|
||||||
const exported = JSON.parse(stdout)
|
|
||||||
|
|
||||||
expect(exitCode).toBe(0)
|
|
||||||
expect(exported).toEqual(transfer)
|
|
||||||
|
|
||||||
const [sanitized, , sanitizedExitCode] = await run([
|
|
||||||
"export",
|
|
||||||
"-s",
|
|
||||||
info.id,
|
|
||||||
"--sanitize",
|
|
||||||
"--server",
|
|
||||||
server.url.toString(),
|
|
||||||
])
|
|
||||||
expect(sanitizedExitCode).toBe(0)
|
|
||||||
expect(JSON.parse(sanitized)).toEqual(sanitizedTransfer)
|
|
||||||
expect(sanitization).toEqual(["false", "true"])
|
|
||||||
} finally {
|
|
||||||
await server.stop(true)
|
|
||||||
}
|
|
||||||
}, 15_000)
|
|
||||||
|
|
||||||
test("export reports an empty session list without a stack trace", async () => {
|
|
||||||
const server = Bun.serve({
|
|
||||||
port: 0,
|
|
||||||
fetch(request) {
|
|
||||||
const url = new URL(request.url)
|
|
||||||
if (url.pathname === "/api/health") return health()
|
|
||||||
if (url.pathname === "/api/location") {
|
|
||||||
return Response.json({
|
|
||||||
directory: "/project",
|
|
||||||
project: { id: "global", directory: "/project", canonical: "/project" },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (url.pathname === "/api/session") return Response.json({ data: [], cursor: {} })
|
|
||||||
return new Response("Not found", { status: 404 })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [stdout, stderr, exitCode] = await run(["export", "--server", server.url.toString()])
|
|
||||||
|
|
||||||
expect(exitCode).toBe(0)
|
|
||||||
expect(stdout).toBe("")
|
|
||||||
expect(stderr).toBe(`No sessions found${os.EOL}`)
|
|
||||||
} finally {
|
|
||||||
await server.stop(true)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("interactive export writes a temporary JSON file", async () => {
|
|
||||||
const output = await writeExport(transfer, info.id, false)
|
|
||||||
const file = output.trim()
|
|
||||||
|
|
||||||
try {
|
|
||||||
expect(path.dirname(file)).toBe(os.tmpdir())
|
|
||||||
expect(await Bun.file(file).json()).toEqual(transfer)
|
|
||||||
} finally {
|
|
||||||
await fs.rm(file, { force: true })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("import validates a file and sends it to the resolved location", async () => {
|
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-import-"))
|
|
||||||
const file = path.join(root, "session.json")
|
|
||||||
await fs.writeFile(file, JSON.stringify(transfer))
|
|
||||||
let imported: unknown
|
|
||||||
const server = Bun.serve({
|
|
||||||
port: 0,
|
|
||||||
async fetch(request) {
|
|
||||||
const url = new URL(request.url)
|
|
||||||
if (url.pathname === "/api/health") return health()
|
|
||||||
if (url.pathname === "/api/location") {
|
|
||||||
return Response.json({
|
|
||||||
directory: root,
|
|
||||||
project: { id: "global", directory: root, canonical: root },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (url.pathname === "/api/session/import") {
|
|
||||||
imported = await request.json()
|
|
||||||
return Response.json({ data: { ...info, location: { directory: root } } })
|
|
||||||
}
|
|
||||||
return new Response("Not found", { status: 404 })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [stdout, , exitCode] = await run([
|
|
||||||
"import",
|
|
||||||
file,
|
|
||||||
"--directory",
|
|
||||||
root,
|
|
||||||
"--server",
|
|
||||||
server.url.toString(),
|
|
||||||
])
|
|
||||||
|
|
||||||
expect(exitCode).toBe(0)
|
|
||||||
expect(stdout).toBe(`Imported session: ${info.id}${os.EOL}`)
|
|
||||||
expect(imported).toEqual({ ...transfer, location: { directory: root } })
|
|
||||||
} finally {
|
|
||||||
await server.stop(true)
|
|
||||||
await fs.rm(root, { recursive: true, force: true })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("import reports an existing session without a stack trace", async () => {
|
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-import-conflict-"))
|
|
||||||
const file = path.join(root, "session.json")
|
|
||||||
await fs.writeFile(file, JSON.stringify(transfer))
|
|
||||||
const server = Bun.serve({
|
|
||||||
port: 0,
|
|
||||||
fetch(request) {
|
|
||||||
const url = new URL(request.url)
|
|
||||||
if (url.pathname === "/api/health") return health()
|
|
||||||
if (url.pathname === "/api/location") {
|
|
||||||
return Response.json({
|
|
||||||
directory: root,
|
|
||||||
project: { id: "global", directory: root, canonical: root },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (url.pathname === "/api/session/import") return new Response("Conflict", { status: 409 })
|
|
||||||
return new Response("Not found", { status: 404 })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [stdout, stderr, exitCode] = await run(["import", file, "--server", server.url.toString()])
|
|
||||||
|
|
||||||
expect(exitCode).toBe(0)
|
|
||||||
expect(stdout).toBe("")
|
|
||||||
expect(stderr).toBe(`Session already exists${os.EOL}`)
|
|
||||||
} finally {
|
|
||||||
await server.stop(true)
|
|
||||||
await fs.rm(root, { recursive: true, force: true })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -127,54 +127,42 @@ export type Endpoint5_1Input = {
|
|||||||
export type Endpoint5_1Output = Session.Info
|
export type Endpoint5_1Output = Session.Info
|
||||||
export type SessionCreateOperation<E = never> = (input?: Endpoint5_1Input) => Effect.Effect<Endpoint5_1Output, E>
|
export type SessionCreateOperation<E = never> = (input?: Endpoint5_1Input) => Effect.Effect<Endpoint5_1Output, E>
|
||||||
|
|
||||||
export type Endpoint5_2Input = {
|
export type Endpoint5_2Output = { readonly [x: Session.ID]: { readonly type: "running" } }
|
||||||
readonly info: Session.Info
|
export type SessionActiveOperation<E = never> = () => Effect.Effect<Endpoint5_2Output, E>
|
||||||
readonly messages: ReadonlyArray<SessionMessage.Info>
|
|
||||||
readonly location?: Location.Ref | undefined
|
|
||||||
}
|
|
||||||
export type Endpoint5_2Output = Session.Info
|
|
||||||
export type SessionImportOperation<E = never> = (input: Endpoint5_2Input) => Effect.Effect<Endpoint5_2Output, E>
|
|
||||||
|
|
||||||
export type Endpoint5_3Input = { readonly sessionID: Session.ID; readonly sanitize?: boolean | undefined }
|
export type Endpoint5_3Input = { readonly sessionID: Session.ID }
|
||||||
export type Endpoint5_3Output = { readonly info: Session.Info; readonly messages: ReadonlyArray<SessionMessage.Info> }
|
export type Endpoint5_3Output = Session.Info
|
||||||
export type SessionExportOperation<E = never> = (input: Endpoint5_3Input) => Effect.Effect<Endpoint5_3Output, E>
|
export type SessionGetOperation<E = never> = (input: Endpoint5_3Input) => Effect.Effect<Endpoint5_3Output, E>
|
||||||
|
|
||||||
export type Endpoint5_4Output = { readonly [x: Session.ID]: { readonly type: "running" } }
|
export type Endpoint5_4Input = { readonly sessionID: Session.ID }
|
||||||
export type SessionActiveOperation<E = never> = () => Effect.Effect<Endpoint5_4Output, E>
|
export type Endpoint5_4Output = void
|
||||||
|
export type SessionRemoveOperation<E = never> = (input: Endpoint5_4Input) => Effect.Effect<Endpoint5_4Output, E>
|
||||||
|
|
||||||
export type Endpoint5_5Input = { readonly sessionID: Session.ID }
|
export type Endpoint5_5Input = { readonly sessionID: Session.ID; readonly boundary: Session.ForkRequestBoundary }
|
||||||
export type Endpoint5_5Output = Session.Info
|
export type Endpoint5_5Output = Session.Info
|
||||||
export type SessionGetOperation<E = never> = (input: Endpoint5_5Input) => Effect.Effect<Endpoint5_5Output, E>
|
export type SessionForkOperation<E = never> = (input: Endpoint5_5Input) => Effect.Effect<Endpoint5_5Output, E>
|
||||||
|
|
||||||
export type Endpoint5_6Input = { readonly sessionID: Session.ID }
|
export type Endpoint5_6Input = { readonly sessionID: Session.ID; readonly agent: Agent.ID }
|
||||||
export type Endpoint5_6Output = void
|
export type Endpoint5_6Output = void
|
||||||
export type SessionRemoveOperation<E = never> = (input: Endpoint5_6Input) => Effect.Effect<Endpoint5_6Output, E>
|
export type SessionSwitchAgentOperation<E = never> = (input: Endpoint5_6Input) => Effect.Effect<Endpoint5_6Output, E>
|
||||||
|
|
||||||
export type Endpoint5_7Input = { readonly sessionID: Session.ID; readonly boundary: Session.ForkRequestBoundary }
|
export type Endpoint5_7Input = { readonly sessionID: Session.ID; readonly model: Model.Ref }
|
||||||
export type Endpoint5_7Output = Session.Info
|
export type Endpoint5_7Output = void
|
||||||
export type SessionForkOperation<E = never> = (input: Endpoint5_7Input) => Effect.Effect<Endpoint5_7Output, E>
|
export type SessionSwitchModelOperation<E = never> = (input: Endpoint5_7Input) => Effect.Effect<Endpoint5_7Output, E>
|
||||||
|
|
||||||
export type Endpoint5_8Input = { readonly sessionID: Session.ID; readonly agent: Agent.ID }
|
export type Endpoint5_8Input = { readonly sessionID: Session.ID; readonly title: string }
|
||||||
export type Endpoint5_8Output = void
|
export type Endpoint5_8Output = void
|
||||||
export type SessionSwitchAgentOperation<E = never> = (input: Endpoint5_8Input) => Effect.Effect<Endpoint5_8Output, E>
|
export type SessionRenameOperation<E = never> = (input: Endpoint5_8Input) => Effect.Effect<Endpoint5_8Output, E>
|
||||||
|
|
||||||
export type Endpoint5_9Input = { readonly sessionID: Session.ID; readonly model: Model.Ref }
|
export type Endpoint5_9Input = {
|
||||||
export type Endpoint5_9Output = void
|
|
||||||
export type SessionSwitchModelOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
|
|
||||||
|
|
||||||
export type Endpoint5_10Input = { readonly sessionID: Session.ID; readonly title: string }
|
|
||||||
export type Endpoint5_10Output = void
|
|
||||||
export type SessionRenameOperation<E = never> = (input: Endpoint5_10Input) => Effect.Effect<Endpoint5_10Output, E>
|
|
||||||
|
|
||||||
export type Endpoint5_11Input = {
|
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly directory: AbsolutePath
|
readonly directory: AbsolutePath
|
||||||
readonly workspaceID?: Workspace.ID | undefined
|
readonly workspaceID?: Workspace.ID | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint5_11Output = void
|
export type Endpoint5_9Output = void
|
||||||
export type SessionMoveOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
|
export type SessionMoveOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
|
||||||
|
|
||||||
export type Endpoint5_12Input = {
|
export type Endpoint5_10Input = {
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly id?: SessionMessage.ID | undefined
|
readonly id?: SessionMessage.ID | undefined
|
||||||
readonly text: string
|
readonly text: string
|
||||||
@@ -184,10 +172,10 @@ export type Endpoint5_12Input = {
|
|||||||
readonly delivery?: "steer" | "queue" | undefined
|
readonly delivery?: "steer" | "queue" | undefined
|
||||||
readonly resume?: boolean | undefined
|
readonly resume?: boolean | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint5_12Output = SessionPending.User
|
export type Endpoint5_10Output = SessionPending.User
|
||||||
export type SessionPromptOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
|
export type SessionPromptOperation<E = never> = (input: Endpoint5_10Input) => Effect.Effect<Endpoint5_10Output, E>
|
||||||
|
|
||||||
export type Endpoint5_13Input = {
|
export type Endpoint5_11Input = {
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly id?: SessionMessage.ID | undefined
|
readonly id?: SessionMessage.ID | undefined
|
||||||
readonly command: string
|
readonly command: string
|
||||||
@@ -199,19 +187,19 @@ export type Endpoint5_13Input = {
|
|||||||
readonly delivery?: "steer" | "queue" | undefined
|
readonly delivery?: "steer" | "queue" | undefined
|
||||||
readonly resume?: boolean | undefined
|
readonly resume?: boolean | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint5_13Output = SessionPending.User
|
export type Endpoint5_11Output = SessionPending.User
|
||||||
export type SessionCommandOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
|
export type SessionCommandOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
|
||||||
|
|
||||||
export type Endpoint5_14Input = {
|
export type Endpoint5_12Input = {
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly id?: SessionMessage.ID | undefined
|
readonly id?: SessionMessage.ID | undefined
|
||||||
readonly skill: Skill.ID
|
readonly skill: Skill.ID
|
||||||
readonly resume?: boolean | undefined
|
readonly resume?: boolean | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint5_14Output = void
|
export type Endpoint5_12Output = void
|
||||||
export type SessionSkillOperation<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, E>
|
export type SessionSkillOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
|
||||||
|
|
||||||
export type Endpoint5_15Input = {
|
export type Endpoint5_13Input = {
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly id?: SessionMessage.ID | undefined
|
readonly id?: SessionMessage.ID | undefined
|
||||||
readonly text: string
|
readonly text: string
|
||||||
@@ -220,81 +208,81 @@ export type Endpoint5_15Input = {
|
|||||||
readonly delivery?: "steer" | "queue" | undefined
|
readonly delivery?: "steer" | "queue" | undefined
|
||||||
readonly resume?: boolean | undefined
|
readonly resume?: boolean | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint5_15Output = SessionPending.Synthetic
|
export type Endpoint5_13Output = SessionPending.Synthetic
|
||||||
export type SessionSyntheticOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
|
export type SessionSyntheticOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
|
||||||
|
|
||||||
export type Endpoint5_16Input = {
|
export type Endpoint5_14Input = {
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly id?: Event.ID | undefined
|
readonly id?: Event.ID | undefined
|
||||||
readonly command: string
|
readonly command: string
|
||||||
}
|
}
|
||||||
|
export type Endpoint5_14Output = void
|
||||||
|
export type SessionShellOperation<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, E>
|
||||||
|
|
||||||
|
export type Endpoint5_15Input = { readonly sessionID: Session.ID; readonly id?: SessionMessage.ID | undefined }
|
||||||
|
export type Endpoint5_15Output = SessionPending.Compaction
|
||||||
|
export type SessionCompactOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
|
||||||
|
|
||||||
|
export type Endpoint5_16Input = { readonly sessionID: Session.ID }
|
||||||
export type Endpoint5_16Output = void
|
export type Endpoint5_16Output = void
|
||||||
export type SessionShellOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
|
export type SessionWaitOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
|
||||||
|
|
||||||
export type Endpoint5_17Input = { readonly sessionID: Session.ID; readonly id?: SessionMessage.ID | undefined }
|
export type Endpoint5_17Input = {
|
||||||
export type Endpoint5_17Output = SessionPending.Compaction
|
|
||||||
export type SessionCompactOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
|
|
||||||
|
|
||||||
export type Endpoint5_18Input = { readonly sessionID: Session.ID }
|
|
||||||
export type Endpoint5_18Output = void
|
|
||||||
export type SessionWaitOperation<E = never> = (input: Endpoint5_18Input) => Effect.Effect<Endpoint5_18Output, E>
|
|
||||||
|
|
||||||
export type Endpoint5_19Input = {
|
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly messageID: SessionMessage.ID
|
readonly messageID: SessionMessage.ID
|
||||||
readonly files?: boolean | undefined
|
readonly files?: boolean | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint5_19Output = Session.Revert
|
export type Endpoint5_17Output = Session.Revert
|
||||||
export type SessionRevertStageOperation<E = never> = (input: Endpoint5_19Input) => Effect.Effect<Endpoint5_19Output, E>
|
export type SessionRevertStageOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
|
||||||
|
|
||||||
|
export type Endpoint5_18Input = { readonly sessionID: Session.ID }
|
||||||
|
export type Endpoint5_18Output = void
|
||||||
|
export type SessionRevertClearOperation<E = never> = (input: Endpoint5_18Input) => Effect.Effect<Endpoint5_18Output, E>
|
||||||
|
|
||||||
|
export type Endpoint5_19Input = { readonly sessionID: Session.ID }
|
||||||
|
export type Endpoint5_19Output = void
|
||||||
|
export type SessionRevertCommitOperation<E = never> = (input: Endpoint5_19Input) => Effect.Effect<Endpoint5_19Output, E>
|
||||||
|
|
||||||
export type Endpoint5_20Input = { readonly sessionID: Session.ID }
|
export type Endpoint5_20Input = { readonly sessionID: Session.ID }
|
||||||
export type Endpoint5_20Output = void
|
export type Endpoint5_20Output = ReadonlyArray<SessionMessage.Info>
|
||||||
export type SessionRevertClearOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
|
export type SessionContextOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
|
||||||
|
|
||||||
export type Endpoint5_21Input = { readonly sessionID: Session.ID }
|
export type Endpoint5_21Input = { readonly sessionID: Session.ID }
|
||||||
export type Endpoint5_21Output = void
|
export type Endpoint5_21Output = ReadonlyArray<SessionPending.Info>
|
||||||
export type SessionRevertCommitOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
|
export type SessionPendingListOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
|
||||||
|
|
||||||
export type Endpoint5_22Input = { readonly sessionID: Session.ID }
|
export type Endpoint5_22Input = { readonly sessionID: Session.ID }
|
||||||
export type Endpoint5_22Output = ReadonlyArray<SessionMessage.Info>
|
export type Endpoint5_22Output = ReadonlyArray<InstructionEntry.Info>
|
||||||
export type SessionContextOperation<E = never> = (input: Endpoint5_22Input) => Effect.Effect<Endpoint5_22Output, E>
|
|
||||||
|
|
||||||
export type Endpoint5_23Input = { readonly sessionID: Session.ID }
|
|
||||||
export type Endpoint5_23Output = ReadonlyArray<SessionPending.Info>
|
|
||||||
export type SessionPendingListOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
|
|
||||||
|
|
||||||
export type Endpoint5_24Input = { readonly sessionID: Session.ID }
|
|
||||||
export type Endpoint5_24Output = ReadonlyArray<InstructionEntry.Info>
|
|
||||||
export type SessionInstructionsEntryListOperation<E = never> = (
|
export type SessionInstructionsEntryListOperation<E = never> = (
|
||||||
input: Endpoint5_24Input,
|
input: Endpoint5_22Input,
|
||||||
) => Effect.Effect<Endpoint5_24Output, E>
|
) => Effect.Effect<Endpoint5_22Output, E>
|
||||||
|
|
||||||
export type Endpoint5_25Input = {
|
export type Endpoint5_23Input = {
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly key: InstructionEntry.Key
|
readonly key: InstructionEntry.Key
|
||||||
readonly value: Schema.Json
|
readonly value: Schema.Json
|
||||||
}
|
}
|
||||||
export type Endpoint5_25Output = void
|
export type Endpoint5_23Output = void
|
||||||
export type SessionInstructionsEntryPutOperation<E = never> = (
|
export type SessionInstructionsEntryPutOperation<E = never> = (
|
||||||
input: Endpoint5_25Input,
|
input: Endpoint5_23Input,
|
||||||
) => Effect.Effect<Endpoint5_25Output, E>
|
) => Effect.Effect<Endpoint5_23Output, E>
|
||||||
|
|
||||||
export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
|
export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
|
||||||
export type Endpoint5_26Output = void
|
export type Endpoint5_24Output = void
|
||||||
export type SessionInstructionsEntryRemoveOperation<E = never> = (
|
export type SessionInstructionsEntryRemoveOperation<E = never> = (
|
||||||
input: Endpoint5_26Input,
|
input: Endpoint5_24Input,
|
||||||
) => Effect.Effect<Endpoint5_26Output, E>
|
) => Effect.Effect<Endpoint5_24Output, E>
|
||||||
|
|
||||||
export type Endpoint5_27Input = { readonly sessionID: Session.ID; readonly prompt: string }
|
export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly prompt: string }
|
||||||
export type Endpoint5_27Output = { readonly text: string }
|
export type Endpoint5_25Output = { readonly text: string }
|
||||||
export type SessionGenerateOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
|
export type SessionGenerateOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
|
||||||
|
|
||||||
export type Endpoint5_28Input = {
|
export type Endpoint5_26Input = {
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly after?: Event.Seq | undefined
|
readonly after?: Event.Seq | undefined
|
||||||
readonly follow?: boolean | undefined
|
readonly follow?: boolean | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint5_28Output =
|
export type Endpoint5_26Output =
|
||||||
| (
|
| (
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
@@ -862,25 +850,23 @@ export type Endpoint5_28Output =
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
| EventLog.Synced
|
| EventLog.Synced
|
||||||
export type SessionLogOperation<E = never> = (input: Endpoint5_28Input) => Stream.Stream<Endpoint5_28Output, E>
|
export type SessionLogOperation<E = never> = (input: Endpoint5_26Input) => Stream.Stream<Endpoint5_26Output, E>
|
||||||
|
|
||||||
export type Endpoint5_29Input = { readonly sessionID: Session.ID }
|
export type Endpoint5_27Input = { readonly sessionID: Session.ID }
|
||||||
export type Endpoint5_29Output = void
|
export type Endpoint5_27Output = void
|
||||||
export type SessionInterruptOperation<E = never> = (input: Endpoint5_29Input) => Effect.Effect<Endpoint5_29Output, E>
|
export type SessionInterruptOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
|
||||||
|
|
||||||
export type Endpoint5_30Input = { readonly sessionID: Session.ID }
|
export type Endpoint5_28Input = { readonly sessionID: Session.ID }
|
||||||
export type Endpoint5_30Output = void
|
export type Endpoint5_28Output = void
|
||||||
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_30Input) => Effect.Effect<Endpoint5_30Output, E>
|
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_28Input) => Effect.Effect<Endpoint5_28Output, E>
|
||||||
|
|
||||||
export type Endpoint5_31Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
|
export type Endpoint5_29Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
|
||||||
export type Endpoint5_31Output = SessionMessage.Info
|
export type Endpoint5_29Output = SessionMessage.Info
|
||||||
export type SessionMessageOperation<E = never> = (input: Endpoint5_31Input) => Effect.Effect<Endpoint5_31Output, E>
|
export type SessionMessageOperation<E = never> = (input: Endpoint5_29Input) => Effect.Effect<Endpoint5_29Output, E>
|
||||||
|
|
||||||
export interface SessionApi<E = never> {
|
export interface SessionApi<E = never> {
|
||||||
readonly list: SessionListOperation<E>
|
readonly list: SessionListOperation<E>
|
||||||
readonly create: SessionCreateOperation<E>
|
readonly create: SessionCreateOperation<E>
|
||||||
readonly import: SessionImportOperation<E>
|
|
||||||
readonly export: SessionExportOperation<E>
|
|
||||||
readonly active: SessionActiveOperation<E>
|
readonly active: SessionActiveOperation<E>
|
||||||
readonly get: SessionGetOperation<E>
|
readonly get: SessionGetOperation<E>
|
||||||
readonly remove: SessionRemoveOperation<E>
|
readonly remove: SessionRemoveOperation<E>
|
||||||
|
|||||||
@@ -21,10 +21,10 @@ import type {
|
|||||||
Endpoint5_0Output,
|
Endpoint5_0Output,
|
||||||
Endpoint5_1Input,
|
Endpoint5_1Input,
|
||||||
Endpoint5_1Output,
|
Endpoint5_1Output,
|
||||||
Endpoint5_2Input,
|
|
||||||
Endpoint5_2Output,
|
Endpoint5_2Output,
|
||||||
Endpoint5_3Input,
|
Endpoint5_3Input,
|
||||||
Endpoint5_3Output,
|
Endpoint5_3Output,
|
||||||
|
Endpoint5_4Input,
|
||||||
Endpoint5_4Output,
|
Endpoint5_4Output,
|
||||||
Endpoint5_5Input,
|
Endpoint5_5Input,
|
||||||
Endpoint5_5Output,
|
Endpoint5_5Output,
|
||||||
@@ -76,10 +76,6 @@ import type {
|
|||||||
Endpoint5_28Output,
|
Endpoint5_28Output,
|
||||||
Endpoint5_29Input,
|
Endpoint5_29Input,
|
||||||
Endpoint5_29Output,
|
Endpoint5_29Output,
|
||||||
Endpoint5_30Input,
|
|
||||||
Endpoint5_30Output,
|
|
||||||
Endpoint5_31Input,
|
|
||||||
Endpoint5_31Output,
|
|
||||||
Endpoint6_0Input,
|
Endpoint6_0Input,
|
||||||
Endpoint6_0Output,
|
Endpoint6_0Output,
|
||||||
Endpoint7_0Input,
|
Endpoint7_0Input,
|
||||||
@@ -321,11 +317,9 @@ const Endpoint5_1 = (raw: RawClient["server.session"]) => (input?: Endpoint5_1In
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_2 = (raw: RawClient["server.session"]) => (input: Endpoint5_2Input) =>
|
const Endpoint5_2 = (raw: RawClient["server.session"]) => () =>
|
||||||
preserveEffect<Endpoint5_2Output>()(
|
preserveEffect<Endpoint5_2Output>()(
|
||||||
raw["session.import"]({
|
raw["session.active"]({}).pipe(
|
||||||
payload: { info: input["info"], messages: input["messages"], location: input["location"] },
|
|
||||||
}).pipe(
|
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
),
|
),
|
||||||
@@ -333,23 +327,20 @@ const Endpoint5_2 = (raw: RawClient["server.session"]) => (input: Endpoint5_2Inp
|
|||||||
|
|
||||||
const Endpoint5_3 = (raw: RawClient["server.session"]) => (input: Endpoint5_3Input) =>
|
const Endpoint5_3 = (raw: RawClient["server.session"]) => (input: Endpoint5_3Input) =>
|
||||||
preserveEffect<Endpoint5_3Output>()(
|
preserveEffect<Endpoint5_3Output>()(
|
||||||
raw["session.export"]({ params: { sessionID: input["sessionID"] }, query: { sanitize: input["sanitize"] } }).pipe(
|
raw["session.get"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_4 = (raw: RawClient["server.session"]) => () =>
|
const Endpoint5_4 = (raw: RawClient["server.session"]) => (input: Endpoint5_4Input) =>
|
||||||
preserveEffect<Endpoint5_4Output>()(
|
preserveEffect<Endpoint5_4Output>()(
|
||||||
raw["session.active"]({}).pipe(
|
raw["session.remove"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||||
Effect.mapError(mapClientError),
|
|
||||||
Effect.map((value) => value.data),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_5 = (raw: RawClient["server.session"]) => (input: Endpoint5_5Input) =>
|
const Endpoint5_5 = (raw: RawClient["server.session"]) => (input: Endpoint5_5Input) =>
|
||||||
preserveEffect<Endpoint5_5Output>()(
|
preserveEffect<Endpoint5_5Output>()(
|
||||||
raw["session.get"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
raw["session.fork"]({ params: { sessionID: input["sessionID"] }, payload: { boundary: input["boundary"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
),
|
),
|
||||||
@@ -357,48 +348,35 @@ const Endpoint5_5 = (raw: RawClient["server.session"]) => (input: Endpoint5_5Inp
|
|||||||
|
|
||||||
const Endpoint5_6 = (raw: RawClient["server.session"]) => (input: Endpoint5_6Input) =>
|
const Endpoint5_6 = (raw: RawClient["server.session"]) => (input: Endpoint5_6Input) =>
|
||||||
preserveEffect<Endpoint5_6Output>()(
|
preserveEffect<Endpoint5_6Output>()(
|
||||||
raw["session.remove"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe(
|
||||||
|
Effect.mapError(mapClientError),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_7 = (raw: RawClient["server.session"]) => (input: Endpoint5_7Input) =>
|
const Endpoint5_7 = (raw: RawClient["server.session"]) => (input: Endpoint5_7Input) =>
|
||||||
preserveEffect<Endpoint5_7Output>()(
|
preserveEffect<Endpoint5_7Output>()(
|
||||||
raw["session.fork"]({ params: { sessionID: input["sessionID"] }, payload: { boundary: input["boundary"] } }).pipe(
|
raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_8 = (raw: RawClient["server.session"]) => (input: Endpoint5_8Input) =>
|
const Endpoint5_8 = (raw: RawClient["server.session"]) => (input: Endpoint5_8Input) =>
|
||||||
preserveEffect<Endpoint5_8Output>()(
|
preserveEffect<Endpoint5_8Output>()(
|
||||||
raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe(
|
raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_9 = (raw: RawClient["server.session"]) => (input: Endpoint5_9Input) =>
|
const Endpoint5_9 = (raw: RawClient["server.session"]) => (input: Endpoint5_9Input) =>
|
||||||
preserveEffect<Endpoint5_9Output>()(
|
preserveEffect<Endpoint5_9Output>()(
|
||||||
raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe(
|
|
||||||
Effect.mapError(mapClientError),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10Input) =>
|
|
||||||
preserveEffect<Endpoint5_10Output>()(
|
|
||||||
raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe(
|
|
||||||
Effect.mapError(mapClientError),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11Input) =>
|
|
||||||
preserveEffect<Endpoint5_11Output>()(
|
|
||||||
raw["session.move"]({
|
raw["session.move"]({
|
||||||
params: { sessionID: input["sessionID"] },
|
params: { sessionID: input["sessionID"] },
|
||||||
payload: { directory: input["directory"], workspaceID: input["workspaceID"] },
|
payload: { directory: input["directory"], workspaceID: input["workspaceID"] },
|
||||||
}).pipe(Effect.mapError(mapClientError)),
|
}).pipe(Effect.mapError(mapClientError)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) =>
|
const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10Input) =>
|
||||||
preserveEffect<Endpoint5_12Output>()(
|
preserveEffect<Endpoint5_10Output>()(
|
||||||
raw["session.prompt"]({
|
raw["session.prompt"]({
|
||||||
params: { sessionID: input["sessionID"] },
|
params: { sessionID: input["sessionID"] },
|
||||||
payload: {
|
payload: {
|
||||||
@@ -416,8 +394,8 @@ const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12I
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) =>
|
const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11Input) =>
|
||||||
preserveEffect<Endpoint5_13Output>()(
|
preserveEffect<Endpoint5_11Output>()(
|
||||||
raw["session.command"]({
|
raw["session.command"]({
|
||||||
params: { sessionID: input["sessionID"] },
|
params: { sessionID: input["sessionID"] },
|
||||||
payload: {
|
payload: {
|
||||||
@@ -437,16 +415,16 @@ const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13I
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) =>
|
const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) =>
|
||||||
preserveEffect<Endpoint5_14Output>()(
|
preserveEffect<Endpoint5_12Output>()(
|
||||||
raw["session.skill"]({
|
raw["session.skill"]({
|
||||||
params: { sessionID: input["sessionID"] },
|
params: { sessionID: input["sessionID"] },
|
||||||
payload: { id: input["id"], skill: input["skill"], resume: input["resume"] },
|
payload: { id: input["id"], skill: input["skill"], resume: input["resume"] },
|
||||||
}).pipe(Effect.mapError(mapClientError)),
|
}).pipe(Effect.mapError(mapClientError)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) =>
|
const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) =>
|
||||||
preserveEffect<Endpoint5_15Output>()(
|
preserveEffect<Endpoint5_13Output>()(
|
||||||
raw["session.synthetic"]({
|
raw["session.synthetic"]({
|
||||||
params: { sessionID: input["sessionID"] },
|
params: { sessionID: input["sessionID"] },
|
||||||
payload: {
|
payload: {
|
||||||
@@ -463,29 +441,29 @@ const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15I
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) =>
|
const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) =>
|
||||||
preserveEffect<Endpoint5_16Output>()(
|
preserveEffect<Endpoint5_14Output>()(
|
||||||
raw["session.shell"]({
|
raw["session.shell"]({
|
||||||
params: { sessionID: input["sessionID"] },
|
params: { sessionID: input["sessionID"] },
|
||||||
payload: { id: input["id"], command: input["command"] },
|
payload: { id: input["id"], command: input["command"] },
|
||||||
}).pipe(Effect.mapError(mapClientError)),
|
}).pipe(Effect.mapError(mapClientError)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
|
const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) =>
|
||||||
preserveEffect<Endpoint5_17Output>()(
|
preserveEffect<Endpoint5_15Output>()(
|
||||||
raw["session.compact"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"] } }).pipe(
|
raw["session.compact"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) =>
|
const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) =>
|
||||||
preserveEffect<Endpoint5_18Output>()(
|
preserveEffect<Endpoint5_16Output>()(
|
||||||
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) =>
|
const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
|
||||||
preserveEffect<Endpoint5_19Output>()(
|
preserveEffect<Endpoint5_17Output>()(
|
||||||
raw["session.revert.stage"]({
|
raw["session.revert.stage"]({
|
||||||
params: { sessionID: input["sessionID"] },
|
params: { sessionID: input["sessionID"] },
|
||||||
payload: { messageID: input["messageID"], files: input["files"] },
|
payload: { messageID: input["messageID"], files: input["files"] },
|
||||||
@@ -495,19 +473,35 @@ const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19I
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) =>
|
||||||
|
preserveEffect<Endpoint5_18Output>()(
|
||||||
|
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) =>
|
||||||
|
preserveEffect<Endpoint5_19Output>()(
|
||||||
|
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||||
|
)
|
||||||
|
|
||||||
const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20Input) =>
|
const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20Input) =>
|
||||||
preserveEffect<Endpoint5_20Output>()(
|
preserveEffect<Endpoint5_20Output>()(
|
||||||
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||||
|
Effect.mapError(mapClientError),
|
||||||
|
Effect.map((value) => value.data),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_21 = (raw: RawClient["server.session"]) => (input: Endpoint5_21Input) =>
|
const Endpoint5_21 = (raw: RawClient["server.session"]) => (input: Endpoint5_21Input) =>
|
||||||
preserveEffect<Endpoint5_21Output>()(
|
preserveEffect<Endpoint5_21Output>()(
|
||||||
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
raw["session.pending.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||||
|
Effect.mapError(mapClientError),
|
||||||
|
Effect.map((value) => value.data),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22Input) =>
|
const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22Input) =>
|
||||||
preserveEffect<Endpoint5_22Output>()(
|
preserveEffect<Endpoint5_22Output>()(
|
||||||
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
),
|
),
|
||||||
@@ -515,45 +509,29 @@ const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22I
|
|||||||
|
|
||||||
const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) =>
|
const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) =>
|
||||||
preserveEffect<Endpoint5_23Output>()(
|
preserveEffect<Endpoint5_23Output>()(
|
||||||
raw["session.pending.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
|
||||||
Effect.mapError(mapClientError),
|
|
||||||
Effect.map((value) => value.data),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
|
|
||||||
preserveEffect<Endpoint5_24Output>()(
|
|
||||||
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
|
||||||
Effect.mapError(mapClientError),
|
|
||||||
Effect.map((value) => value.data),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
|
|
||||||
preserveEffect<Endpoint5_25Output>()(
|
|
||||||
raw["session.instructions.entry.put"]({
|
raw["session.instructions.entry.put"]({
|
||||||
params: { sessionID: input["sessionID"], key: input["key"] },
|
params: { sessionID: input["sessionID"], key: input["key"] },
|
||||||
payload: { value: input["value"] },
|
payload: { value: input["value"] },
|
||||||
}).pipe(Effect.mapError(mapClientError)),
|
}).pipe(Effect.mapError(mapClientError)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
|
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
|
||||||
preserveEffect<Endpoint5_26Output>()(
|
preserveEffect<Endpoint5_24Output>()(
|
||||||
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
|
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
|
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
|
||||||
preserveEffect<Endpoint5_27Output>()(
|
preserveEffect<Endpoint5_25Output>()(
|
||||||
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
|
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
|
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
|
||||||
preserveStream<Endpoint5_28Output>()(
|
preserveStream<Endpoint5_26Output>()(
|
||||||
Stream.unwrap(
|
Stream.unwrap(
|
||||||
raw["session.log"]({
|
raw["session.log"]({
|
||||||
params: { sessionID: input["sessionID"] },
|
params: { sessionID: input["sessionID"] },
|
||||||
@@ -565,18 +543,18 @@ const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28I
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
|
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
|
||||||
preserveEffect<Endpoint5_29Output>()(
|
preserveEffect<Endpoint5_27Output>()(
|
||||||
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
|
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
|
||||||
preserveEffect<Endpoint5_30Output>()(
|
preserveEffect<Endpoint5_28Output>()(
|
||||||
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) =>
|
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
|
||||||
preserveEffect<Endpoint5_31Output>()(
|
preserveEffect<Endpoint5_29Output>()(
|
||||||
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
|
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
@@ -586,32 +564,30 @@ const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31I
|
|||||||
const adaptGroup5 = (raw: RawClient["server.session"]) => ({
|
const adaptGroup5 = (raw: RawClient["server.session"]) => ({
|
||||||
list: Endpoint5_0(raw),
|
list: Endpoint5_0(raw),
|
||||||
create: Endpoint5_1(raw),
|
create: Endpoint5_1(raw),
|
||||||
import: Endpoint5_2(raw),
|
active: Endpoint5_2(raw),
|
||||||
export: Endpoint5_3(raw),
|
get: Endpoint5_3(raw),
|
||||||
active: Endpoint5_4(raw),
|
remove: Endpoint5_4(raw),
|
||||||
get: Endpoint5_5(raw),
|
fork: Endpoint5_5(raw),
|
||||||
remove: Endpoint5_6(raw),
|
switchAgent: Endpoint5_6(raw),
|
||||||
fork: Endpoint5_7(raw),
|
switchModel: Endpoint5_7(raw),
|
||||||
switchAgent: Endpoint5_8(raw),
|
rename: Endpoint5_8(raw),
|
||||||
switchModel: Endpoint5_9(raw),
|
move: Endpoint5_9(raw),
|
||||||
rename: Endpoint5_10(raw),
|
prompt: Endpoint5_10(raw),
|
||||||
move: Endpoint5_11(raw),
|
command: Endpoint5_11(raw),
|
||||||
prompt: Endpoint5_12(raw),
|
skill: Endpoint5_12(raw),
|
||||||
command: Endpoint5_13(raw),
|
synthetic: Endpoint5_13(raw),
|
||||||
skill: Endpoint5_14(raw),
|
shell: Endpoint5_14(raw),
|
||||||
synthetic: Endpoint5_15(raw),
|
compact: Endpoint5_15(raw),
|
||||||
shell: Endpoint5_16(raw),
|
wait: Endpoint5_16(raw),
|
||||||
compact: Endpoint5_17(raw),
|
revert: { stage: Endpoint5_17(raw), clear: Endpoint5_18(raw), commit: Endpoint5_19(raw) },
|
||||||
wait: Endpoint5_18(raw),
|
context: Endpoint5_20(raw),
|
||||||
revert: { stage: Endpoint5_19(raw), clear: Endpoint5_20(raw), commit: Endpoint5_21(raw) },
|
pending: { list: Endpoint5_21(raw) },
|
||||||
context: Endpoint5_22(raw),
|
instructions: { entry: { list: Endpoint5_22(raw), put: Endpoint5_23(raw), remove: Endpoint5_24(raw) } },
|
||||||
pending: { list: Endpoint5_23(raw) },
|
generate: Endpoint5_25(raw),
|
||||||
instructions: { entry: { list: Endpoint5_24(raw), put: Endpoint5_25(raw), remove: Endpoint5_26(raw) } },
|
log: Endpoint5_26(raw),
|
||||||
generate: Endpoint5_27(raw),
|
interrupt: Endpoint5_27(raw),
|
||||||
log: Endpoint5_28(raw),
|
background: Endpoint5_28(raw),
|
||||||
interrupt: Endpoint5_29(raw),
|
message: Endpoint5_29(raw),
|
||||||
background: Endpoint5_30(raw),
|
|
||||||
message: Endpoint5_31(raw),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>
|
const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>
|
||||||
|
|||||||
@@ -15,10 +15,6 @@ import type {
|
|||||||
SessionListOutput,
|
SessionListOutput,
|
||||||
SessionCreateInput,
|
SessionCreateInput,
|
||||||
SessionCreateOutput,
|
SessionCreateOutput,
|
||||||
SessionImportInput,
|
|
||||||
SessionImportOutput,
|
|
||||||
SessionExportInput,
|
|
||||||
SessionExportOutput,
|
|
||||||
SessionActiveOutput,
|
SessionActiveOutput,
|
||||||
SessionGetInput,
|
SessionGetInput,
|
||||||
SessionGetOutput,
|
SessionGetOutput,
|
||||||
@@ -482,30 +478,6 @@ export function make(options: ClientOptions) {
|
|||||||
},
|
},
|
||||||
requestOptions,
|
requestOptions,
|
||||||
).then((value) => value.data),
|
).then((value) => value.data),
|
||||||
import: (input: SessionImportInput, requestOptions?: RequestOptions) =>
|
|
||||||
request<{ readonly data: SessionImportOutput }>(
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
path: `/api/session/import`,
|
|
||||||
body: { info: input["info"], messages: input["messages"], location: input["location"] },
|
|
||||||
successStatus: 200,
|
|
||||||
declaredStatuses: [409, 401, 400],
|
|
||||||
empty: false,
|
|
||||||
},
|
|
||||||
requestOptions,
|
|
||||||
).then((value) => value.data),
|
|
||||||
export: (input: SessionExportInput, requestOptions?: RequestOptions) =>
|
|
||||||
request<{ readonly data: SessionExportOutput }>(
|
|
||||||
{
|
|
||||||
method: "GET",
|
|
||||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/export`,
|
|
||||||
query: { sanitize: input["sanitize"] },
|
|
||||||
successStatus: 200,
|
|
||||||
declaredStatuses: [404, 500, 401, 400],
|
|
||||||
empty: false,
|
|
||||||
},
|
|
||||||
requestOptions,
|
|
||||||
).then((value) => value.data),
|
|
||||||
active: (requestOptions?: RequestOptions) =>
|
active: (requestOptions?: RequestOptions) =>
|
||||||
request<{ readonly data: SessionActiveOutput }>(
|
request<{ readonly data: SessionActiveOutput }>(
|
||||||
{
|
{
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+17
-35
@@ -24,7 +24,8 @@ import { Global } from "@opencode-ai/util/global"
|
|||||||
import { Location } from "./location"
|
import { Location } from "./location"
|
||||||
import { AbsolutePath } from "./schema"
|
import { AbsolutePath } from "./schema"
|
||||||
import { ConfigVariable } from "./config/variable"
|
import { ConfigVariable } from "./config/variable"
|
||||||
import { ConfigNormalize } from "./config/normalize"
|
import { ConfigV1 } from "./v1/config/config"
|
||||||
|
import { ConfigMigrateV1 } from "./v1/config/migrate"
|
||||||
import { WellKnown } from "./wellknown"
|
import { WellKnown } from "./wellknown"
|
||||||
|
|
||||||
export function latest<K extends keyof Info>(entries: readonly Entry[], key: K): Info[K] | undefined {
|
export function latest<K extends keyof Info>(entries: readonly Entry[], key: K): Info[K] | undefined {
|
||||||
@@ -92,43 +93,24 @@ export const layer = (options?: Options) => Layer.effect(
|
|||||||
const reloadLock = Semaphore.makeUnsafe(1)
|
const reloadLock = Semaphore.makeUnsafe(1)
|
||||||
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
||||||
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
|
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
|
||||||
const parseInfo = Effect.fn("Config.parseInfo")(function* (text: string, source: string) {
|
const decodeV1Info = Schema.decodeUnknownOption(ConfigV1.Info, decodeOptions)
|
||||||
|
|
||||||
|
const parseInfo = (text: string) => {
|
||||||
const errors: ParseError[] = []
|
const errors: ParseError[] = []
|
||||||
const input: unknown = parse(text, errors, { allowTrailingComma: true })
|
const input: unknown = parse(text, errors, { allowTrailingComma: true })
|
||||||
if (errors.length) {
|
if (errors.length) return
|
||||||
yield* Effect.logWarning("configuration normalization diagnostic", {
|
return Option.getOrUndefined(
|
||||||
source,
|
ConfigMigrateV1.isV1(input)
|
||||||
path: "$",
|
? decodeV1Info(input).pipe(Option.map(ConfigMigrateV1.migrate), Option.flatMap(decodeInfo))
|
||||||
kind: "invalid",
|
: decodeInfo(input),
|
||||||
action: "rejected malformed JSON or JSONC document",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const result = ConfigNormalize.normalize(input)
|
|
||||||
yield* Effect.forEach(result.diagnostics, (diagnostic) =>
|
|
||||||
Effect.logWarning("configuration normalization diagnostic", {
|
|
||||||
source,
|
|
||||||
path: diagnostic.path[0] === "$" ? "$" : `$.${diagnostic.path.join(".")}`,
|
|
||||||
kind: diagnostic.kind,
|
|
||||||
action: diagnostic.message,
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
if (result.type === "rejected") return
|
}
|
||||||
const info = Option.getOrUndefined(decodeInfo(result.encoded))
|
|
||||||
if (info) return info
|
|
||||||
yield* Effect.logWarning("configuration normalization diagnostic", {
|
|
||||||
source,
|
|
||||||
path: "$",
|
|
||||||
kind: "invalid",
|
|
||||||
action: "rejected canonical configuration after final validation",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const loadFile = Effect.fnUntraced(function* (filepath: string) {
|
const loadFile = Effect.fnUntraced(function* (filepath: string) {
|
||||||
const text = yield* fs.readFileStringSafe(filepath)
|
const text = yield* fs.readFileStringSafe(filepath)
|
||||||
if (text === undefined) return
|
if (!text) return
|
||||||
const substituted = yield* ConfigVariable.substitute({ type: "path", path: filepath, text })
|
const substituted = yield* ConfigVariable.substitute({ type: "path", path: filepath, text })
|
||||||
const info = yield* parseInfo(substituted, filepath)
|
const info = parseInfo(substituted)
|
||||||
if (!info) return
|
if (!info) return
|
||||||
return new Document({ type: "document", path: filepath, info })
|
return new Document({ type: "document", path: filepath, info })
|
||||||
})
|
})
|
||||||
@@ -159,7 +141,7 @@ export const layer = (options?: Options) => Layer.effect(
|
|||||||
text: JSON.stringify(config),
|
text: JSON.stringify(config),
|
||||||
env: variables,
|
env: variables,
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.flatMap((text) => parseInfo(text, entry.origin)),
|
Effect.map(parseInfo),
|
||||||
Effect.map((info) => (info ? new Document({ type: "document", info }) : undefined)),
|
Effect.map((info) => (info ? new Document({ type: "document", info }) : undefined)),
|
||||||
),
|
),
|
||||||
).pipe(Effect.map((documents) => documents.filter((document) => document !== undefined)))
|
).pipe(Effect.map((documents) => documents.filter((document) => document !== undefined)))
|
||||||
@@ -236,14 +218,14 @@ export const layer = (options?: Options) => Layer.effect(
|
|||||||
Effect.orDie,
|
Effect.orDie,
|
||||||
)
|
)
|
||||||
: []
|
: []
|
||||||
const content = options?.content !== undefined
|
const content = options?.content
|
||||||
? yield* ConfigVariable.substitute({
|
? yield* ConfigVariable.substitute({
|
||||||
type: "virtual",
|
type: "virtual",
|
||||||
source: "OPENCODE_CONFIG_CONTENT",
|
source: "OPENCODE_CONFIG_CONTENT",
|
||||||
dir: location.directory,
|
dir: location.directory,
|
||||||
text: options.content,
|
text: options.content,
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.flatMap((text) => parseInfo(text, "OPENCODE_CONFIG_CONTENT")),
|
Effect.map(parseInfo),
|
||||||
Effect.map((info) => (info ? [new Document({ type: "document", info })] : [])),
|
Effect.map((info) => (info ? [new Document({ type: "document", info })] : [])),
|
||||||
Effect.orDie,
|
Effect.orDie,
|
||||||
)
|
)
|
||||||
@@ -251,13 +233,13 @@ export const layer = (options?: Options) => Layer.effect(
|
|||||||
|
|
||||||
const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
|
const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
|
||||||
return [
|
return [
|
||||||
...(yield* loadWellknown().pipe(Effect.orDie)),
|
|
||||||
...claude,
|
...claude,
|
||||||
...agents,
|
...agents,
|
||||||
...(supplementary[0] ?? []),
|
...(supplementary[0] ?? []),
|
||||||
...explicit,
|
...explicit,
|
||||||
...direct,
|
...direct,
|
||||||
...supplementary.slice(1).flat(),
|
...supplementary.slice(1).flat(),
|
||||||
|
...(yield* loadWellknown().pipe(Effect.orDie)),
|
||||||
...content,
|
...content,
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,813 +0,0 @@
|
|||||||
export * as ConfigNormalize from "./normalize"
|
|
||||||
|
|
||||||
import { isDeepStrictEqual } from "node:util"
|
|
||||||
import { Option, Schema } from "effect"
|
|
||||||
import { Info } from "@opencode-ai/schema/config"
|
|
||||||
import { ConfigAgent } from "@opencode-ai/schema/config/agent"
|
|
||||||
import { ConfigCommand } from "@opencode-ai/schema/config/command"
|
|
||||||
import { ConfigCompaction } from "@opencode-ai/schema/config/compaction"
|
|
||||||
import { ConfigFormatter } from "@opencode-ai/schema/config/formatter"
|
|
||||||
import { ConfigLSP } from "@opencode-ai/schema/config/lsp"
|
|
||||||
import { ConfigMedia } from "@opencode-ai/schema/config/media"
|
|
||||||
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
|
|
||||||
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
|
|
||||||
import { ConfigPolicy } from "@opencode-ai/schema/config/policy"
|
|
||||||
import { ConfigProvider } from "@opencode-ai/schema/config/provider"
|
|
||||||
import { ConfigReference } from "@opencode-ai/schema/config/reference"
|
|
||||||
import { ConfigExperimental } from "@opencode-ai/schema/config/experimental"
|
|
||||||
import { Permission } from "@opencode-ai/schema/permission"
|
|
||||||
import { ConfigAgentV1 } from "../v1/config/agent"
|
|
||||||
import { ConfigAttachmentV1 } from "../v1/config/attachment"
|
|
||||||
import { ConfigCommandV1 } from "../v1/config/command"
|
|
||||||
import { ConfigMCPV1 } from "../v1/config/mcp"
|
|
||||||
import { ConfigPermissionV1 } from "../v1/config/permission"
|
|
||||||
import { ConfigPluginV1 } from "../v1/config/plugin"
|
|
||||||
import { ConfigProviderV1 } from "../v1/config/provider"
|
|
||||||
import { ConfigMigrateV1 } from "../v1/config/migrate"
|
|
||||||
import { PositiveInt } from "../schema"
|
|
||||||
|
|
||||||
export interface Diagnostic {
|
|
||||||
readonly kind: "conflict" | "invalid" | "unsupported"
|
|
||||||
readonly path: readonly string[]
|
|
||||||
readonly message: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Result =
|
|
||||||
| {
|
|
||||||
readonly type: "normalized"
|
|
||||||
readonly encoded: Readonly<Record<string, unknown>>
|
|
||||||
readonly diagnostics: readonly Diagnostic[]
|
|
||||||
}
|
|
||||||
| { readonly type: "rejected"; readonly diagnostics: readonly Diagnostic[] }
|
|
||||||
|
|
||||||
const options = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
|
||||||
const unsupportedTopLevel = ["logLevel", "server", "subagent_depth", "layout"] as const
|
|
||||||
const unsupportedExperimental = [
|
|
||||||
"disable_paste_summary",
|
|
||||||
"batch_tool",
|
|
||||||
"openTelemetry",
|
|
||||||
"primary_tools",
|
|
||||||
"continue_loop_on_deny",
|
|
||||||
] as const
|
|
||||||
const unsupportedProvider = ["id", "whitelist", "blacklist"] as const
|
|
||||||
const unsupportedModel = ["release_date", "attachment", "reasoning", "temperature", "experimental"] as const
|
|
||||||
|
|
||||||
export function normalize(input: unknown): Result {
|
|
||||||
if (!isRecord(input))
|
|
||||||
return {
|
|
||||||
type: "rejected",
|
|
||||||
diagnostics: [
|
|
||||||
{ kind: "invalid", path: ["$"], message: "rejected configuration because its root is not an object" },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
const diagnostics: Diagnostic[] = []
|
|
||||||
const encoded: Record<string, unknown> = {}
|
|
||||||
unsupportedTopLevel.forEach((key) => unsupportedIfPresent(input, key, [key], diagnostics))
|
|
||||||
|
|
||||||
const legacySnapshots = own(input, "snapshot")
|
|
||||||
? decodeEncoded(Schema.Boolean, input.snapshot, ["snapshot"], diagnostics)
|
|
||||||
: undefined
|
|
||||||
const legacyShare = own(input, "autoshare")
|
|
||||||
? decodeValue(Schema.Boolean, input.autoshare, ["autoshare"], diagnostics) === true
|
|
||||||
? "auto"
|
|
||||||
: undefined
|
|
||||||
: undefined
|
|
||||||
const legacyMedia = own(input, "attachment")
|
|
||||||
? decodeValue(ConfigAttachmentV1.Info, input.attachment, ["attachment"], diagnostics)
|
|
||||||
: undefined
|
|
||||||
if (legacyMedia !== undefined) {
|
|
||||||
const migrated = ConfigMigrateV1.migrate({ attachment: legacyMedia }).media
|
|
||||||
if (migrated !== undefined) encoded.media = canonical(ConfigMedia.Info, migrated)
|
|
||||||
}
|
|
||||||
if (legacySnapshots !== undefined) encoded.snapshots = legacySnapshots
|
|
||||||
if (legacyShare !== undefined) encoded.share = legacyShare
|
|
||||||
|
|
||||||
const legacyReferences = decodeEncodedMap(input.reference, ConfigReference.Entry, ["reference"], diagnostics)
|
|
||||||
const nativeReferences = decodeEncodedMap(input.references, ConfigReference.Entry, ["references"], diagnostics)
|
|
||||||
mergeMap(
|
|
||||||
encoded,
|
|
||||||
"references",
|
|
||||||
legacyReferences,
|
|
||||||
nativeReferences,
|
|
||||||
isRecord(input.reference) || isRecord(input.references),
|
|
||||||
diagnostics,
|
|
||||||
)
|
|
||||||
|
|
||||||
const legacyCommands = decodeMap(input.command, ConfigCommandV1.Info, ["command"], diagnostics)
|
|
||||||
diagnoseSelectionMap(input.command, ["command"], diagnostics)
|
|
||||||
const migratedCommands = mapValues(legacyCommands, (value) => {
|
|
||||||
const migrated = ConfigMigrateV1.commands({ value })?.value
|
|
||||||
return migrated === undefined ? undefined : canonical(ConfigCommand.Info, migrated)
|
|
||||||
})
|
|
||||||
const nativeCommands = decodeEncodedMap(input.commands, ConfigCommand.Info, ["commands"], diagnostics)
|
|
||||||
mergeMap(
|
|
||||||
encoded,
|
|
||||||
"commands",
|
|
||||||
migratedCommands,
|
|
||||||
nativeCommands,
|
|
||||||
isRecord(input.command) || isRecord(input.commands),
|
|
||||||
diagnostics,
|
|
||||||
)
|
|
||||||
|
|
||||||
const legacyAgents = mapValues(decodeMap(input.agent, ConfigAgentV1.Info, ["agent"], diagnostics), (value) =>
|
|
||||||
canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent(value)),
|
|
||||||
)
|
|
||||||
const legacySmallModel = own(input, "small_model")
|
|
||||||
? decodeValue(Schema.String, input.small_model, ["small_model"], diagnostics)
|
|
||||||
: undefined
|
|
||||||
const migratedSmallModel = legacySmallModel
|
|
||||||
? ConfigMigrateV1.migrate({ small_model: legacySmallModel }).agents?.title?.model
|
|
||||||
: undefined
|
|
||||||
if (legacySmallModel && !migratedSmallModel)
|
|
||||||
diagnostics.push({
|
|
||||||
kind: "unsupported",
|
|
||||||
path: ["small_model"],
|
|
||||||
message: "omitted unsupported legacy model reference",
|
|
||||||
})
|
|
||||||
if (migratedSmallModel)
|
|
||||||
legacyAgents.title = {
|
|
||||||
model: migratedSmallModel,
|
|
||||||
...legacyAgents.title,
|
|
||||||
}
|
|
||||||
const modeAgents = mapValues(decodeMap(input.mode, ConfigAgentV1.Info, ["mode"], diagnostics), (value) =>
|
|
||||||
canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent({ ...value, mode: "primary" })),
|
|
||||||
)
|
|
||||||
const migratedAgents = mergeMaps(legacyAgents, modeAgents, ["agents"], diagnostics)
|
|
||||||
const nativeAgents = decodeEncodedMap(input.agents, ConfigAgent.Info, ["agents"], diagnostics)
|
|
||||||
diagnoseAgentUnsupported(input.agent, ["agent"], diagnostics)
|
|
||||||
diagnoseAgentUnsupported(input.mode, ["mode"], diagnostics)
|
|
||||||
mergeMap(
|
|
||||||
encoded,
|
|
||||||
"agents",
|
|
||||||
migratedAgents,
|
|
||||||
nativeAgents,
|
|
||||||
isRecord(input.agent) || isRecord(input.mode) || isRecord(input.agents),
|
|
||||||
diagnostics,
|
|
||||||
)
|
|
||||||
|
|
||||||
const legacyProviders = migrateProviders(input.provider, diagnostics)
|
|
||||||
const nativeProviders = decodeEncodedMap(input.providers, ConfigProvider.Info, ["providers"], diagnostics)
|
|
||||||
mergeMap(
|
|
||||||
encoded,
|
|
||||||
"providers",
|
|
||||||
legacyProviders,
|
|
||||||
nativeProviders,
|
|
||||||
isRecord(input.provider) || isRecord(input.providers),
|
|
||||||
diagnostics,
|
|
||||||
)
|
|
||||||
|
|
||||||
const toolRules = migrateTools(input.tools, diagnostics)
|
|
||||||
const permissionRules = migratePermissions(input.permission, diagnostics)
|
|
||||||
const nativePermissions = decodeEncodedList(input.permissions, Permission.Rule, ["permissions"], diagnostics)
|
|
||||||
const permissions = [...toolRules, ...permissionRules, ...nativePermissions]
|
|
||||||
if (permissions.length || Array.isArray(input.permissions)) encoded.permissions = permissions
|
|
||||||
|
|
||||||
const legacyPlugins = decodeList(input.plugin, ConfigPluginV1.Spec, ["plugin"], diagnostics).map((plugin) =>
|
|
||||||
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
|
|
||||||
)
|
|
||||||
const nativePlugins = decodeEncodedList(input.plugins, ConfigPlugin.Plugin, ["plugins"], diagnostics)
|
|
||||||
if (legacyPlugins.length || nativePlugins.length || Array.isArray(input.plugin) || Array.isArray(input.plugins))
|
|
||||||
encoded.plugins = [...legacyPlugins, ...nativePlugins]
|
|
||||||
|
|
||||||
normalizeSkills(input, encoded, diagnostics)
|
|
||||||
normalizeMcp(input, encoded, diagnostics)
|
|
||||||
normalizeCompaction(input, encoded, diagnostics)
|
|
||||||
normalizeExperimental(input, encoded, diagnostics)
|
|
||||||
normalizeWatcher(input, encoded, diagnostics)
|
|
||||||
normalizeFormatter(input, encoded, diagnostics)
|
|
||||||
normalizeLsp(input, encoded, diagnostics)
|
|
||||||
|
|
||||||
const nativeAtomic = {
|
|
||||||
$schema: Info.fields.$schema,
|
|
||||||
shell: Info.fields.shell,
|
|
||||||
model: Info.fields.model,
|
|
||||||
default_agent: Info.fields.default_agent,
|
|
||||||
autoupdate: Info.fields.autoupdate,
|
|
||||||
share: Info.fields.share,
|
|
||||||
enterprise: Info.fields.enterprise,
|
|
||||||
username: Info.fields.username,
|
|
||||||
snapshots: Info.fields.snapshots,
|
|
||||||
media: Info.fields.media,
|
|
||||||
tool_output: Info.fields.tool_output,
|
|
||||||
websearch: Info.fields.websearch,
|
|
||||||
warming: Info.fields.warming,
|
|
||||||
}
|
|
||||||
Object.entries(nativeAtomic).forEach(([key, schema]) => {
|
|
||||||
if (!own(input, key)) return
|
|
||||||
const value = decodeEncoded(schema, input[key], [key], diagnostics)
|
|
||||||
if (value === undefined) return
|
|
||||||
overlay(encoded, key, value, [key], diagnostics)
|
|
||||||
})
|
|
||||||
|
|
||||||
const instructions = decodeEncodedList(input.instructions, Schema.String, ["instructions"], diagnostics)
|
|
||||||
if (instructions.length || Array.isArray(input.instructions)) encoded.instructions = instructions
|
|
||||||
|
|
||||||
return { type: "normalized", encoded, diagnostics }
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeSkills(input: Record<string, unknown>, encoded: Record<string, unknown>, diagnostics: Diagnostic[]) {
|
|
||||||
if (!own(input, "skills")) return
|
|
||||||
if (Array.isArray(input.skills)) {
|
|
||||||
encoded.skills = decodeEncodedList(input.skills, Schema.String, ["skills"], diagnostics)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!isRecord(input.skills)) {
|
|
||||||
invalid(["skills"], diagnostics)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
encoded.skills = [
|
|
||||||
...decodeEncodedList(input.skills.paths, Schema.String, ["skills", "paths"], diagnostics),
|
|
||||||
...decodeEncodedList(input.skills.urls, Schema.String, ["skills", "urls"], diagnostics),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeMcp(input: Record<string, unknown>, encoded: Record<string, unknown>, diagnostics: Diagnostic[]) {
|
|
||||||
const legacyServers: Record<string, unknown> = {}
|
|
||||||
const nativeServers: Record<string, unknown> = {}
|
|
||||||
const timeout: Record<string, unknown> = {}
|
|
||||||
if (isRecord(input.experimental) && own(input.experimental, "mcp_timeout")) {
|
|
||||||
const value = decodeEncoded(
|
|
||||||
PositiveInt,
|
|
||||||
input.experimental.mcp_timeout,
|
|
||||||
["experimental", "mcp_timeout"],
|
|
||||||
diagnostics,
|
|
||||||
)
|
|
||||||
if (value !== undefined) {
|
|
||||||
timeout.catalog = value
|
|
||||||
timeout.execution = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (own(input, "mcp")) {
|
|
||||||
if (!isRecord(input.mcp)) invalid(["mcp"], diagnostics)
|
|
||||||
if (isRecord(input.mcp)) {
|
|
||||||
Object.entries(input.mcp).forEach(([name, value]) => {
|
|
||||||
const path = ["mcp", name]
|
|
||||||
if (isEnabledOnlyMcp(value)) {
|
|
||||||
diagnostics.push({ kind: "unsupported", path, message: "omitted enabled-only legacy MCP entry" })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (name === "servers" && !isDirectLegacyMcp(value)) {
|
|
||||||
Object.entries(decodeEncodedMap(value, ConfigMCP.Server, path, diagnostics)).forEach(([key, server]) =>
|
|
||||||
setOwn(nativeServers, key, server),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (name === "timeout" && !isDirectLegacyMcp(value)) {
|
|
||||||
normalizeMcpTimeout(value, timeout, path, diagnostics)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const server = decodeValue(ConfigMCPV1.Info, value, path, diagnostics)
|
|
||||||
if (server !== undefined)
|
|
||||||
setOwn(legacyServers, name, canonical(ConfigMCP.Server, ConfigMigrateV1.migrateMcp(server)))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const servers = mergeMaps(legacyServers, nativeServers, ["mcp", "servers"], diagnostics)
|
|
||||||
if (!Object.keys(servers).length && !Object.keys(timeout).length) {
|
|
||||||
if (isRecord(input.mcp) && !Object.keys(input.mcp).length) encoded.mcp = {}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
encoded.mcp = {
|
|
||||||
...(Object.keys(timeout).length ? { timeout } : {}),
|
|
||||||
...(Object.keys(servers).length ? { servers } : {}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeMcpTimeout(
|
|
||||||
value: unknown,
|
|
||||||
timeout: Record<string, unknown>,
|
|
||||||
path: string[],
|
|
||||||
diagnostics: Diagnostic[],
|
|
||||||
) {
|
|
||||||
if (!isRecord(value)) {
|
|
||||||
invalid(path, diagnostics)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const recognized = ["startup", "catalog", "execution"].filter((key) => own(value, key))
|
|
||||||
if (Object.keys(value).length && !recognized.length) {
|
|
||||||
invalid(path, diagnostics)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
recognized.forEach((key) => {
|
|
||||||
const leaf = decodeEncoded(
|
|
||||||
ConfigMCP.Timeout.fields[key as keyof typeof ConfigMCP.Timeout.fields],
|
|
||||||
value[key],
|
|
||||||
[...path, key],
|
|
||||||
diagnostics,
|
|
||||||
)
|
|
||||||
if (leaf === undefined) return
|
|
||||||
overlay(timeout, key, leaf, [...path, key], diagnostics)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeCompaction(
|
|
||||||
input: Record<string, unknown>,
|
|
||||||
encoded: Record<string, unknown>,
|
|
||||||
diagnostics: Diagnostic[],
|
|
||||||
) {
|
|
||||||
if (!own(input, "compaction")) return
|
|
||||||
if (!isRecord(input.compaction)) {
|
|
||||||
invalid(["compaction"], diagnostics)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
unsupportedIfPresent(input.compaction, "tail_turns", ["compaction", "tail_turns"], diagnostics)
|
|
||||||
unsupportedIfPresent(input.compaction, "prune", ["compaction", "prune"], diagnostics)
|
|
||||||
const result: Record<string, unknown> = {}
|
|
||||||
if (own(input.compaction, "auto")) {
|
|
||||||
const value = decodeEncoded(
|
|
||||||
ConfigCompaction.Info.fields.auto,
|
|
||||||
input.compaction.auto,
|
|
||||||
["compaction", "auto"],
|
|
||||||
diagnostics,
|
|
||||||
)
|
|
||||||
if (value !== undefined) result.auto = value
|
|
||||||
}
|
|
||||||
const legacyTokens = own(input.compaction, "preserve_recent_tokens")
|
|
||||||
? decodeEncoded(
|
|
||||||
ConfigCompaction.Keep.fields.tokens,
|
|
||||||
input.compaction.preserve_recent_tokens,
|
|
||||||
["compaction", "preserve_recent_tokens"],
|
|
||||||
diagnostics,
|
|
||||||
)
|
|
||||||
: undefined
|
|
||||||
const nativeKeep = isRecord(input.compaction.keep) ? input.compaction.keep : undefined
|
|
||||||
if (own(input.compaction, "keep") && !nativeKeep) invalid(["compaction", "keep"], diagnostics)
|
|
||||||
const nativeTokens =
|
|
||||||
nativeKeep && own(nativeKeep, "tokens")
|
|
||||||
? decodeEncoded(
|
|
||||||
ConfigCompaction.Keep.fields.tokens,
|
|
||||||
nativeKeep.tokens,
|
|
||||||
["compaction", "keep", "tokens"],
|
|
||||||
diagnostics,
|
|
||||||
)
|
|
||||||
: undefined
|
|
||||||
const tokens = prefer(legacyTokens, nativeTokens, ["compaction", "keep", "tokens"], diagnostics)
|
|
||||||
if (tokens !== undefined) result.keep = { tokens }
|
|
||||||
const legacyBuffer = own(input.compaction, "reserved")
|
|
||||||
? decodeEncoded(
|
|
||||||
ConfigCompaction.Info.fields.buffer,
|
|
||||||
input.compaction.reserved,
|
|
||||||
["compaction", "reserved"],
|
|
||||||
diagnostics,
|
|
||||||
)
|
|
||||||
: undefined
|
|
||||||
const nativeBuffer = own(input.compaction, "buffer")
|
|
||||||
? decodeEncoded(ConfigCompaction.Info.fields.buffer, input.compaction.buffer, ["compaction", "buffer"], diagnostics)
|
|
||||||
: undefined
|
|
||||||
const buffer = prefer(legacyBuffer, nativeBuffer, ["compaction", "buffer"], diagnostics)
|
|
||||||
if (buffer !== undefined) result.buffer = buffer
|
|
||||||
if (Object.keys(result).length || !Object.keys(input.compaction).length) encoded.compaction = result
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeExperimental(
|
|
||||||
input: Record<string, unknown>,
|
|
||||||
encoded: Record<string, unknown>,
|
|
||||||
diagnostics: Diagnostic[],
|
|
||||||
) {
|
|
||||||
const result: Record<string, unknown> = {}
|
|
||||||
const generated: unknown[] = []
|
|
||||||
const enabled = decodeProviderList(input, "enabled_providers", diagnostics)
|
|
||||||
if (enabled.present && (!enabled.nonEmpty || enabled.values.length)) {
|
|
||||||
generated.push({ action: "provider.use", resource: "*", effect: "deny" })
|
|
||||||
generated.push(
|
|
||||||
...enabled.values.map((resource) => ({
|
|
||||||
action: "provider.use",
|
|
||||||
resource: ConfigMigrateV1.providerID(resource),
|
|
||||||
effect: "allow",
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const disabled = decodeProviderList(input, "disabled_providers", diagnostics)
|
|
||||||
generated.push(
|
|
||||||
...disabled.values.map((resource) => ({
|
|
||||||
action: "provider.use",
|
|
||||||
resource: ConfigMigrateV1.providerID(resource),
|
|
||||||
effect: "deny",
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
const native: unknown[] = []
|
|
||||||
if (own(input, "experimental")) {
|
|
||||||
if (!isRecord(input.experimental)) invalid(["experimental"], diagnostics)
|
|
||||||
if (isRecord(input.experimental)) {
|
|
||||||
const experimental = input.experimental
|
|
||||||
unsupportedExperimental.forEach((key) =>
|
|
||||||
unsupportedIfPresent(experimental, key, ["experimental", key], diagnostics),
|
|
||||||
)
|
|
||||||
if (own(experimental, "subagent_depth")) {
|
|
||||||
const value = decodeEncoded(
|
|
||||||
ConfigExperimental.Info.fields.subagent_depth,
|
|
||||||
experimental.subagent_depth,
|
|
||||||
["experimental", "subagent_depth"],
|
|
||||||
diagnostics,
|
|
||||||
)
|
|
||||||
if (value !== undefined) result.subagent_depth = value
|
|
||||||
}
|
|
||||||
native.push(
|
|
||||||
...decodeEncodedList(experimental.policies, ConfigPolicy.Info, ["experimental", "policies"], diagnostics),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (generated.length || native.length || (isRecord(input.experimental) && Array.isArray(input.experimental.policies)))
|
|
||||||
result.policies = [...generated, ...native]
|
|
||||||
if (Object.keys(result).length || (isRecord(input.experimental) && !Object.keys(input.experimental).length))
|
|
||||||
encoded.experimental = result
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeWatcher(input: Record<string, unknown>, encoded: Record<string, unknown>, diagnostics: Diagnostic[]) {
|
|
||||||
if (!own(input, "watcher")) return
|
|
||||||
if (!isRecord(input.watcher)) {
|
|
||||||
invalid(["watcher"], diagnostics)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const ignore = decodeEncodedList(input.watcher.ignore, Schema.String, ["watcher", "ignore"], diagnostics)
|
|
||||||
encoded.watcher = ignore.length || Array.isArray(input.watcher.ignore) ? { ignore } : {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeFormatter(
|
|
||||||
input: Record<string, unknown>,
|
|
||||||
encoded: Record<string, unknown>,
|
|
||||||
diagnostics: Diagnostic[],
|
|
||||||
) {
|
|
||||||
if (!own(input, "formatter")) return
|
|
||||||
if (typeof input.formatter === "boolean") {
|
|
||||||
const value = decodeEncoded(ConfigFormatter.Info, input.formatter, ["formatter"], diagnostics)
|
|
||||||
if (value !== undefined) encoded.formatter = value
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const entries = decodeEncodedMap(input.formatter, ConfigFormatter.Entry, ["formatter"], diagnostics)
|
|
||||||
if (isRecord(input.formatter) && (!Object.keys(input.formatter).length || Object.keys(entries).length))
|
|
||||||
encoded.formatter = entries
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeLsp(input: Record<string, unknown>, encoded: Record<string, unknown>, diagnostics: Diagnostic[]) {
|
|
||||||
if (!own(input, "lsp")) return
|
|
||||||
if (typeof input.lsp === "boolean") {
|
|
||||||
const value = decodeEncoded(ConfigLSP.Info, input.lsp, ["lsp"], diagnostics)
|
|
||||||
if (value !== undefined) encoded.lsp = value
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const entries = decodeEncodedMap(input.lsp, ConfigLSP.Entry, ["lsp"], diagnostics)
|
|
||||||
if (isRecord(input.lsp) && (!Object.keys(input.lsp).length || Object.keys(entries).length)) encoded.lsp = entries
|
|
||||||
}
|
|
||||||
|
|
||||||
function migrateTools(value: unknown, diagnostics: Diagnostic[]) {
|
|
||||||
if (value === undefined) return []
|
|
||||||
if (!isRecord(value)) {
|
|
||||||
invalid(["tools"], diagnostics)
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
return Object.entries(value).flatMap(([action, raw]) => {
|
|
||||||
const enabled = decodeValue(Schema.Boolean, raw, ["tools", action], diagnostics)
|
|
||||||
if (enabled === undefined) return []
|
|
||||||
return [{ action: ConfigMigrateV1.normalizeAction(action), resource: "*", effect: enabled ? "allow" : "deny" }]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function migratePermissions(value: unknown, diagnostics: Diagnostic[]) {
|
|
||||||
if (value === undefined) return []
|
|
||||||
if (typeof value === "string") {
|
|
||||||
const effect = decodeValue(ConfigPermissionV1.Action, value, ["permission"], diagnostics)
|
|
||||||
return effect === undefined ? [] : [{ action: "*", resource: "*", effect }]
|
|
||||||
}
|
|
||||||
if (!isRecord(value)) {
|
|
||||||
invalid(["permission"], diagnostics)
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
return Object.entries(value).flatMap(([action, raw]) => {
|
|
||||||
if (typeof raw === "string") {
|
|
||||||
const effect = decodeValue(ConfigPermissionV1.Action, raw, ["permission", action], diagnostics)
|
|
||||||
return effect === undefined ? [] : [{ action: ConfigMigrateV1.normalizeAction(action), resource: "*", effect }]
|
|
||||||
}
|
|
||||||
if (!isRecord(raw)) {
|
|
||||||
invalid(["permission", action], diagnostics)
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
return Object.entries(raw).flatMap(([resource, effect], index) => {
|
|
||||||
const decoded = decodeValue(ConfigPermissionV1.Action, effect, ["permission", action, String(index)], diagnostics)
|
|
||||||
return decoded === undefined
|
|
||||||
? []
|
|
||||||
: [{ action: ConfigMigrateV1.normalizeAction(action), resource, effect: decoded }]
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function migrateProviders(value: unknown, diagnostics: Diagnostic[]) {
|
|
||||||
if (value === undefined) return {}
|
|
||||||
if (!isRecord(value)) {
|
|
||||||
invalid(["provider"], diagnostics)
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
const candidates = Object.entries(value).flatMap(([name, raw]) => {
|
|
||||||
const path = ["provider", name]
|
|
||||||
diagnoseProviderUnsupported(raw, path, diagnostics)
|
|
||||||
if (invalidProviderOverlays(raw, path, diagnostics)) return []
|
|
||||||
const provider = decodeValue(ConfigProviderV1.Info, raw, path, diagnostics)
|
|
||||||
if (provider === undefined) return []
|
|
||||||
const destination = ConfigMigrateV1.providerID(name)
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
name,
|
|
||||||
destination,
|
|
||||||
provider: canonical(ConfigProvider.Info, ConfigMigrateV1.migrateProvider(name, provider)),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
})
|
|
||||||
const current = new Set(candidates.filter((item) => item.name === item.destination).map((item) => item.destination))
|
|
||||||
const result: Record<string, unknown> = {}
|
|
||||||
candidates.forEach((item) => {
|
|
||||||
if (item.name !== item.destination && current.has(item.destination)) return
|
|
||||||
setOwn(result, item.destination, item.provider)
|
|
||||||
})
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
function invalidProviderOverlays(value: unknown, path: string[], diagnostics: Diagnostic[]) {
|
|
||||||
if (!isRecord(value) || !isRecord(value.options)) return false
|
|
||||||
const headersInvalid =
|
|
||||||
own(value.options, "headers") &&
|
|
||||||
(!isPlainRecord(value.options.headers) ||
|
|
||||||
Object.values(value.options.headers).some((item) => typeof item !== "string"))
|
|
||||||
const bodyInvalid = own(value.options, "body") && !isPlainRecord(value.options.body)
|
|
||||||
if (headersInvalid) invalid([...path, "options", "headers"], diagnostics)
|
|
||||||
if (bodyInvalid) invalid([...path, "options", "body"], diagnostics)
|
|
||||||
return headersInvalid || bodyInvalid
|
|
||||||
}
|
|
||||||
|
|
||||||
function diagnoseProviderUnsupported(value: unknown, path: string[], diagnostics: Diagnostic[]) {
|
|
||||||
if (!isRecord(value)) return
|
|
||||||
unsupportedProvider.forEach((key) => unsupportedIfPresent(value, key, [...path, key], diagnostics))
|
|
||||||
if (!isRecord(value.models)) return
|
|
||||||
Object.entries(value.models).forEach(([name, model]) => {
|
|
||||||
if (!isRecord(model)) return
|
|
||||||
unsupportedModel.forEach((key) => unsupportedIfPresent(model, key, [...path, "models", name, key], diagnostics))
|
|
||||||
if (own(model, "status") && model.status !== "deprecated")
|
|
||||||
unsupportedIfPresent(model, "status", [...path, "models", name, "status"], diagnostics)
|
|
||||||
if (own(model, "interleaved") && typeof model.interleaved === "boolean")
|
|
||||||
unsupportedIfPresent(model, "interleaved", [...path, "models", name, "interleaved"], diagnostics)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function diagnoseAgentUnsupported(value: unknown, path: string[], diagnostics: Diagnostic[]) {
|
|
||||||
if (!isRecord(value)) return
|
|
||||||
Object.entries(value).forEach(([name, agent]) => {
|
|
||||||
if (!isRecord(agent)) return
|
|
||||||
unsupportedIfPresent(agent, "name", [...path, name, "name"], diagnostics)
|
|
||||||
diagnoseSelection(agent, [...path, name], diagnostics)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function diagnoseSelectionMap(value: unknown, path: string[], diagnostics: Diagnostic[]) {
|
|
||||||
if (!isRecord(value)) return
|
|
||||||
Object.entries(value).forEach(([name, entry]) => {
|
|
||||||
if (isRecord(entry)) diagnoseSelection(entry, [...path, name], diagnostics)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function diagnoseSelection(value: Record<string, unknown>, path: string[], diagnostics: Diagnostic[]) {
|
|
||||||
const modelValid = typeof value.model === "string" && /^[^/#]+\/[^#]+$/.test(value.model)
|
|
||||||
if (own(value, "model") && typeof value.model === "string" && !modelValid)
|
|
||||||
diagnostics.push({
|
|
||||||
kind: "unsupported",
|
|
||||||
path: [...path, "model"],
|
|
||||||
message: "omitted unsupported legacy model reference",
|
|
||||||
})
|
|
||||||
if (
|
|
||||||
own(value, "variant") &&
|
|
||||||
typeof value.variant === "string" &&
|
|
||||||
(!modelValid || value.variant.length === 0 || value.variant.includes("#"))
|
|
||||||
)
|
|
||||||
diagnostics.push({
|
|
||||||
kind: "unsupported",
|
|
||||||
path: [...path, "variant"],
|
|
||||||
message: "omitted unsupported legacy model variant",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeProviderList(
|
|
||||||
input: Record<string, unknown>,
|
|
||||||
key: "enabled_providers" | "disabled_providers",
|
|
||||||
diagnostics: Diagnostic[],
|
|
||||||
) {
|
|
||||||
if (!own(input, key)) return { present: false, nonEmpty: false, values: [] as string[] }
|
|
||||||
if (!Array.isArray(input[key])) {
|
|
||||||
invalid([key], diagnostics)
|
|
||||||
return { present: true, nonEmpty: true, values: [] as string[] }
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
present: true,
|
|
||||||
nonEmpty: input[key].length > 0,
|
|
||||||
values: decodeList(input[key], Schema.String, [key], diagnostics),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeEncodedMap<S extends Schema.Codec<unknown, unknown, never, never>>(
|
|
||||||
value: unknown,
|
|
||||||
schema: S,
|
|
||||||
path: string[],
|
|
||||||
diagnostics: Diagnostic[],
|
|
||||||
) {
|
|
||||||
if (value === undefined) return {}
|
|
||||||
if (!isRecord(value)) {
|
|
||||||
invalid(path, diagnostics)
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
return Object.fromEntries(
|
|
||||||
Object.entries(value).flatMap(([name, raw]) => {
|
|
||||||
const decoded = decodeEncoded(schema, raw, [...path, name], diagnostics)
|
|
||||||
return decoded === undefined ? [] : [[name, decoded]]
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeMap<S extends Schema.Codec<unknown, unknown, never, never>>(
|
|
||||||
value: unknown,
|
|
||||||
schema: S,
|
|
||||||
path: string[],
|
|
||||||
diagnostics: Diagnostic[],
|
|
||||||
) {
|
|
||||||
if (value === undefined) return {} as Record<string, S["Type"]>
|
|
||||||
if (!isRecord(value)) {
|
|
||||||
invalid(path, diagnostics)
|
|
||||||
return {} as Record<string, S["Type"]>
|
|
||||||
}
|
|
||||||
return Object.fromEntries(
|
|
||||||
Object.entries(value).flatMap(([name, raw]) => {
|
|
||||||
const decoded = decodeValue(schema, raw, [...path, name], diagnostics)
|
|
||||||
return decoded === undefined ? [] : [[name, decoded]]
|
|
||||||
}),
|
|
||||||
) as Record<string, S["Type"]>
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeEncodedList<S extends Schema.Codec<unknown, unknown, never, never>>(
|
|
||||||
value: unknown,
|
|
||||||
schema: S,
|
|
||||||
path: string[],
|
|
||||||
diagnostics: Diagnostic[],
|
|
||||||
) {
|
|
||||||
if (value === undefined) return [] as S["Encoded"][]
|
|
||||||
if (!Array.isArray(value)) {
|
|
||||||
invalid(path, diagnostics)
|
|
||||||
return [] as S["Encoded"][]
|
|
||||||
}
|
|
||||||
return value.flatMap((item, index) => {
|
|
||||||
const decoded = decodeEncoded(schema, item, [...path, String(index)], diagnostics)
|
|
||||||
return decoded === undefined ? [] : [decoded]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeList<S extends Schema.Codec<unknown, unknown, never, never>>(
|
|
||||||
value: unknown,
|
|
||||||
schema: S,
|
|
||||||
path: string[],
|
|
||||||
diagnostics: Diagnostic[],
|
|
||||||
) {
|
|
||||||
if (value === undefined) return [] as S["Type"][]
|
|
||||||
if (!Array.isArray(value)) {
|
|
||||||
invalid(path, diagnostics)
|
|
||||||
return [] as S["Type"][]
|
|
||||||
}
|
|
||||||
return value.flatMap((item, index) => {
|
|
||||||
const decoded = decodeValue(schema, item, [...path, String(index)], diagnostics)
|
|
||||||
return decoded === undefined ? [] : [decoded]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeValue<S extends Schema.Codec<unknown, unknown, never, never>>(
|
|
||||||
schema: S,
|
|
||||||
value: unknown,
|
|
||||||
path: string[],
|
|
||||||
diagnostics: Diagnostic[],
|
|
||||||
) {
|
|
||||||
const decoded = Schema.decodeUnknownOption(schema, options)(value)
|
|
||||||
if (Option.isSome(decoded)) return decoded.value
|
|
||||||
invalid(path, diagnostics)
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeEncoded<S extends Schema.Codec<unknown, unknown, never, never>>(
|
|
||||||
schema: S,
|
|
||||||
value: unknown,
|
|
||||||
path: string[],
|
|
||||||
diagnostics: Diagnostic[],
|
|
||||||
) {
|
|
||||||
const decoded = Schema.decodeUnknownOption(schema, options)(value)
|
|
||||||
if (Option.isNone(decoded)) {
|
|
||||||
invalid(path, diagnostics)
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
const encoded = Schema.encodeUnknownOption(schema, options)(decoded.value)
|
|
||||||
if (Option.isSome(encoded)) return plain(encoded.value)
|
|
||||||
invalid(path, diagnostics)
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function canonical<S extends Schema.Codec<unknown, unknown, never, never>>(schema: S, value: unknown) {
|
|
||||||
return plain(
|
|
||||||
Option.getOrThrow(
|
|
||||||
Schema.decodeUnknownOption(
|
|
||||||
schema,
|
|
||||||
options,
|
|
||||||
)(plain(value)).pipe(Option.flatMap((decoded) => Schema.encodeUnknownOption(schema, options)(decoded))),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function plain(value: unknown): unknown {
|
|
||||||
if (Array.isArray(value)) return value.map(plain)
|
|
||||||
if (!isRecord(value)) return value
|
|
||||||
return Object.fromEntries(
|
|
||||||
Object.entries(value).flatMap(([key, item]) => (item === undefined ? [] : [[key, plain(item)]])),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeMap(
|
|
||||||
target: Record<string, unknown>,
|
|
||||||
key: string,
|
|
||||||
legacy: Readonly<Record<string, unknown>>,
|
|
||||||
native: Readonly<Record<string, unknown>>,
|
|
||||||
present: boolean,
|
|
||||||
diagnostics: Diagnostic[],
|
|
||||||
) {
|
|
||||||
const merged = mergeMaps(legacy, native, [key], diagnostics)
|
|
||||||
if (present) target[key] = merged
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeMaps(
|
|
||||||
legacy: Readonly<Record<string, unknown>>,
|
|
||||||
native: Readonly<Record<string, unknown>>,
|
|
||||||
path: string[],
|
|
||||||
diagnostics: Diagnostic[],
|
|
||||||
) {
|
|
||||||
const result = Object.fromEntries(Object.entries(legacy))
|
|
||||||
Object.entries(native).forEach(([name, value]) => {
|
|
||||||
if (own(result, name) && !isDeepStrictEqual(result[name], value)) conflict([...path, name], diagnostics)
|
|
||||||
setOwn(result, name, value)
|
|
||||||
})
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapValues<A>(input: Readonly<Record<string, A>>, map: (value: A) => unknown) {
|
|
||||||
return Object.fromEntries(
|
|
||||||
Object.entries(input).flatMap(([key, value]) => {
|
|
||||||
const mapped = map(value)
|
|
||||||
return mapped === undefined ? [] : [[key, mapped]]
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function overlay(
|
|
||||||
target: Record<string, unknown>,
|
|
||||||
key: string,
|
|
||||||
value: unknown,
|
|
||||||
path: string[],
|
|
||||||
diagnostics: Diagnostic[],
|
|
||||||
) {
|
|
||||||
if (own(target, key) && !isDeepStrictEqual(target[key], value)) conflict(path, diagnostics)
|
|
||||||
target[key] = value
|
|
||||||
}
|
|
||||||
|
|
||||||
function prefer(legacy: unknown, native: unknown, path: string[], diagnostics: Diagnostic[]) {
|
|
||||||
if (native === undefined) return legacy
|
|
||||||
if (legacy !== undefined && !isDeepStrictEqual(legacy, native)) conflict(path, diagnostics)
|
|
||||||
return native
|
|
||||||
}
|
|
||||||
|
|
||||||
function unsupportedIfPresent(value: Record<string, unknown>, key: string, path: string[], diagnostics: Diagnostic[]) {
|
|
||||||
if (!own(value, key)) return
|
|
||||||
diagnostics.push({ kind: "unsupported", path, message: "omitted unsupported legacy setting" })
|
|
||||||
}
|
|
||||||
|
|
||||||
function invalid(path: string[], diagnostics: Diagnostic[]) {
|
|
||||||
diagnostics.push({ kind: "invalid", path, message: "skipped malformed recognized value" })
|
|
||||||
}
|
|
||||||
|
|
||||||
function conflict(path: string[], diagnostics: Diagnostic[]) {
|
|
||||||
diagnostics.push({ kind: "conflict", path, message: "retained native value over legacy value" })
|
|
||||||
}
|
|
||||||
|
|
||||||
function isDirectLegacyMcp(value: unknown) {
|
|
||||||
return isRecord(value) && (value.type === "local" || value.type === "remote")
|
|
||||||
}
|
|
||||||
|
|
||||||
function isEnabledOnlyMcp(value: unknown) {
|
|
||||||
return isRecord(value) && !own(value, "type") && typeof value.enabled === "boolean"
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
if (!isRecord(value)) return false
|
|
||||||
const prototype = Object.getPrototypeOf(value)
|
|
||||||
return prototype === Object.prototype || prototype === null
|
|
||||||
}
|
|
||||||
|
|
||||||
function own(value: Record<string, unknown>, key: string) {
|
|
||||||
return Object.prototype.hasOwnProperty.call(value, key)
|
|
||||||
}
|
|
||||||
|
|
||||||
function setOwn(value: Record<string, unknown>, key: string, item: unknown) {
|
|
||||||
Object.defineProperty(value, key, { value: item, enumerable: true, configurable: true, writable: true })
|
|
||||||
}
|
|
||||||
@@ -161,14 +161,11 @@ function isPathAction(action: string): action is PathAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function expandHome(resource: string, home: string) {
|
function expandHome(resource: string, home: string) {
|
||||||
|
if (resource.startsWith("~/")) return home + resource.slice(1)
|
||||||
if (resource === "~") return home
|
if (resource === "~") return home
|
||||||
if (resource === "$HOME") return home
|
if (resource === "$HOME") return home
|
||||||
const relative = resource.startsWith("~/")
|
if (resource.startsWith("$HOME/")) return home + resource.slice(5)
|
||||||
? resource.slice(2)
|
if (resource.startsWith("$HOME\\")) return home + resource.slice(5)
|
||||||
: resource.startsWith("$HOME/") || resource.startsWith("$HOME\\")
|
|
||||||
? resource.slice(6)
|
|
||||||
: undefined
|
|
||||||
if (relative !== undefined) return (path.posix.isAbsolute(home) ? path.posix : path.win32).join(home, relative)
|
|
||||||
return resource
|
return resource
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
|||||||
import { Bom } from "@opencode-ai/util/bom"
|
import { Bom } from "@opencode-ai/util/bom"
|
||||||
|
|
||||||
export interface Target {
|
export interface Target {
|
||||||
readonly absolute: string
|
readonly canonical: string
|
||||||
readonly resource: string
|
readonly resource: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ export interface Interface {
|
|||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/FileMutation") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/FileMutation") {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serialize file changes by absolute target. Conditional writes compare and
|
* Serialize file changes by canonical target. Conditional writes compare and
|
||||||
* write under the same process-local lock so cooperating OpenCode mutations do
|
* write under the same process-local lock so cooperating OpenCode mutations do
|
||||||
* not overwrite changes made from the same stale content.
|
* not overwrite changes made from the same stale content.
|
||||||
*/
|
*/
|
||||||
@@ -49,11 +49,11 @@ const layer = Layer.effect(
|
|||||||
const withTargetLock =
|
const withTargetLock =
|
||||||
(target: Target) =>
|
(target: Target) =>
|
||||||
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||||
locks.withLock(target.absolute)(Effect.uninterruptible(effect))
|
locks.withLock(target.canonical)(Effect.uninterruptible(effect))
|
||||||
|
|
||||||
const writeResult = (target: Target, existed: boolean): WriteResult => ({
|
const writeResult = (target: Target, existed: boolean): WriteResult => ({
|
||||||
operation: "write",
|
operation: "write",
|
||||||
target: target.absolute,
|
target: target.canonical,
|
||||||
resource: target.resource,
|
resource: target.resource,
|
||||||
existed,
|
existed,
|
||||||
})
|
})
|
||||||
@@ -61,8 +61,8 @@ const layer = Layer.effect(
|
|||||||
const write = Effect.fn("FileMutation.write")((input: WriteInput) =>
|
const write = Effect.fn("FileMutation.write")((input: WriteInput) =>
|
||||||
withTargetLock(input.target)(
|
withTargetLock(input.target)(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const existed = yield* fs.exists(input.target.absolute)
|
const existed = yield* fs.exists(input.target.canonical)
|
||||||
yield* fs.writeWithDirs(input.target.absolute, input.content)
|
yield* fs.writeWithDirs(input.target.canonical, input.content)
|
||||||
return writeResult(input.target, existed)
|
return writeResult(input.target, existed)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -73,10 +73,10 @@ const layer = Layer.effect(
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const next = Bom.split(input.content)
|
const next = Bom.split(input.content)
|
||||||
const current = yield* fs
|
const current = yield* fs
|
||||||
.readFile(input.target.absolute)
|
.readFile(input.target.canonical)
|
||||||
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
|
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
|
||||||
yield* fs.writeWithDirs(
|
yield* fs.writeWithDirs(
|
||||||
input.target.absolute,
|
input.target.canonical,
|
||||||
Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom),
|
Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom),
|
||||||
)
|
)
|
||||||
return writeResult(input.target, current !== undefined)
|
return writeResult(input.target, current !== undefined)
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ export const layer = Layer.effect(
|
|||||||
id,
|
id,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
title: input.title,
|
title: input.title,
|
||||||
|
...(input.coalesce === undefined ? {} : { coalesce: input.coalesce }),
|
||||||
...(input.metadata === undefined ? {} : { metadata: input.metadata }),
|
...(input.metadata === undefined ? {} : { metadata: input.metadata }),
|
||||||
fields: input.fields,
|
fields: input.fields,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,9 +23,14 @@ export const ResolveInput = Schema.Struct({
|
|||||||
})
|
})
|
||||||
export type ResolveInput = typeof ResolveInput.Type
|
export type ResolveInput = typeof ResolveInput.Type
|
||||||
|
|
||||||
|
export class PathError extends Schema.TaggedErrorClass<PathError>()("LocationMutation.PathError", {
|
||||||
|
path: Schema.String,
|
||||||
|
reason: Schema.Literal("non_directory_ancestor"),
|
||||||
|
}) {}
|
||||||
|
|
||||||
export interface ExternalDirectoryAuthorization {
|
export interface ExternalDirectoryAuthorization {
|
||||||
readonly action: "external_directory"
|
readonly action: "external_directory"
|
||||||
/** Lexical directory used as the external approval boundary. */
|
/** Canonical existing directory used as the external approval boundary. */
|
||||||
readonly directory: string
|
readonly directory: string
|
||||||
/** `external_directory` permission resource. */
|
/** `external_directory` permission resource. */
|
||||||
readonly resource: string
|
readonly resource: string
|
||||||
@@ -39,9 +44,9 @@ export const externalDirectoryPermission = (input: ExternalDirectoryAuthorizatio
|
|||||||
})
|
})
|
||||||
|
|
||||||
export interface Target {
|
export interface Target {
|
||||||
/** Absolute lexical path. */
|
/** Canonical existing path, or missing path below a canonical directory. */
|
||||||
readonly absolute: string
|
readonly canonical: string
|
||||||
/** Permission resource: Location-relative for internal paths, absolute for external paths. */
|
/** Permission resource: Location-relative for internal paths, canonical for external paths. */
|
||||||
readonly resource: string
|
readonly resource: string
|
||||||
readonly externalDirectory?: ExternalDirectoryAuthorization
|
readonly externalDirectory?: ExternalDirectoryAuthorization
|
||||||
}
|
}
|
||||||
@@ -52,11 +57,25 @@ export interface Interface {
|
|||||||
* from the Location. Paths outside it require separate `external_directory`
|
* from the Location. Paths outside it require separate `external_directory`
|
||||||
* approval. This does not approve the mutation.
|
* approval. This does not approve the mutation.
|
||||||
*/
|
*/
|
||||||
readonly resolve: (input: ResolveInput) => Effect.Effect<Target, FSUtil.Error>
|
readonly resolve: (input: ResolveInput) => Effect.Effect<Target, PathError | FSUtil.Error>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LocationMutation") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/LocationMutation") {}
|
||||||
|
|
||||||
|
interface ResolvedPath {
|
||||||
|
readonly canonical: string
|
||||||
|
readonly type?:
|
||||||
|
| "File"
|
||||||
|
| "Directory"
|
||||||
|
| "SymbolicLink"
|
||||||
|
| "BlockDevice"
|
||||||
|
| "CharacterDevice"
|
||||||
|
| "FIFO"
|
||||||
|
| "Socket"
|
||||||
|
| "Unknown"
|
||||||
|
readonly directory: string
|
||||||
|
}
|
||||||
|
|
||||||
const slash = (value: string) => value.replaceAll("\\", "/")
|
const slash = (value: string) => value.replaceAll("\\", "/")
|
||||||
|
|
||||||
const layer = Layer.effect(
|
const layer = Layer.effect(
|
||||||
@@ -65,33 +84,65 @@ const layer = Layer.effect(
|
|||||||
const fs = yield* FSUtil.Service
|
const fs = yield* FSUtil.Service
|
||||||
const location = yield* Location.Service
|
const location = yield* Location.Service
|
||||||
|
|
||||||
|
function notFound<A>(effect: Effect.Effect<A, FSUtil.Error>) {
|
||||||
|
return effect.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvePath = Effect.fnUntraced(function* (absolute: string) {
|
||||||
|
const existing = yield* notFound(fs.realPath(absolute))
|
||||||
|
if (existing !== undefined) {
|
||||||
|
const info = yield* fs.stat(existing)
|
||||||
|
return {
|
||||||
|
canonical: existing,
|
||||||
|
type: info.type,
|
||||||
|
directory: info.type === "Directory" ? existing : path.dirname(existing),
|
||||||
|
} satisfies ResolvedPath
|
||||||
|
}
|
||||||
|
|
||||||
|
let anchor = path.dirname(absolute)
|
||||||
|
while (true) {
|
||||||
|
const canonical = yield* notFound(fs.realPath(anchor))
|
||||||
|
if (canonical !== undefined) {
|
||||||
|
const info = yield* fs.stat(canonical)
|
||||||
|
if (info.type !== "Directory") {
|
||||||
|
return yield* new PathError({ path: absolute, reason: "non_directory_ancestor" })
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
canonical: path.resolve(canonical, path.relative(anchor, absolute)),
|
||||||
|
directory: canonical,
|
||||||
|
} satisfies ResolvedPath
|
||||||
|
}
|
||||||
|
const parent = path.dirname(anchor)
|
||||||
|
if (parent === anchor) return yield* new PathError({ path: absolute, reason: "non_directory_ancestor" })
|
||||||
|
anchor = parent
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) {
|
const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) {
|
||||||
const absolute = path.resolve(location.directory, input.path)
|
const absolute = path.resolve(location.directory, input.path)
|
||||||
if (FSUtil.contains(location.directory, absolute)) {
|
// External access follows the requested path boundary. Symlinks reached through an
|
||||||
return {
|
// internal path intentionally retain internal permission semantics after canonicalization.
|
||||||
absolute,
|
const lexicallyInternal = FSUtil.contains(location.directory, absolute)
|
||||||
resource: slash(path.relative(location.directory, absolute) || "."),
|
|
||||||
} satisfies Target
|
const resolved = yield* resolvePath(absolute)
|
||||||
}
|
const external = !lexicallyInternal
|
||||||
const type =
|
const resource = external ? slash(resolved.canonical) : slash(path.relative(location.directory, absolute) || ".")
|
||||||
input.kind === "directory"
|
const externalDirectory =
|
||||||
? "Directory"
|
input.kind === "directory" && resolved.type === "Directory" ? resolved.canonical : resolved.directory
|
||||||
: (yield* fs
|
|
||||||
.stat(absolute)
|
|
||||||
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined))))?.type
|
|
||||||
const externalDirectory = type === "Directory" ? absolute : path.dirname(absolute)
|
|
||||||
const externalResource = slash(path.join(externalDirectory, "*"))
|
const externalResource = slash(path.join(externalDirectory, "*"))
|
||||||
return {
|
return {
|
||||||
absolute,
|
canonical: resolved.canonical,
|
||||||
resource: slash(absolute),
|
resource,
|
||||||
externalDirectory: {
|
externalDirectory: external
|
||||||
|
? {
|
||||||
action: "external_directory",
|
action: "external_directory",
|
||||||
directory: externalDirectory,
|
directory: externalDirectory,
|
||||||
resource: externalResource,
|
resource: externalResource,
|
||||||
save: slash(
|
save: slash(
|
||||||
path.join((yield* Project.root(fs, AbsolutePath.make(externalDirectory))) ?? externalDirectory, "*"),
|
path.join((yield* Project.root(fs, AbsolutePath.make(externalDirectory))) ?? externalDirectory, "*"),
|
||||||
),
|
),
|
||||||
},
|
}
|
||||||
|
: undefined,
|
||||||
} satisfies Target
|
} satisfies Target
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ import { SessionGenerateNode } from "./session/generate-node"
|
|||||||
import { McpTool } from "./tool/mcp"
|
import { McpTool } from "./tool/mcp"
|
||||||
import { ReadToolFileSystem } from "./tool/read-filesystem"
|
import { ReadToolFileSystem } from "./tool/read-filesystem"
|
||||||
import { Tool } from "./tool"
|
import { Tool } from "./tool"
|
||||||
import { ToolOutput } from "./tool-output"
|
|
||||||
import { Vcs } from "./vcs"
|
import { Vcs } from "./vcs"
|
||||||
|
|
||||||
export { LocationServiceMap } from "./location-service-map"
|
export { LocationServiceMap } from "./location-service-map"
|
||||||
@@ -79,7 +78,6 @@ const locationServiceNodes = [
|
|||||||
MCP.node,
|
MCP.node,
|
||||||
Permission.node,
|
Permission.node,
|
||||||
Tool.node,
|
Tool.node,
|
||||||
ToolOutput.node,
|
|
||||||
Image.node,
|
Image.node,
|
||||||
SkillInstructions.node,
|
SkillInstructions.node,
|
||||||
ReferenceInstructions.node,
|
ReferenceInstructions.node,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export * as PluginPromise from "./promise"
|
|||||||
|
|
||||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||||
import type { Context, Plugin } from "@opencode-ai/plugin/promise/plugin"
|
import type { Context, Plugin } from "@opencode-ai/plugin/promise/plugin"
|
||||||
|
import type { SessionHooks, SessionHttp, SessionHttpMiddleware } from "@opencode-ai/plugin/promise/session"
|
||||||
import type { Info } from "@opencode-ai/plugin/promise/tool"
|
import type { Info } from "@opencode-ai/plugin/promise/tool"
|
||||||
import { Agent } from "@opencode-ai/schema/agent"
|
import { Agent } from "@opencode-ai/schema/agent"
|
||||||
import { Integration } from "@opencode-ai/schema/integration"
|
import { Integration } from "@opencode-ai/schema/integration"
|
||||||
@@ -57,6 +58,62 @@ export function fromPromise(plugin: Plugin) {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
function sessionHook<Name extends keyof SessionHooks>(
|
||||||
|
name: Name,
|
||||||
|
callback: (event: SessionHooks[Name]) => Promise<void> | void,
|
||||||
|
): Promise<Registration>
|
||||||
|
function sessionHook(
|
||||||
|
...registration: {
|
||||||
|
[Name in keyof SessionHooks]: [
|
||||||
|
name: Name,
|
||||||
|
callback: (event: SessionHooks[Name]) => Promise<void> | void,
|
||||||
|
]
|
||||||
|
}[keyof SessionHooks]
|
||||||
|
) {
|
||||||
|
if (registration[0] !== "http")
|
||||||
|
return register(
|
||||||
|
host.session.hook(registration[0], (event) =>
|
||||||
|
Effect.promise(() => Promise.resolve(registration[1](event))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return register(
|
||||||
|
host.session.hook("http", (event) => {
|
||||||
|
const middlewares: SessionHttpMiddleware[] = []
|
||||||
|
const output: SessionHttp = {
|
||||||
|
...event,
|
||||||
|
use: (item) => {
|
||||||
|
middlewares.push(item)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return Effect.promise(() => Promise.resolve(registration[1](output))).pipe(
|
||||||
|
Effect.flatMap(() =>
|
||||||
|
Effect.forEach(
|
||||||
|
middlewares,
|
||||||
|
(item) =>
|
||||||
|
event.use((input, next) =>
|
||||||
|
Effect.tryPromise({
|
||||||
|
try: (signal) => {
|
||||||
|
const inputSignal = AbortSignal.any([signal, input.signal])
|
||||||
|
return Promise.resolve(
|
||||||
|
item(new Request(input, { signal: inputSignal }), (request) => {
|
||||||
|
const requestSignal = AbortSignal.any([signal, request.signal])
|
||||||
|
return Effect.runPromiseWith(
|
||||||
|
context,
|
||||||
|
)(next(new Request(request, { signal: requestSignal })), { signal: requestSignal })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
{ discard: true },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const context2: Context = {
|
const context2: Context = {
|
||||||
app: host.app,
|
app: host.app,
|
||||||
options: host.options,
|
options: host.options,
|
||||||
@@ -265,8 +322,7 @@ export function fromPromise(plugin: Plugin) {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
hook: (name, callback) =>
|
hook: sessionHook,
|
||||||
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
|
||||||
create: (input) =>
|
create: (input) =>
|
||||||
run(
|
run(
|
||||||
host.session.create(
|
host.session.create(
|
||||||
|
|||||||
@@ -221,18 +221,18 @@ export const OpenAIPlugin = define({
|
|||||||
}
|
}
|
||||||
draft.cost = []
|
draft.cost = []
|
||||||
// Match Codex CLI so context consumption and subscription usage stay consistent between clients.
|
// Match Codex CLI so context consumption and subscription usage stay consistent between clients.
|
||||||
draft.limit = { ...draft.limit, context: 400_000, input: 272_000 }
|
draft.limit = { ...draft.limit, context: 272_000, input: 272_000 }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
yield* ctx.session.hook("http.request", (evt) =>
|
yield* ctx.session.hook("http", (evt) =>
|
||||||
Effect.sync(() => {
|
evt.use((request, next) => {
|
||||||
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return
|
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return next(request)
|
||||||
const url = new URL(evt.request.url)
|
const url = new URL(request.url)
|
||||||
evt.request.headers.set("originator", "opencode")
|
request.headers.set("originator", "opencode")
|
||||||
evt.request.headers.set("session-id", evt.sessionID)
|
request.headers.set("session-id", evt.sessionID)
|
||||||
if (url.origin !== "https://api.openai.com") return
|
if (url.origin !== "https://api.openai.com") return next(request)
|
||||||
evt.request = new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, evt.request)
|
return next(new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, request))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import type { Info } from "../model"
|
|||||||
import { SessionUsage } from "./usage"
|
import { SessionUsage } from "./usage"
|
||||||
|
|
||||||
const DEFAULT_BUFFER = 20_000
|
const DEFAULT_BUFFER = 20_000
|
||||||
const DEFAULT_KEEP_TOKENS = 15_000
|
const DEFAULT_KEEP_TOKENS = 8_000
|
||||||
const OUTPUT_TOKEN_MAX = 32_000
|
const OUTPUT_TOKEN_MAX = 32_000
|
||||||
const TOOL_OUTPUT_MAX_CHARS = 2_000
|
const TOOL_OUTPUT_MAX_CHARS = 2_000
|
||||||
const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
|
const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export * as SessionModelRequest from "./model-request"
|
|||||||
|
|
||||||
import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
|
import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
|
||||||
import type { StreamOptions } from "@opencode-ai/ai/route"
|
import type { StreamOptions } from "@opencode-ai/ai/route"
|
||||||
|
import type { SessionHttpHandler, SessionHttpMiddleware } from "@opencode-ai/plugin/effect/session"
|
||||||
import type { Content } from "@opencode-ai/schema/tool"
|
import type { Content } from "@opencode-ai/schema/tool"
|
||||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||||
import { Cause, Config, Context, Effect, Layer, Result, Stream } from "effect"
|
import { Cause, Config, Context, Effect, Layer, Result, Stream } from "effect"
|
||||||
@@ -229,31 +230,44 @@ export const layer = Layer.effect(
|
|||||||
const options: StreamOptions = {
|
const options: StreamOptions = {
|
||||||
http: (request, handler) =>
|
http: (request, handler) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const before = yield* hooks.trigger("session", "http.request", {
|
let latest = request
|
||||||
|
const origins = new WeakMap<Response, HttpClientRequest.HttpClientRequest>()
|
||||||
|
const middlewares: SessionHttpMiddleware[] = []
|
||||||
|
const web = yield* HttpClientRequest.toWeb(request)
|
||||||
|
yield* hooks.trigger("session", "http", {
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
agent: agent.id,
|
agent: agent.id,
|
||||||
model: resolved.ref,
|
model: resolved.ref,
|
||||||
request: yield* HttpClientRequest.toWeb(request),
|
use: (item) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
middlewares.push(item)
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
let sent = HttpClientRequest.fromWeb(before.request)
|
const send = (input: Request) =>
|
||||||
if (before.request.body)
|
Effect.gen(function* () {
|
||||||
|
let sent = HttpClientRequest.fromWeb(input)
|
||||||
|
if (input.body)
|
||||||
sent = HttpClientRequest.bodyUint8Array(
|
sent = HttpClientRequest.bodyUint8Array(
|
||||||
sent,
|
sent,
|
||||||
new Uint8Array(yield* Effect.promise(() => before.request.clone().arrayBuffer())),
|
new Uint8Array(yield* Effect.promise(() => input.clone().arrayBuffer())),
|
||||||
before.request.headers.get("content-type") ?? undefined,
|
input.headers.get("content-type") ?? undefined,
|
||||||
)
|
)
|
||||||
|
latest = sent
|
||||||
const response = yield* handler(sent)
|
const response = yield* handler(sent)
|
||||||
const after = yield* hooks.trigger("session", "http.response", {
|
const body = [204, 205, 304].includes(response.status)
|
||||||
sessionID: session.id,
|
? null
|
||||||
agent: agent.id,
|
: yield* Stream.toReadableStreamEffect(response.stream)
|
||||||
model: resolved.ref,
|
const output = new Response(body, { status: response.status, headers: response.headers })
|
||||||
request: before.request,
|
origins.set(output, sent)
|
||||||
response: new Response(
|
return output
|
||||||
[204, 205, 304].includes(response.status) ? null : yield* Stream.toReadableStreamEffect(response.stream),
|
|
||||||
{ status: response.status, headers: response.headers },
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
return HttpClientResponse.fromWeb(sent, after.response)
|
const dispatch = middlewares.reduce<SessionHttpHandler>(
|
||||||
|
(next, item) => (input: Request) => item(input, next),
|
||||||
|
send,
|
||||||
|
)
|
||||||
|
const response = yield* dispatch(web)
|
||||||
|
const origin = origins.get(response) ?? latest
|
||||||
|
return HttpClientResponse.fromWeb(origin, response)
|
||||||
}).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
|
}).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
|
||||||
}
|
}
|
||||||
if (promptCacheSnapshots) {
|
if (promptCacheSnapshots) {
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ import { StepFailedError } from "../error"
|
|||||||
import { toSessionError } from "../to-session-error"
|
import { toSessionError } from "../to-session-error"
|
||||||
import { SessionRunnerRetry } from "./retry"
|
import { SessionRunnerRetry } from "./retry"
|
||||||
import { SessionUsage } from "../usage"
|
import { SessionUsage } from "../usage"
|
||||||
import { ToolOutput } from "../../tool-output"
|
|
||||||
|
|
||||||
/** How one model call ended: settled, awaiting a scheduled retry, or restarted by compaction. */
|
/** How one model call ended: settled, awaiting a scheduled retry, or restarted by compaction. */
|
||||||
type CallOutcome = Data.TaggedEnum<{
|
type CallOutcome = Data.TaggedEnum<{
|
||||||
@@ -108,7 +107,6 @@ const layer = Layer.effect(
|
|||||||
const db = (yield* Database.Service).db
|
const db = (yield* Database.Service).db
|
||||||
const compaction = yield* SessionCompaction.Service
|
const compaction = yield* SessionCompaction.Service
|
||||||
const title = yield* SessionTitle.Service
|
const title = yield* SessionTitle.Service
|
||||||
const toolOutput = yield* ToolOutput.Service
|
|
||||||
// Title generation is a side effect of a successful step; it must not delay continuation.
|
// Title generation is a side effect of a successful step; it must not delay continuation.
|
||||||
// The in-flight set coalesces overlapping steps while title presence records success durably.
|
// The in-flight set coalesces overlapping steps while title presence records success durably.
|
||||||
const titlesRunning = new Set<SessionSchema.ID>()
|
const titlesRunning = new Set<SessionSchema.ID>()
|
||||||
@@ -336,7 +334,6 @@ const layer = Layer.effect(
|
|||||||
).pipe(
|
).pipe(
|
||||||
// The fiber owns its call: it publishes its own completion, masked so a
|
// The fiber owns its call: it publishes its own completion, masked so a
|
||||||
// finished execution always reaches its durable settlement.
|
// finished execution always reaches its durable settlement.
|
||||||
Effect.flatMap(toolOutput.truncate),
|
|
||||||
Effect.flatMap((outcome) => publisher.toolExecution(event.id, event.name, outcome)),
|
Effect.flatMap((outcome) => publisher.toolExecution(event.id, event.name, outcome)),
|
||||||
Effect.catchTag("Tool.Error", (error) =>
|
Effect.catchTag("Tool.Error", (error) =>
|
||||||
publisher.failTool(event.id, toSessionError(error)).pipe(Effect.asVoid),
|
publisher.failTool(event.id, toSessionError(error)).pipe(Effect.asVoid),
|
||||||
@@ -565,7 +562,6 @@ export const node = makeLocationNode({
|
|||||||
SessionCompaction.node,
|
SessionCompaction.node,
|
||||||
SessionTitle.node,
|
SessionTitle.node,
|
||||||
Snapshot.node,
|
Snapshot.node,
|
||||||
ToolOutput.node,
|
|
||||||
Database.node,
|
Database.node,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,323 +0,0 @@
|
|||||||
export * as SessionTransfer from "./transfer"
|
|
||||||
|
|
||||||
import { SessionTransfer } from "@opencode-ai/schema/session-transfer"
|
|
||||||
import { Tool } from "@opencode-ai/schema/tool"
|
|
||||||
import { eq, isNotNull, isNull, ne, or } from "drizzle-orm"
|
|
||||||
import { Context, DateTime, Effect, Layer, Schema } from "effect"
|
|
||||||
import path from "path"
|
|
||||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
|
||||||
import { App } from "../app"
|
|
||||||
import { Bus } from "../bus"
|
|
||||||
import { Database } from "../database/database"
|
|
||||||
import { Location } from "../location"
|
|
||||||
import { Project } from "../project"
|
|
||||||
import { ProjectTable } from "../project/sql"
|
|
||||||
import { AbsolutePath, RelativePath } from "../schema"
|
|
||||||
import { Session } from "../session"
|
|
||||||
import { Slug } from "../util/slug"
|
|
||||||
import { SessionEvent } from "./event"
|
|
||||||
import { SessionMessage } from "./message"
|
|
||||||
import { SessionProjector } from "./projector"
|
|
||||||
import { SessionMessageTable, SessionTable } from "./sql"
|
|
||||||
|
|
||||||
export const Data = SessionTransfer.Data
|
|
||||||
export type Data = SessionTransfer.Data
|
|
||||||
|
|
||||||
export class ImportConflictError extends Schema.TaggedErrorClass<ImportConflictError>()(
|
|
||||||
"SessionTransfer.ImportConflictError",
|
|
||||||
{ sessionID: Session.ID },
|
|
||||||
) {}
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly export: (input: {
|
|
||||||
sessionID: Session.ID
|
|
||||||
sanitize?: boolean
|
|
||||||
}) => Effect.Effect<Data, Session.NotFoundError | Session.MessageDecodeError>
|
|
||||||
readonly import: (input: {
|
|
||||||
data: Data
|
|
||||||
location: Location.Ref
|
|
||||||
}) => Effect.Effect<Session.Info, ImportConflictError>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionTransfer") {}
|
|
||||||
|
|
||||||
const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const app = yield* App.Metadata
|
|
||||||
const bus = yield* Bus.Service
|
|
||||||
const { db } = yield* Database.Service
|
|
||||||
const projects = yield* Project.Service
|
|
||||||
const sessions = yield* Session.Service
|
|
||||||
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
|
|
||||||
|
|
||||||
const persistProject = (project: Project.Resolved) => {
|
|
||||||
const vcs = project.vcs?.type
|
|
||||||
return db
|
|
||||||
.insert(ProjectTable)
|
|
||||||
.values({ id: project.id, worktree: project.canonical, vcs, sandboxes: [] })
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: ProjectTable.id,
|
|
||||||
set: { worktree: project.canonical, vcs: vcs ?? null },
|
|
||||||
setWhere: or(
|
|
||||||
ne(ProjectTable.worktree, project.canonical),
|
|
||||||
vcs ? or(isNull(ProjectTable.vcs), ne(ProjectTable.vcs, vcs)) : isNotNull(ProjectTable.vcs),
|
|
||||||
),
|
|
||||||
})
|
|
||||||
.run()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
}
|
|
||||||
|
|
||||||
return Service.of({
|
|
||||||
export: Effect.fn("SessionTransfer.export")(function* (input) {
|
|
||||||
const data = {
|
|
||||||
info: yield* sessions.get(input.sessionID),
|
|
||||||
messages: yield* sessions.messages({ sessionID: input.sessionID, order: "asc" }),
|
|
||||||
}
|
|
||||||
return input.sanitize ? sanitize(data) : data
|
|
||||||
}),
|
|
||||||
import: Effect.fn("SessionTransfer.import")(function* (input) {
|
|
||||||
const sessionID = input.data.info.id
|
|
||||||
const recorded = yield* db
|
|
||||||
.select({ id: SessionTable.id })
|
|
||||||
.from(SessionTable)
|
|
||||||
.where(eq(SessionTable.id, sessionID))
|
|
||||||
.get()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
if (recorded) return yield* new ImportConflictError({ sessionID })
|
|
||||||
const project = yield* projects.resolve(input.location.directory)
|
|
||||||
yield* persistProject(project)
|
|
||||||
const messages = input.data.messages.map((message, index) => {
|
|
||||||
const encoded = encodeMessage(message)
|
|
||||||
const { id: _, type, ...data } = encoded
|
|
||||||
return {
|
|
||||||
id: message.id,
|
|
||||||
session_id: sessionID,
|
|
||||||
type,
|
|
||||||
seq: index + 1,
|
|
||||||
time_created: DateTime.toEpochMillis(message.time.created),
|
|
||||||
data,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
yield* bus
|
|
||||||
.publish(
|
|
||||||
SessionEvent.Created,
|
|
||||||
{
|
|
||||||
sessionID,
|
|
||||||
slug: Slug.create(),
|
|
||||||
version: app.version,
|
|
||||||
projectID: project.id,
|
|
||||||
location: input.location,
|
|
||||||
subpath: RelativePath.make(path.relative(project.directory, input.location.directory).replaceAll("\\", "/")),
|
|
||||||
title: input.data.info.title,
|
|
||||||
agent: input.data.info.agent,
|
|
||||||
model: input.data.info.model,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
location: input.location,
|
|
||||||
commit: (seq) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
if (messages.length > 0) {
|
|
||||||
yield* db.insert(SessionMessageTable).values(messages).run().pipe(Effect.orDie)
|
|
||||||
yield* Bus.reserveSequence(db, sessionID, seq + messages.length)
|
|
||||||
}
|
|
||||||
yield* db
|
|
||||||
.update(SessionTable)
|
|
||||||
.set({
|
|
||||||
cost: input.data.info.cost,
|
|
||||||
tokens_input: input.data.info.tokens.input,
|
|
||||||
tokens_output: input.data.info.tokens.output,
|
|
||||||
tokens_reasoning: input.data.info.tokens.reasoning,
|
|
||||||
tokens_cache_read: input.data.info.tokens.cache.read,
|
|
||||||
tokens_cache_write: input.data.info.tokens.cache.write,
|
|
||||||
time_created: DateTime.toEpochMillis(input.data.info.time.created),
|
|
||||||
time_updated: DateTime.toEpochMillis(input.data.info.time.updated),
|
|
||||||
time_archived: input.data.info.time.archived
|
|
||||||
? DateTime.toEpochMillis(input.data.info.time.archived)
|
|
||||||
: null,
|
|
||||||
})
|
|
||||||
.where(eq(SessionTable.id, sessionID))
|
|
||||||
.run()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.pipe(
|
|
||||||
Effect.catchDefect((defect) =>
|
|
||||||
defect instanceof SessionProjector.SessionAlreadyProjected
|
|
||||||
? Effect.fail(new ImportConflictError({ sessionID }))
|
|
||||||
: Effect.die(defect),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return yield* sessions.get(sessionID).pipe(Effect.orDie)
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const node = makeGlobalNode({
|
|
||||||
service: Service,
|
|
||||||
layer,
|
|
||||||
deps: [App.node, Bus.node, Database.node, Project.node, Session.node],
|
|
||||||
})
|
|
||||||
|
|
||||||
function redact(kind: string, id: string, value: string) {
|
|
||||||
return value.trim() ? `[redacted:${kind}:${id}]` : value
|
|
||||||
}
|
|
||||||
|
|
||||||
function metadata(kind: string, id: string, value: Readonly<Record<string, unknown>> | undefined) {
|
|
||||||
if (!value) return value
|
|
||||||
return Object.keys(value).length > 0 ? { redacted: `${kind}:${id}` } : value
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitize(data: Data): Data {
|
|
||||||
return {
|
|
||||||
info: {
|
|
||||||
...data.info,
|
|
||||||
title: data.info.title === undefined ? undefined : redact("session-title", data.info.id, data.info.title),
|
|
||||||
location: {
|
|
||||||
...data.info.location,
|
|
||||||
directory: AbsolutePath.make(`/${redact("session-directory", data.info.id, data.info.location.directory)}`),
|
|
||||||
},
|
|
||||||
revert: data.info.revert
|
|
||||||
? {
|
|
||||||
...data.info.revert,
|
|
||||||
files: data.info.revert.files?.map((file, index) => ({
|
|
||||||
...file,
|
|
||||||
file: redact("revert-file", String(index), file.file),
|
|
||||||
patch: redact("revert-patch", String(index), file.patch),
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
},
|
|
||||||
messages: data.messages.map(sanitizeMessage),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeMessage(message: SessionMessage.Info): SessionMessage.Info {
|
|
||||||
const meta = metadata("message-metadata", message.id, message.metadata)
|
|
||||||
if (message.type === "user")
|
|
||||||
return {
|
|
||||||
...message,
|
|
||||||
metadata: meta,
|
|
||||||
text: redact("text", message.id, message.text),
|
|
||||||
files: message.files?.map((file, index) => ({
|
|
||||||
...file,
|
|
||||||
data: "",
|
|
||||||
source: { type: "inline" },
|
|
||||||
name: file.name === undefined ? undefined : redact("file-name", String(index), file.name),
|
|
||||||
description:
|
|
||||||
file.description === undefined ? undefined : redact("file-description", String(index), file.description),
|
|
||||||
mention: file.mention
|
|
||||||
? { ...file.mention, text: redact("file-mention", String(index), file.mention.text) }
|
|
||||||
: undefined,
|
|
||||||
})),
|
|
||||||
agents: message.agents?.map((agent, index) => ({
|
|
||||||
...agent,
|
|
||||||
name: redact("agent-name", String(index), agent.name),
|
|
||||||
mention: agent.mention
|
|
||||||
? { ...agent.mention, text: redact("agent-mention", String(index), agent.mention.text) }
|
|
||||||
: undefined,
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
if (message.type === "synthetic")
|
|
||||||
return {
|
|
||||||
...message,
|
|
||||||
metadata: meta,
|
|
||||||
text: redact("synthetic", message.id, message.text),
|
|
||||||
description:
|
|
||||||
message.description === undefined
|
|
||||||
? undefined
|
|
||||||
: redact("synthetic-description", message.id, message.description),
|
|
||||||
}
|
|
||||||
if (message.type === "system")
|
|
||||||
return { ...message, metadata: meta, text: redact("system", message.id, message.text) }
|
|
||||||
if (message.type === "skill") return { ...message, metadata: meta, text: redact("skill", message.id, message.text) }
|
|
||||||
if (message.type === "shell")
|
|
||||||
return {
|
|
||||||
...message,
|
|
||||||
metadata: meta,
|
|
||||||
command: redact("shell-command", message.id, message.command),
|
|
||||||
output: message.output
|
|
||||||
? { ...message.output, output: redact("shell-output", message.id, message.output.output) }
|
|
||||||
: undefined,
|
|
||||||
}
|
|
||||||
if (message.type === "assistant")
|
|
||||||
return {
|
|
||||||
...message,
|
|
||||||
metadata: meta,
|
|
||||||
content: message.content.map((content) => {
|
|
||||||
if (content.type === "text")
|
|
||||||
return {
|
|
||||||
...content,
|
|
||||||
text: redact("text", message.id, content.text),
|
|
||||||
state: content.state ? { redacted: `text-state:${message.id}` } : undefined,
|
|
||||||
}
|
|
||||||
if (content.type === "reasoning")
|
|
||||||
return {
|
|
||||||
...content,
|
|
||||||
text: redact("reasoning", message.id, content.text),
|
|
||||||
state: content.state ? { redacted: `reasoning-state:${message.id}` } : undefined,
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...content,
|
|
||||||
providerState: content.providerState ? { redacted: `tool-provider-state:${message.id}` } : undefined,
|
|
||||||
providerResultState: content.providerResultState
|
|
||||||
? { redacted: `tool-provider-result-state:${message.id}` }
|
|
||||||
: undefined,
|
|
||||||
state: sanitizeToolState(message.id, content.state),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
if (message.type === "compaction") {
|
|
||||||
if (message.status === "failed")
|
|
||||||
return {
|
|
||||||
...message,
|
|
||||||
metadata: meta,
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...message,
|
|
||||||
metadata: meta,
|
|
||||||
summary: redact("compaction-summary", message.id, message.summary),
|
|
||||||
recent: redact("compaction-recent", message.id, message.recent),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { ...message, metadata: meta }
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeToolState(id: string, state: SessionMessage.ToolState): SessionMessage.ToolState {
|
|
||||||
if (state.status === "streaming") return { ...state, input: redact("tool-input", id, state.input) }
|
|
||||||
if (state.status === "running")
|
|
||||||
return { ...state, input: { redacted: `tool-input:${id}` }, metadata: { redacted: `tool-metadata:${id}` } }
|
|
||||||
const meta = state.metadata === undefined ? undefined : { redacted: `tool-metadata:${id}` }
|
|
||||||
if (state.status === "completed")
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
input: { redacted: `tool-input:${id}` },
|
|
||||||
content: [
|
|
||||||
sanitizeToolContent(id, state.content[0]),
|
|
||||||
...state.content.slice(1).map((item) => sanitizeToolContent(id, item)),
|
|
||||||
],
|
|
||||||
metadata: meta,
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
input: { redacted: `tool-input:${id}` },
|
|
||||||
content: state.content
|
|
||||||
? [
|
|
||||||
sanitizeToolContent(id, state.content[0]),
|
|
||||||
...state.content.slice(1).map((item) => sanitizeToolContent(id, item)),
|
|
||||||
]
|
|
||||||
: undefined,
|
|
||||||
metadata: meta,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeToolContent(id: string, content: Tool.Content): Tool.Content {
|
|
||||||
if (content.type === "text") return { ...content, text: redact("tool-output", id, content.text) }
|
|
||||||
return {
|
|
||||||
...content,
|
|
||||||
uri: redact("tool-file-uri", id, content.uri),
|
|
||||||
name: content.name === undefined ? undefined : redact("tool-file-name", id, content.name),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
export * as ToolOutput from "./tool-output"
|
|
||||||
|
|
||||||
import path from "path"
|
|
||||||
import type { Tool } from "@opencode-ai/schema/tool"
|
|
||||||
import { Context, Duration, Effect, Layer, Schedule } from "effect"
|
|
||||||
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
|
||||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
|
||||||
import { Global } from "@opencode-ai/util/global"
|
|
||||||
import { Config } from "./config"
|
|
||||||
import { Identifier } from "./id/id"
|
|
||||||
|
|
||||||
export const MAX_LINES = 2_000
|
|
||||||
export const MAX_BYTES = 50 * 1024 // 50 KiB
|
|
||||||
export const RETENTION = Duration.days(7)
|
|
||||||
export const DIRECTORY = "tool-output"
|
|
||||||
|
|
||||||
type Result = Tool.Result
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly truncate: (result: Result) => Effect.Effect<Result>
|
|
||||||
readonly cleanup: () => Effect.Effect<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ToolOutput") {}
|
|
||||||
|
|
||||||
const cleanup = Effect.fn("ToolOutput.cleanup")(function* (fs: FSUtil.Interface, directory: string) {
|
|
||||||
const cutoff = Identifier.timestamp(
|
|
||||||
Identifier.create("tool", "ascending", Date.now() - Duration.toMillis(RETENTION)),
|
|
||||||
)
|
|
||||||
const entries = yield* fs.readDirectory(directory).pipe(
|
|
||||||
Effect.map((entries) => entries.filter((entry) => /^tool_[0-9a-f]{12}/.test(entry))),
|
|
||||||
Effect.catch(() => Effect.succeed([])),
|
|
||||||
)
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (Identifier.timestamp(entry) >= cutoff) continue
|
|
||||||
yield* fs.remove(path.join(directory, entry)).pipe(Effect.catch(() => Effect.void))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const config = yield* Config.Service
|
|
||||||
const fs = yield* FSUtil.Service
|
|
||||||
const global = yield* Global.Service
|
|
||||||
const directory = path.join(global.data, DIRECTORY)
|
|
||||||
|
|
||||||
const truncate = Effect.fn("ToolOutput.truncate")(function* (result: Result) {
|
|
||||||
if (result.metadata?.truncated !== undefined) return result
|
|
||||||
const content =
|
|
||||||
typeof result.content === "string" ? [{ type: "text" as const, text: result.content }] : (result.content ?? [])
|
|
||||||
const text = content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n")
|
|
||||||
const configured = Config.latest(yield* config.entries(), "tool_output")
|
|
||||||
const maxLines = configured?.max_lines ?? MAX_LINES
|
|
||||||
const maxBytes = configured?.max_bytes ?? MAX_BYTES
|
|
||||||
const lines = text.split("\n")
|
|
||||||
if (text.endsWith("\n")) lines.pop()
|
|
||||||
const totalBytes = Buffer.byteLength(text, "utf-8")
|
|
||||||
if (lines.length <= maxLines && totalBytes <= maxBytes)
|
|
||||||
return { ...result, metadata: { ...result.metadata, truncated: false } }
|
|
||||||
|
|
||||||
const kept: string[] = []
|
|
||||||
let bytes = 0
|
|
||||||
let hitBytes = false
|
|
||||||
for (const line of lines.slice(0, maxLines)) {
|
|
||||||
const size = Buffer.byteLength(line, "utf-8") + (kept.length > 0 ? 1 : 0)
|
|
||||||
if (bytes + size > maxBytes) {
|
|
||||||
hitBytes = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
kept.push(line)
|
|
||||||
bytes += size
|
|
||||||
}
|
|
||||||
if (!hitBytes && kept.length === lines.length && totalBytes > bytes) hitBytes = true
|
|
||||||
const removed = hitBytes ? totalBytes - bytes : lines.length - kept.length
|
|
||||||
const unit = hitBytes ? (removed === 1 ? "byte" : "bytes") : removed === 1 ? "line" : "lines"
|
|
||||||
const file = path.join(directory, Identifier.ascending("tool"))
|
|
||||||
yield* fs.ensureDir(directory).pipe(Effect.orDie)
|
|
||||||
yield* fs.writeFileString(file, text).pipe(Effect.orDie)
|
|
||||||
const marker = `... ${removed} ${unit} truncated; full content saved to ${file} ...`
|
|
||||||
const bounded: Tool.Content[] = []
|
|
||||||
let remaining = kept.join("\n").length
|
|
||||||
let seenText = false
|
|
||||||
let marked = false
|
|
||||||
for (const item of content) {
|
|
||||||
if (item.type === "file") {
|
|
||||||
bounded.push(item)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (seenText && remaining > 0) remaining--
|
|
||||||
seenText = true
|
|
||||||
if (remaining >= item.text.length) {
|
|
||||||
bounded.push(item)
|
|
||||||
remaining -= item.text.length
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (remaining > 0) bounded.push({ ...item, text: item.text.slice(0, remaining) })
|
|
||||||
if (!marked) bounded.push({ type: "text", text: marker })
|
|
||||||
remaining = 0
|
|
||||||
marked = true
|
|
||||||
}
|
|
||||||
if (!marked) bounded.push({ type: "text", text: marker })
|
|
||||||
return {
|
|
||||||
...result,
|
|
||||||
content: bounded,
|
|
||||||
metadata: { ...result.metadata, truncated: true, outputPath: file },
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ truncate, cleanup: () => cleanup(fs, directory) })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const cleanupLayer = Layer.effectDiscard(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const fs = yield* FSUtil.Service
|
|
||||||
const global = yield* Global.Service
|
|
||||||
yield* cleanup(fs, path.join(global.data, DIRECTORY)).pipe(
|
|
||||||
Effect.repeat(Schedule.spaced(Duration.hours(1))),
|
|
||||||
Effect.forkScoped,
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const cleanupNode = makeGlobalNode({ name: "tool-output-cleanup", layer: cleanupLayer, deps: [FSUtil.node, Global.node] })
|
|
||||||
|
|
||||||
export const node = makeLocationNode({
|
|
||||||
service: Service,
|
|
||||||
layer,
|
|
||||||
deps: [Config.node, FSUtil.node, Global.node, cleanupNode],
|
|
||||||
})
|
|
||||||
@@ -116,7 +116,8 @@ export const Plugin = {
|
|||||||
|
|
||||||
yield* ctx.tool
|
yield* ctx.tool
|
||||||
.transform((draft) =>
|
.transform((draft) =>
|
||||||
draft.add({
|
draft.add(
|
||||||
|
({
|
||||||
name,
|
name,
|
||||||
options: { codemode: false, permission: "edit" },
|
options: { codemode: false, permission: "edit" },
|
||||||
description:
|
description:
|
||||||
@@ -152,9 +153,7 @@ export const Plugin = {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const info = yield* fs
|
const info = yield* fs.stat(target.canonical).pipe(
|
||||||
.stat(target.absolute)
|
|
||||||
.pipe(
|
|
||||||
Effect.catchReason("PlatformError", "NotFound", () =>
|
Effect.catchReason("PlatformError", "NotFound", () =>
|
||||||
Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })),
|
Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })),
|
||||||
),
|
),
|
||||||
@@ -162,7 +161,7 @@ export const Plugin = {
|
|||||||
if (info.type === "Directory") {
|
if (info.type === "Directory") {
|
||||||
return yield* new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` })
|
return yield* new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` })
|
||||||
}
|
}
|
||||||
const original = yield* Bom.readFile(fs, target.absolute)
|
const original = yield* Bom.readFile(fs, target.canonical)
|
||||||
const source = original.text
|
const source = original.text
|
||||||
const ending = source.includes(crlf) ? crlf : "\n"
|
const ending = source.includes(crlf) ? crlf : "\n"
|
||||||
const oldString = input.oldString.replaceAll(crlf, "\n").replaceAll("\n", ending)
|
const oldString = input.oldString.replaceAll(crlf, "\n").replaceAll("\n", ending)
|
||||||
@@ -171,13 +170,17 @@ export const Plugin = {
|
|||||||
// These one-to-one mappings preserve offsets into the original source.
|
// These one-to-one mappings preserve offsets into the original source.
|
||||||
const unicode =
|
const unicode =
|
||||||
exact.length > 0 ? [] : findOccurrences(normalizeForMatch(source), normalizeForMatch(oldString))
|
exact.length > 0 ? [] : findOccurrences(normalizeForMatch(source), normalizeForMatch(oldString))
|
||||||
const trailing = exact.length > 0 || unicode.length > 0 ? [] : findLineOccurrences(source, oldString)
|
const trailing =
|
||||||
|
exact.length > 0 || unicode.length > 0
|
||||||
|
? []
|
||||||
|
: findLineOccurrences(source, oldString)
|
||||||
const matches = exact.length > 0 ? exact : unicode.length > 0 ? unicode : trailing
|
const matches = exact.length > 0 ? exact : unicode.length > 0 ? unicode : trailing
|
||||||
const replacements = matches.length
|
const replacements = matches.length
|
||||||
const replaced = (input.replaceAll === true ? matches : matches.slice(0, 1))
|
const replaced = (input.replaceAll === true ? matches : matches.slice(0, 1))
|
||||||
.toReversed()
|
.toReversed()
|
||||||
.reduce(
|
.reduce(
|
||||||
(content, match) => `${content.slice(0, match.start)}${newString}${content.slice(match.end)}`,
|
(content, match) =>
|
||||||
|
`${content.slice(0, match.start)}${newString}${content.slice(match.end)}`,
|
||||||
source,
|
source,
|
||||||
)
|
)
|
||||||
const preview =
|
const preview =
|
||||||
@@ -209,9 +212,9 @@ export const Plugin = {
|
|||||||
content: Bom.join(replaced, original.bom || replacementBom),
|
content: Bom.join(replaced, original.bom || replacementBom),
|
||||||
})
|
})
|
||||||
const bom = original.bom || replacementBom
|
const bom = original.bom || replacementBom
|
||||||
const formatted = (yield* formatter.file(target.absolute))
|
const formatted = (yield* formatter.file(target.canonical))
|
||||||
? yield* Bom.syncFile(fs, target.absolute, bom)
|
? yield* Bom.syncFile(fs, target.canonical, bom)
|
||||||
: (yield* Bom.readFile(fs, target.absolute)).text
|
: (yield* Bom.readFile(fs, target.canonical)).text
|
||||||
return {
|
return {
|
||||||
files: [fileDiff(result.resource, source, formatted)],
|
files: [fileDiff(result.resource, source, formatted)],
|
||||||
replacements,
|
replacements,
|
||||||
@@ -230,6 +233,7 @@ export const Plugin = {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -50,10 +50,12 @@ export const Plugin = {
|
|||||||
|
|
||||||
yield* ctx.tool
|
yield* ctx.tool
|
||||||
.transform((draft) =>
|
.transform((draft) =>
|
||||||
draft.add({
|
draft.add(
|
||||||
|
({
|
||||||
name,
|
name,
|
||||||
options: { codemode: false },
|
options: { codemode: false },
|
||||||
description: 'Search file paths using a glob pattern (examples: "**/*.ts", "src/**/*.tsx").',
|
description:
|
||||||
|
'Search file paths using a glob pattern (examples: "**/*.ts", "src/**/*.tsx").',
|
||||||
input: Input,
|
input: Input,
|
||||||
output: Output,
|
output: Output,
|
||||||
execute: (input, context) =>
|
execute: (input, context) =>
|
||||||
@@ -83,7 +85,7 @@ export const Plugin = {
|
|||||||
source,
|
source,
|
||||||
})
|
})
|
||||||
const info = yield* fs
|
const info = yield* fs
|
||||||
.stat(target.absolute)
|
.stat(target.canonical)
|
||||||
.pipe(
|
.pipe(
|
||||||
Effect.catchReason("PlatformError", "NotFound", () =>
|
Effect.catchReason("PlatformError", "NotFound", () =>
|
||||||
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${searchPath ?? "."}` })),
|
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${searchPath ?? "."}` })),
|
||||||
@@ -97,7 +99,7 @@ export const Plugin = {
|
|||||||
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
|
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
|
||||||
const entries = yield* ripgrep
|
const entries = yield* ripgrep
|
||||||
.glob({
|
.glob({
|
||||||
cwd: target.absolute,
|
cwd: target.canonical,
|
||||||
pattern: input.pattern,
|
pattern: input.pattern,
|
||||||
limit: limit + 1,
|
limit: limit + 1,
|
||||||
})
|
})
|
||||||
@@ -137,6 +139,7 @@ export const Plugin = {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ type Prepared =
|
|||||||
})
|
})
|
||||||
|
|
||||||
interface Target {
|
interface Target {
|
||||||
readonly absolute: string
|
readonly canonical: string
|
||||||
readonly resource: string
|
readonly resource: string
|
||||||
readonly externalDirectory?: {
|
readonly externalDirectory?: {
|
||||||
readonly directory: string
|
readonly directory: string
|
||||||
@@ -76,7 +76,8 @@ export const Plugin = {
|
|||||||
|
|
||||||
yield* ctx.tool
|
yield* ctx.tool
|
||||||
.transform((draft) =>
|
.transform((draft) =>
|
||||||
draft.add({
|
draft.add(
|
||||||
|
({
|
||||||
name,
|
name,
|
||||||
options: { codemode: false, permission: "edit" },
|
options: { codemode: false, permission: "edit" },
|
||||||
description: DESCRIPTION,
|
description: DESCRIPTION,
|
||||||
@@ -98,7 +99,9 @@ export const Plugin = {
|
|||||||
}
|
}
|
||||||
if (!input.patchText) return yield* new ToolFailure({ message: "patchText is required" })
|
if (!input.patchText) return yield* new ToolFailure({ message: "patchText is required" })
|
||||||
const hunks = yield* Effect.fromResult(Patch.parse(input.patchText)).pipe(
|
const hunks = yield* Effect.fromResult(Patch.parse(input.patchText)).pipe(
|
||||||
Effect.mapError((error) => new ToolFailure({ message: `patch verification failed: ${error.message}` })),
|
Effect.mapError(
|
||||||
|
(error) => new ToolFailure({ message: `patch verification failed: ${error.message}` }),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
if (hunks.length === 0) {
|
if (hunks.length === 0) {
|
||||||
return yield* new ToolFailure({ message: "patch rejected: empty patch" })
|
return yield* new ToolFailure({ message: "patch rejected: empty patch" })
|
||||||
@@ -116,7 +119,7 @@ export const Plugin = {
|
|||||||
resources: [target.externalDirectory.resource],
|
resources: [target.externalDirectory.resource],
|
||||||
save: [target.externalDirectory.resource],
|
save: [target.externalDirectory.resource],
|
||||||
metadata: {
|
metadata: {
|
||||||
filepath: target.absolute,
|
filepath: target.canonical,
|
||||||
parentDir: target.externalDirectory.directory,
|
parentDir: target.externalDirectory.directory,
|
||||||
},
|
},
|
||||||
sessionID: context.sessionID,
|
sessionID: context.sessionID,
|
||||||
@@ -130,13 +133,15 @@ export const Plugin = {
|
|||||||
target,
|
target,
|
||||||
before: "",
|
before: "",
|
||||||
after: Bom.split(
|
after: Bom.split(
|
||||||
hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`,
|
hunk.contents.endsWith("\n") || hunk.contents === ""
|
||||||
|
? hunk.contents
|
||||||
|
: `${hunk.contents}\n`,
|
||||||
).text,
|
).text,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (hunk.type === "delete") {
|
if (hunk.type === "delete") {
|
||||||
const content = yield* Bom.readFile(fs, target.absolute).pipe(
|
const content = yield* Bom.readFile(fs, target.canonical).pipe(
|
||||||
Effect.mapError(
|
Effect.mapError(
|
||||||
(error) =>
|
(error) =>
|
||||||
new ToolFailure({
|
new ToolFailure({
|
||||||
@@ -147,28 +152,28 @@ export const Plugin = {
|
|||||||
prepared.push({ ...hunk, target, before: content.text, after: "" })
|
prepared.push({ ...hunk, target, before: content.text, after: "" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const previous = updates.get(target.absolute)
|
const previous = updates.get(target.canonical)
|
||||||
const original =
|
const original =
|
||||||
previous ??
|
previous ??
|
||||||
(yield* Effect.gen(function* () {
|
(yield* Effect.gen(function* () {
|
||||||
const stats = yield* fs.stat(target.absolute).pipe(
|
const stats = yield* fs.stat(target.canonical).pipe(
|
||||||
Effect.mapError(
|
Effect.mapError(
|
||||||
(error) =>
|
(error) =>
|
||||||
new ToolFailure({
|
new ToolFailure({
|
||||||
message: `patch verification failed: Failed to read file to update ${target.absolute}: ${errorMessage(error)}`,
|
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${errorMessage(error)}`,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (stats.type === "Directory") {
|
if (stats.type === "Directory") {
|
||||||
return yield* new ToolFailure({
|
return yield* new ToolFailure({
|
||||||
message: `patch verification failed: Failed to read file to update ${target.absolute}: path is a directory`,
|
message: `patch verification failed: Failed to read file to update ${target.canonical}: path is a directory`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const content = yield* Bom.readFile(fs, target.absolute).pipe(
|
const content = yield* Bom.readFile(fs, target.canonical).pipe(
|
||||||
Effect.mapError(
|
Effect.mapError(
|
||||||
(error) =>
|
(error) =>
|
||||||
new ToolFailure({
|
new ToolFailure({
|
||||||
message: `patch verification failed: Failed to read file to update ${target.absolute}: ${errorMessage(error)}`,
|
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${errorMessage(error)}`,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -177,7 +182,8 @@ export const Plugin = {
|
|||||||
const before = Bom.split(original).text
|
const before = Bom.split(original).text
|
||||||
const update = yield* Effect.try({
|
const update = yield* Effect.try({
|
||||||
try: () => Patch.derive(hunk.path, hunk.chunks, original),
|
try: () => Patch.derive(hunk.path, hunk.chunks, original),
|
||||||
catch: (error) => new ToolFailure({ message: `patch verification failed: ${errorMessage(error)}` }),
|
catch: (error) =>
|
||||||
|
new ToolFailure({ message: `patch verification failed: ${errorMessage(error)}` }),
|
||||||
})
|
})
|
||||||
const moveTarget = hunk.movePath ? resolveTarget(location, hunk.movePath) : undefined
|
const moveTarget = hunk.movePath ? resolveTarget(location, hunk.movePath) : undefined
|
||||||
if (moveTarget) targets.push(moveTarget)
|
if (moveTarget) targets.push(moveTarget)
|
||||||
@@ -187,7 +193,7 @@ export const Plugin = {
|
|||||||
resources: [moveTarget.externalDirectory.resource],
|
resources: [moveTarget.externalDirectory.resource],
|
||||||
save: [moveTarget.externalDirectory.resource],
|
save: [moveTarget.externalDirectory.resource],
|
||||||
metadata: {
|
metadata: {
|
||||||
filepath: moveTarget.absolute,
|
filepath: moveTarget.canonical,
|
||||||
parentDir: moveTarget.externalDirectory.directory,
|
parentDir: moveTarget.externalDirectory.directory,
|
||||||
},
|
},
|
||||||
sessionID: context.sessionID,
|
sessionID: context.sessionID,
|
||||||
@@ -203,7 +209,7 @@ export const Plugin = {
|
|||||||
after: update.content,
|
after: update.content,
|
||||||
moveTarget,
|
moveTarget,
|
||||||
})
|
})
|
||||||
if (!moveTarget) updates.set(target.absolute, Patch.joinBom(update.content, update.bom))
|
if (!moveTarget) updates.set(target.canonical, Patch.joinBom(update.content, update.bom))
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.mapError((error) =>
|
Effect.mapError((error) =>
|
||||||
error instanceof ToolFailure
|
error instanceof ToolFailure
|
||||||
@@ -235,38 +241,40 @@ export const Plugin = {
|
|||||||
if (change.type === "add") {
|
if (change.type === "add") {
|
||||||
yield* fs
|
yield* fs
|
||||||
.writeWithDirs(
|
.writeWithDirs(
|
||||||
change.target.absolute,
|
change.target.canonical,
|
||||||
change.contents.endsWith("\n") || change.contents === ""
|
change.contents.endsWith("\n") || change.contents === ""
|
||||||
? change.contents
|
? change.contents
|
||||||
: `${change.contents}\n`,
|
: `${change.contents}\n`,
|
||||||
)
|
)
|
||||||
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
|
.pipe(
|
||||||
|
Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)),
|
||||||
|
)
|
||||||
applied.push({
|
applied.push({
|
||||||
type: change.type,
|
type: change.type,
|
||||||
resource: change.target.resource,
|
resource: change.target.resource,
|
||||||
target: change.target.absolute,
|
target: change.target.canonical,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (change.type === "delete") {
|
if (change.type === "delete") {
|
||||||
yield* fs
|
yield* fs
|
||||||
.remove(change.target.absolute)
|
.remove(change.target.canonical)
|
||||||
.pipe(Effect.mapError((error) => fail(`Failed to delete ${change.target.resource}`, error)))
|
.pipe(
|
||||||
|
Effect.mapError((error) => fail(`Failed to delete ${change.target.resource}`, error)),
|
||||||
|
)
|
||||||
applied.push({
|
applied.push({
|
||||||
type: change.type,
|
type: change.type,
|
||||||
resource: change.target.resource,
|
resource: change.target.resource,
|
||||||
target: change.target.absolute,
|
target: change.target.canonical,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (change.moveTarget) {
|
if (change.moveTarget) {
|
||||||
const moveTarget = change.moveTarget
|
const moveTarget = change.moveTarget
|
||||||
yield* fs
|
yield* fs
|
||||||
.writeWithDirs(moveTarget.absolute, change.content)
|
.writeWithDirs(moveTarget.canonical, change.content)
|
||||||
.pipe(Effect.mapError((error) => fail(`Failed to write ${moveTarget.resource}`, error)))
|
.pipe(Effect.mapError((error) => fail(`Failed to write ${moveTarget.resource}`, error)))
|
||||||
yield* fs
|
yield* fs.remove(change.target.canonical).pipe(
|
||||||
.remove(change.target.absolute)
|
|
||||||
.pipe(
|
|
||||||
Effect.mapError((error) =>
|
Effect.mapError((error) =>
|
||||||
fail(`Wrote ${moveTarget.resource} but failed to remove ${change.target.resource}`, error),
|
fail(`Wrote ${moveTarget.resource} but failed to remove ${change.target.resource}`, error),
|
||||||
),
|
),
|
||||||
@@ -274,17 +282,17 @@ export const Plugin = {
|
|||||||
applied.push({
|
applied.push({
|
||||||
type: change.type,
|
type: change.type,
|
||||||
resource: change.moveTarget.resource,
|
resource: change.moveTarget.resource,
|
||||||
target: change.moveTarget.absolute,
|
target: change.moveTarget.canonical,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
yield* fs
|
yield* fs
|
||||||
.writeWithDirs(change.target.absolute, change.content)
|
.writeWithDirs(change.target.canonical, change.content)
|
||||||
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
|
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
|
||||||
applied.push({
|
applied.push({
|
||||||
type: change.type,
|
type: change.type,
|
||||||
resource: change.target.resource,
|
resource: change.target.resource,
|
||||||
target: change.target.absolute,
|
target: change.target.canonical,
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
{ discard: true },
|
{ discard: true },
|
||||||
@@ -311,7 +319,7 @@ export const Plugin = {
|
|||||||
const files = yield* Effect.forEach(prepared, (change) => {
|
const files = yield* Effect.forEach(prepared, (change) => {
|
||||||
if (change.type === "delete") return Effect.succeed(patchFile(change))
|
if (change.type === "delete") return Effect.succeed(patchFile(change))
|
||||||
const target = change.type === "update" && change.moveTarget ? change.moveTarget : change.target
|
const target = change.type === "update" && change.moveTarget ? change.moveTarget : change.target
|
||||||
return Effect.succeed(patchFile(change, formatted.get(target.absolute)))
|
return Effect.succeed(patchFile(change, formatted.get(target.canonical)))
|
||||||
})
|
})
|
||||||
return { applied, files }
|
return { applied, files }
|
||||||
}).pipe(
|
}).pipe(
|
||||||
@@ -321,11 +329,14 @@ export const Plugin = {
|
|||||||
metadata: { files: output.files },
|
metadata: { files: output.files },
|
||||||
})),
|
})),
|
||||||
Effect.mapError((error) =>
|
Effect.mapError((error) =>
|
||||||
error instanceof ToolFailure ? error : new ToolFailure({ message: "Unable to apply patch", error }),
|
error instanceof ToolFailure
|
||||||
|
? error
|
||||||
|
: new ToolFailure({ message: "Unable to apply patch", error }),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
|
|
||||||
@@ -354,7 +365,9 @@ function errorMessage(error: unknown) {
|
|||||||
|
|
||||||
function patchFile(change: Prepared, after = change.after): typeof FileDiff.Info.Type {
|
function patchFile(change: Prepared, after = change.after): typeof FileDiff.Info.Type {
|
||||||
const target = (change.type === "update" ? change.moveTarget : undefined)?.resource ?? change.target.resource
|
const target = (change.type === "update" ? change.moveTarget : undefined)?.resource ?? change.target.resource
|
||||||
const patch = trimDiff(createTwoFilesPatch(change.target.absolute, change.target.absolute, change.before, after))
|
const patch = trimDiff(
|
||||||
|
createTwoFilesPatch(change.target.canonical, change.target.canonical, change.before, after),
|
||||||
|
)
|
||||||
const counts =
|
const counts =
|
||||||
change.type === "delete"
|
change.type === "delete"
|
||||||
? { additions: 0, deletions: change.before.split("\n").length }
|
? { additions: 0, deletions: change.before.split("\n").length }
|
||||||
@@ -403,22 +416,22 @@ function trimDiff(diff: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveTarget(location: Location.Interface, value: string): Target {
|
function resolveTarget(location: Location.Interface, value: string): Target {
|
||||||
const absolute =
|
const canonical =
|
||||||
process.platform === "win32"
|
process.platform === "win32"
|
||||||
? FSUtil.normalizePath(path.resolve(location.directory, value))
|
? FSUtil.normalizePath(path.resolve(location.directory, value))
|
||||||
: path.resolve(location.directory, value)
|
: path.resolve(location.directory, value)
|
||||||
const projectRoot = path.parse(location.project.directory).root
|
const projectRoot = path.parse(location.project.directory).root
|
||||||
const external =
|
const external =
|
||||||
!FSUtil.contains(location.directory, absolute) &&
|
!FSUtil.contains(location.directory, canonical) &&
|
||||||
(location.project.directory === projectRoot || !FSUtil.contains(location.project.directory, absolute))
|
(location.project.directory === projectRoot || !FSUtil.contains(location.project.directory, canonical))
|
||||||
const directory = path.dirname(absolute)
|
const directory = path.dirname(canonical)
|
||||||
const resource =
|
const resource =
|
||||||
process.platform === "win32"
|
process.platform === "win32"
|
||||||
? FSUtil.normalizePathPattern(path.join(directory, "*"))
|
? FSUtil.normalizePathPattern(path.join(directory, "*"))
|
||||||
: path.join(directory, "*").replaceAll("\\", "/")
|
: path.join(directory, "*").replaceAll("\\", "/")
|
||||||
return {
|
return {
|
||||||
absolute,
|
canonical,
|
||||||
resource: path.relative(location.project.directory, absolute).replaceAll("\\", "/") || ".",
|
resource: path.relative(location.project.directory, canonical).replaceAll("\\", "/") || ".",
|
||||||
externalDirectory: external ? { directory, resource } : undefined,
|
externalDirectory: external ? { directory, resource } : undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,11 @@ const LocationInput = Schema.Struct({
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
export const Input = LocationInput
|
export const Input = LocationInput
|
||||||
const Output = Schema.Union([ReadToolFileSystem.FileContent, ReadToolFileSystem.TextPage, ReadToolFileSystem.ListPage])
|
const Output = Schema.Union([
|
||||||
|
ReadToolFileSystem.FileContent,
|
||||||
|
ReadToolFileSystem.TextPage,
|
||||||
|
ReadToolFileSystem.ListPage,
|
||||||
|
])
|
||||||
|
|
||||||
export const Plugin = {
|
export const Plugin = {
|
||||||
id: "opencode.tool.read",
|
id: "opencode.tool.read",
|
||||||
@@ -39,7 +43,8 @@ export const Plugin = {
|
|||||||
|
|
||||||
yield* ctx.tool
|
yield* ctx.tool
|
||||||
.transform((draft) =>
|
.transform((draft) =>
|
||||||
draft.add({
|
draft.add(
|
||||||
|
({
|
||||||
name,
|
name,
|
||||||
options: { codemode: false },
|
options: { codemode: false },
|
||||||
description:
|
description:
|
||||||
@@ -53,7 +58,7 @@ export const Plugin = {
|
|||||||
messageID: context.messageID,
|
messageID: context.messageID,
|
||||||
id: context.id,
|
id: context.id,
|
||||||
}
|
}
|
||||||
const target = yield* mutation.resolve({ path: input.path })
|
const target = yield* mutation.resolve({ path: input.path, kind: "directory" })
|
||||||
const external = target.externalDirectory
|
const external = target.externalDirectory
|
||||||
if (external)
|
if (external)
|
||||||
yield* permission.assert({
|
yield* permission.assert({
|
||||||
@@ -63,7 +68,7 @@ export const Plugin = {
|
|||||||
source,
|
source,
|
||||||
})
|
})
|
||||||
const resource = target.resource
|
const resource = target.resource
|
||||||
const absolute = AbsolutePath.make(target.absolute)
|
const absolute = AbsolutePath.make(target.canonical)
|
||||||
yield* permission.assert({
|
yield* permission.assert({
|
||||||
action: name,
|
action: name,
|
||||||
resources: [resource],
|
resources: [resource],
|
||||||
@@ -72,9 +77,9 @@ export const Plugin = {
|
|||||||
agent: context.agent,
|
agent: context.agent,
|
||||||
source,
|
source,
|
||||||
})
|
})
|
||||||
const type = yield* reader
|
const type = yield* reader.inspect(absolute).pipe(
|
||||||
.inspect(absolute)
|
Effect.catchReason("PlatformError", "NotFound", () => missing(input.path, target.canonical)),
|
||||||
.pipe(Effect.catchReason("PlatformError", "NotFound", () => missing(input.path, target.absolute)))
|
)
|
||||||
const content =
|
const content =
|
||||||
type === "directory"
|
type === "directory"
|
||||||
? yield* reader.list(absolute, { offset: input.offset, limit: input.limit })
|
? yield* reader.list(absolute, { offset: input.offset, limit: input.limit })
|
||||||
@@ -89,7 +94,7 @@ export const Plugin = {
|
|||||||
// skipped, and discovery failures never fail the read.
|
// skipped, and discovery failures never fail the read.
|
||||||
yield* Effect.gen(function* () {
|
yield* Effect.gen(function* () {
|
||||||
if (target.externalDirectory !== undefined) return
|
if (target.externalDirectory !== undefined) return
|
||||||
const resolved = yield* fs.resolve(target.absolute)
|
const resolved = yield* fs.resolve(target.canonical)
|
||||||
const root = yield* fs.resolve(location.directory)
|
const root = yield* fs.resolve(location.directory)
|
||||||
// up() searches its stop directory, so the Location-root AGENTS.md (already
|
// up() searches its stop directory, so the Location-root AGENTS.md (already
|
||||||
// supplied by core initial instructions) is dropped by the dirname filter.
|
// supplied by core initial instructions) is dropped by the dirname filter.
|
||||||
@@ -114,13 +119,13 @@ export const Plugin = {
|
|||||||
Effect.map((output) => ({
|
Effect.map((output) => ({
|
||||||
output,
|
output,
|
||||||
content: toModelContent(input.path, input.offset, output),
|
content: toModelContent(input.path, input.offset, output),
|
||||||
metadata: { truncated: output.type === "file" ? false : output.truncated },
|
|
||||||
})),
|
})),
|
||||||
Effect.mapError((error) => {
|
Effect.mapError((error) => {
|
||||||
if (error instanceof ToolFailure) return error
|
if (error instanceof ToolFailure) return error
|
||||||
const message =
|
const message =
|
||||||
error instanceof ReadToolFileSystem.BinaryFileError ||
|
error instanceof ReadToolFileSystem.BinaryFileError ||
|
||||||
error instanceof ReadToolFileSystem.MediaIngestLimitError ||
|
error instanceof ReadToolFileSystem.MediaIngestLimitError ||
|
||||||
|
error instanceof ReadToolFileSystem.MalformedUtf8Error ||
|
||||||
error instanceof ReadToolFileSystem.OffsetOutOfRangeError ||
|
error instanceof ReadToolFileSystem.OffsetOutOfRangeError ||
|
||||||
error instanceof ReadToolFileSystem.PathKindError
|
error instanceof ReadToolFileSystem.PathKindError
|
||||||
? error.message
|
? error.message
|
||||||
@@ -130,12 +135,13 @@ export const Plugin = {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
|
|
||||||
const missing = Effect.fn("ReadTool.missing")(function* (input: string, absolute: string) {
|
const missing = Effect.fn("ReadTool.missing")(function* (input: string, canonical: string) {
|
||||||
const base = basename(input).toLowerCase()
|
const base = basename(input).toLowerCase()
|
||||||
const suggestions = yield* fs.readDirectory(dirname(absolute)).pipe(
|
const suggestions = yield* fs.readDirectory(dirname(canonical)).pipe(
|
||||||
Effect.map((entries) =>
|
Effect.map((entries) =>
|
||||||
entries
|
entries
|
||||||
.filter((entry) => {
|
.filter((entry) => {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import type { Content } from "@opencode-ai/schema/tool"
|
|||||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||||
import { Deferred, Effect, Schema, Scope } from "effect"
|
import { Deferred, Effect, Schema, Scope } from "effect"
|
||||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
import { Config } from "../../config"
|
|
||||||
import { LocationMutation } from "../../location-mutation"
|
import { LocationMutation } from "../../location-mutation"
|
||||||
import { Permission } from "../../permission"
|
import { Permission } from "../../permission"
|
||||||
import { PluginRuntime } from "../../plugin/runtime"
|
import { PluginRuntime } from "../../plugin/runtime"
|
||||||
@@ -14,10 +13,10 @@ import { NonNegativeInt } from "../../schema"
|
|||||||
import { SessionSchema } from "../../session/schema"
|
import { SessionSchema } from "../../session/schema"
|
||||||
import { Shell } from "../../shell"
|
import { Shell } from "../../shell"
|
||||||
import { ShellParse } from "../../shell/parse"
|
import { ShellParse } from "../../shell/parse"
|
||||||
import { ToolOutput } from "../../tool-output"
|
|
||||||
|
|
||||||
export const name = "shell"
|
export const name = "shell"
|
||||||
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
|
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
|
||||||
|
export const MAX_CAPTURE_BYTES = 1024 * 1024
|
||||||
|
|
||||||
const BACKGROUND_STARTED = "The command was moved to the background."
|
const BACKGROUND_STARTED = "The command was moved to the background."
|
||||||
const BACKGROUND_INSTRUCTION =
|
const BACKGROUND_INSTRUCTION =
|
||||||
@@ -87,7 +86,6 @@ export const Plugin = {
|
|||||||
const mutation = yield* LocationMutation.Service
|
const mutation = yield* LocationMutation.Service
|
||||||
const shell = yield* Shell.Service
|
const shell = yield* Shell.Service
|
||||||
const permission = yield* Permission.Service
|
const permission = yield* Permission.Service
|
||||||
const config = yield* Config.Service
|
|
||||||
|
|
||||||
const notifyWhenDone = Effect.fn("ShellTool.notifyWhenDone")(function* (
|
const notifyWhenDone = Effect.fn("ShellTool.notifyWhenDone")(function* (
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
@@ -124,7 +122,8 @@ export const Plugin = {
|
|||||||
|
|
||||||
yield* ctx.tool
|
yield* ctx.tool
|
||||||
.transform((draft) =>
|
.transform((draft) =>
|
||||||
draft.add({
|
draft.add(
|
||||||
|
({
|
||||||
name,
|
name,
|
||||||
options: { codemode: false },
|
options: { codemode: false },
|
||||||
description: description(),
|
description: description(),
|
||||||
@@ -149,18 +148,16 @@ export const Plugin = {
|
|||||||
(invocation) =>
|
(invocation) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const target = yield* mutation.resolve({ path: invocation.cwd, kind: "directory" })
|
const target = yield* mutation.resolve({ path: invocation.cwd, kind: "directory" })
|
||||||
const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.absolute)
|
const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.canonical)
|
||||||
const directories = yield* Effect.forEach(parsed.directories, (directory) =>
|
const directories = yield* Effect.forEach(parsed.directories, (directory) =>
|
||||||
mutation.resolve({ path: path.resolve(target.absolute, directory), kind: "directory" }),
|
mutation.resolve({ path: path.resolve(target.canonical, directory), kind: "directory" }),
|
||||||
)
|
)
|
||||||
invocation.cwd = target.absolute
|
invocation.cwd = target.canonical
|
||||||
finalTimeout = invocation.timeout
|
finalTimeout = invocation.timeout
|
||||||
const external = [target, ...directories]
|
const external = [target, ...directories]
|
||||||
.map((item) => item.externalDirectory)
|
.map((item) => item.externalDirectory)
|
||||||
.filter((item) => item !== undefined)
|
.filter((item) => item !== undefined)
|
||||||
.filter(
|
.filter((item, index, items) => items.findIndex((other) => other.resource === item.resource) === index)
|
||||||
(item, index, items) => items.findIndex((other) => other.resource === item.resource) === index,
|
|
||||||
)
|
|
||||||
if (external.length > 0)
|
if (external.length > 0)
|
||||||
yield* permission.assert({
|
yield* permission.assert({
|
||||||
action: "external_directory",
|
action: "external_directory",
|
||||||
@@ -179,35 +176,27 @@ export const Plugin = {
|
|||||||
agent: context.agent,
|
agent: context.agent,
|
||||||
source,
|
source,
|
||||||
})
|
})
|
||||||
const workdir = yield* fsUtil
|
const workdir = yield* fsUtil.stat(target.canonical).pipe(
|
||||||
.stat(target.absolute)
|
|
||||||
.pipe(
|
|
||||||
Effect.catchReason("PlatformError", "NotFound", () =>
|
Effect.catchReason("PlatformError", "NotFound", () =>
|
||||||
Effect.fail(new Error(`Working directory does not exist: ${target.absolute}`)),
|
Effect.fail(new Error(`Working directory does not exist: ${target.canonical}`)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (workdir.type !== "Directory")
|
if (workdir.type !== "Directory")
|
||||||
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.absolute}`))
|
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
yield* context.progress({ shellID: info.id })
|
yield* context.progress({ shellID: info.id })
|
||||||
|
|
||||||
const captureShell = Effect.fn("ShellTool.captureShell")(function* () {
|
const captureShell = Effect.fn("ShellTool.captureShell")(function* () {
|
||||||
const configured = Config.latest(yield* config.entries(), "tool_output")
|
|
||||||
const maxLines = configured?.max_lines ?? ToolOutput.MAX_LINES
|
|
||||||
const maxBytes = configured?.max_bytes ?? ToolOutput.MAX_BYTES
|
|
||||||
const latest = yield* shell.output(info.id, { cursor: Number.MAX_SAFE_INTEGER })
|
const latest = yield* shell.output(info.id, { cursor: Number.MAX_SAFE_INTEGER })
|
||||||
|
const truncated = latest.size > MAX_CAPTURE_BYTES
|
||||||
const page = yield* shell.output(info.id, {
|
const page = yield* shell.output(info.id, {
|
||||||
cursor: Math.max(0, latest.size - maxBytes),
|
cursor: Math.max(0, latest.size - MAX_CAPTURE_BYTES),
|
||||||
limit: maxBytes,
|
limit: MAX_CAPTURE_BYTES,
|
||||||
})
|
})
|
||||||
const lines = page.output.split("\n")
|
|
||||||
if (page.output.endsWith("\n")) lines.pop()
|
|
||||||
const truncated = latest.size > maxBytes || lines.length > maxLines
|
|
||||||
const output = lines.length > maxLines ? lines.slice(-maxLines).join("\n") : page.output
|
|
||||||
const notice = truncated ? `\n\n[output truncated; full output saved to: ${info.file}]` : ""
|
const notice = truncated ? `\n\n[output truncated; full output saved to: ${info.file}]` : ""
|
||||||
return {
|
return {
|
||||||
output: `${output || "(no output)"}${notice}`,
|
output: `${page.output || "(no output)"}${notice}`,
|
||||||
truncated,
|
truncated,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -261,9 +250,9 @@ export const Plugin = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = yield* runtime.job
|
const result = yield* runtime.job.block({ id: job.id, sessionID: context.sessionID }).pipe(
|
||||||
.block({ id: job.id, sessionID: context.sessionID })
|
Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)),
|
||||||
.pipe(Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)))
|
)
|
||||||
if (result?.type === "backgrounded") {
|
if (result?.type === "backgrounded") {
|
||||||
yield* shell.timeout(info.id, 0)
|
yield* shell.timeout(info.id, 0)
|
||||||
yield* notifyWhenDone(context.sessionID, context.id, info.command)
|
yield* notifyWhenDone(context.sessionID, context.id, info.command)
|
||||||
@@ -300,6 +289,7 @@ export const Plugin = {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ export * as WebSearchTool from "./websearch"
|
|||||||
|
|
||||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||||
import { ToolFailure } from "@opencode-ai/ai"
|
import { ToolFailure } from "@opencode-ai/ai"
|
||||||
import { Effect, Schema, Semaphore } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import { Form } from "../../form"
|
import { Form } from "../../form"
|
||||||
import { KV } from "../../kv"
|
import { KV } from "../../kv"
|
||||||
import { Permission } from "../../permission"
|
import { Permission } from "../../permission"
|
||||||
@@ -10,7 +10,6 @@ import { WebSearch } from "../../websearch"
|
|||||||
|
|
||||||
export const name = "websearch"
|
export const name = "websearch"
|
||||||
export const NO_RESULTS = "No search results found. Please try a different query."
|
export const NO_RESULTS = "No search results found. Please try a different query."
|
||||||
const providerSelectionLock = Semaphore.makeUnsafe(1)
|
|
||||||
|
|
||||||
export const description = `Search the web using the user's selected search integration. Use this for current information beyond knowledge cutoff.
|
export const description = `Search the web using the user's selected search integration. Use this for current information beyond knowledge cutoff.
|
||||||
|
|
||||||
@@ -30,7 +29,6 @@ export const Plugin = {
|
|||||||
const permission = yield* Permission.Service
|
const permission = yield* Permission.Service
|
||||||
const forms = yield* Form.Service
|
const forms = yield* Form.Service
|
||||||
const kv = yield* KV.Service
|
const kv = yield* KV.Service
|
||||||
const websearch = yield* WebSearch.Service
|
|
||||||
|
|
||||||
yield* ctx.tool
|
yield* ctx.tool
|
||||||
.transform((draft) =>
|
.transform((draft) =>
|
||||||
@@ -51,20 +49,17 @@ export const Plugin = {
|
|||||||
agent: context.agent,
|
agent: context.agent,
|
||||||
source: { type: "tool", messageID: context.messageID, id: context.id },
|
source: { type: "tool", messageID: context.messageID, id: context.id },
|
||||||
})
|
})
|
||||||
const search = (): Effect.Effect<Effect.Success<ReturnType<typeof ctx.websearch.query>>, unknown> =>
|
const result = yield* ctx.websearch.query(input).pipe(
|
||||||
ctx.websearch.query(input).pipe(
|
|
||||||
Effect.catch((error) => {
|
Effect.catch((error) => {
|
||||||
if (!Schema.is(WebSearch.ProviderRequiredError)(error)) return Effect.fail(error)
|
if (!Schema.is(WebSearch.ProviderRequiredError)(error)) return Effect.fail(error)
|
||||||
return providerSelectionLock
|
return Effect.gen(function* () {
|
||||||
.withPermit(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
if (yield* websearch.default()) return yield* Effect.void
|
|
||||||
const providers = (yield* ctx.websearch.providers()).data
|
const providers = (yield* ctx.websearch.providers()).data
|
||||||
const defaultProvider = providers[0]
|
const defaultProvider = providers[0]
|
||||||
if (!defaultProvider) return yield* new WebSearch.ProviderRequiredError()
|
if (!defaultProvider) return yield* new WebSearch.ProviderRequiredError()
|
||||||
const response = yield* forms.ask({
|
const response = yield* forms.ask({
|
||||||
sessionID: context.sessionID,
|
sessionID: context.sessionID,
|
||||||
title: "Web Search",
|
title: "Web Search",
|
||||||
|
coalesce: `${context.messageID}:websearch-consent`,
|
||||||
metadata: { kind: "websearch.provider" },
|
metadata: { kind: "websearch.provider" },
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
@@ -87,8 +82,7 @@ export const Plugin = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
if (response.status === "cancelled")
|
if (response.status === "cancelled") return yield* Effect.fail(new Error("Web search cancelled"))
|
||||||
return yield* Effect.fail(new Error("Web search cancelled"))
|
|
||||||
if (response.answer.choice === "disable") {
|
if (response.answer.choice === "disable") {
|
||||||
yield* kv.set("websearch:provider", false)
|
yield* kv.set("websearch:provider", false)
|
||||||
return yield* new WebSearch.DisabledError()
|
return yield* new WebSearch.DisabledError()
|
||||||
@@ -98,6 +92,7 @@ export const Plugin = {
|
|||||||
? yield* forms.ask({
|
? yield* forms.ask({
|
||||||
sessionID: context.sessionID,
|
sessionID: context.sessionID,
|
||||||
title: "Choose a web search provider",
|
title: "Choose a web search provider",
|
||||||
|
coalesce: `${context.messageID}:websearch-provider`,
|
||||||
metadata: { kind: "websearch.provider" },
|
metadata: { kind: "websearch.provider" },
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
@@ -106,35 +101,20 @@ export const Plugin = {
|
|||||||
type: "string",
|
type: "string",
|
||||||
required: true,
|
required: true,
|
||||||
custom: false,
|
custom: false,
|
||||||
options: providers.map((provider) => ({
|
options: providers.map((provider) => ({ value: provider.id, label: provider.name })),
|
||||||
value: provider.id,
|
|
||||||
label: provider.name,
|
|
||||||
})),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
: undefined
|
: undefined
|
||||||
if (selection?.status === "cancelled")
|
if (selection?.status === "cancelled") return yield* Effect.fail(new Error("Web search cancelled"))
|
||||||
return yield* Effect.fail(new Error("Web search cancelled"))
|
|
||||||
const providerID = selection?.answer.provider ?? defaultProvider.id
|
const providerID = selection?.answer.provider ?? defaultProvider.id
|
||||||
if (
|
if (typeof providerID !== "string" || !providers.some((provider) => provider.id === providerID))
|
||||||
typeof providerID !== "string" ||
|
|
||||||
!providers.some((provider) => provider.id === providerID)
|
|
||||||
)
|
|
||||||
return yield* new WebSearch.ProviderRequiredError()
|
return yield* new WebSearch.ProviderRequiredError()
|
||||||
return yield* kv.set("websearch:provider", providerID)
|
yield* kv.set("websearch:provider", providerID)
|
||||||
|
return yield* ctx.websearch.query(input)
|
||||||
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.pipe(
|
|
||||||
Effect.timeoutOrElse({
|
|
||||||
duration: "1 minute",
|
|
||||||
orElse: () => Effect.fail(new Error("Web search cancelled")),
|
|
||||||
}),
|
|
||||||
Effect.andThen(Effect.suspend(search)),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const result = yield* search()
|
|
||||||
const output = {
|
const output = {
|
||||||
provider: result.data.providerID,
|
provider: result.data.providerID,
|
||||||
results: result.data.results,
|
results: result.data.results,
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ export const Plugin = {
|
|||||||
|
|
||||||
yield* ctx.tool
|
yield* ctx.tool
|
||||||
.transform((draft) =>
|
.transform((draft) =>
|
||||||
draft.add({
|
draft.add(
|
||||||
|
({
|
||||||
name,
|
name,
|
||||||
options: { codemode: false, permission: "edit" },
|
options: { codemode: false, permission: "edit" },
|
||||||
description:
|
description:
|
||||||
@@ -77,11 +78,16 @@ export const Plugin = {
|
|||||||
agent: context.agent,
|
agent: context.agent,
|
||||||
source,
|
source,
|
||||||
})
|
})
|
||||||
const current = yield* Bom.readFile(fs, target.absolute).pipe(
|
const current = yield* Bom.readFile(fs, target.canonical).pipe(
|
||||||
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)),
|
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)),
|
||||||
)
|
)
|
||||||
const next = Bom.split(input.content)
|
const next = Bom.split(input.content)
|
||||||
const preview = fileDiff(target.resource, current?.text ?? "", next.text, current ? "modified" : "added")
|
const preview = fileDiff(
|
||||||
|
target.resource,
|
||||||
|
current?.text ?? "",
|
||||||
|
next.text,
|
||||||
|
current ? "modified" : "added",
|
||||||
|
)
|
||||||
yield* permission.assert({
|
yield* permission.assert({
|
||||||
action: "edit",
|
action: "edit",
|
||||||
resources: [target.resource],
|
resources: [target.resource],
|
||||||
@@ -92,14 +98,15 @@ export const Plugin = {
|
|||||||
source,
|
source,
|
||||||
})
|
})
|
||||||
const result = yield* files.writeTextPreservingBom({ target, content: input.content })
|
const result = yield* files.writeTextPreservingBom({ target, content: input.content })
|
||||||
const bom = (yield* Bom.readFile(fs, target.absolute)).bom
|
const bom = (yield* Bom.readFile(fs, target.canonical)).bom
|
||||||
if (yield* formatter.file(target.absolute)) yield* Bom.syncFile(fs, target.absolute, bom)
|
if (yield* formatter.file(target.canonical)) yield* Bom.syncFile(fs, target.canonical, bom)
|
||||||
return result
|
return result
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.map((output) => ({ output, content: toModelOutput(output) })),
|
Effect.map((output) => ({ output, content: toModelOutput(output) })),
|
||||||
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
|
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -34,6 +34,14 @@ export class MediaIngestLimitError extends Schema.TaggedErrorClass<MediaIngestLi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class MalformedUtf8Error extends Schema.TaggedErrorClass<MalformedUtf8Error>()("ReadTool.MalformedUtf8Error", {
|
||||||
|
resource: Schema.String,
|
||||||
|
}) {
|
||||||
|
override get message() {
|
||||||
|
return `File is not valid UTF-8: ${this.resource}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class OffsetOutOfRangeError extends Schema.TaggedErrorClass<OffsetOutOfRangeError>()(
|
export class OffsetOutOfRangeError extends Schema.TaggedErrorClass<OffsetOutOfRangeError>()(
|
||||||
"ReadTool.OffsetOutOfRangeError",
|
"ReadTool.OffsetOutOfRangeError",
|
||||||
{ offset: Schema.Number },
|
{ offset: Schema.Number },
|
||||||
@@ -53,7 +61,13 @@ export class PathKindError extends Schema.TaggedErrorClass<PathKindError>()("Rea
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type InspectError = FSUtil.Error | PathKindError
|
export type InspectError = FSUtil.Error | PathKindError
|
||||||
export type ReadError = FSUtil.Error | BinaryFileError | MediaIngestLimitError | OffsetOutOfRangeError | PathKindError
|
export type ReadError =
|
||||||
|
| FSUtil.Error
|
||||||
|
| BinaryFileError
|
||||||
|
| MediaIngestLimitError
|
||||||
|
| MalformedUtf8Error
|
||||||
|
| OffsetOutOfRangeError
|
||||||
|
| PathKindError
|
||||||
|
|
||||||
export const PageInput = Schema.Struct({
|
export const PageInput = Schema.Struct({
|
||||||
offset: Schema.optionalKey(NonNegativeInt),
|
offset: Schema.optionalKey(NonNegativeInt),
|
||||||
@@ -76,15 +90,9 @@ export class TextPage extends Schema.Class<TextPage>("ReadTool.TextPage")({
|
|||||||
next: Schema.optionalKey(PositiveInt),
|
next: Schema.optionalKey(PositiveInt),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export interface ListEntry extends Schema.Schema.Type<typeof ListEntry> {}
|
|
||||||
export const ListEntry = Schema.Struct({
|
|
||||||
path: RelativePath,
|
|
||||||
type: Schema.Literals(["file", "directory", "symlink"]),
|
|
||||||
}).annotate({ identifier: "ReadTool.ListEntry" })
|
|
||||||
|
|
||||||
export class ListPage extends Schema.Class<ListPage>("ReadTool.ListPage")({
|
export class ListPage extends Schema.Class<ListPage>("ReadTool.ListPage")({
|
||||||
type: Schema.Literal("list-page"),
|
type: Schema.Literal("list-page"),
|
||||||
entries: Schema.Array(ListEntry),
|
entries: Schema.Array(FileSystem.Entry),
|
||||||
truncated: Schema.Boolean,
|
truncated: Schema.Boolean,
|
||||||
next: Schema.optionalKey(PositiveInt),
|
next: Schema.optionalKey(PositiveInt),
|
||||||
}) {}
|
}) {}
|
||||||
@@ -101,6 +109,36 @@ export interface Interface {
|
|||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ReadToolFileSystem") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/ReadToolFileSystem") {}
|
||||||
|
|
||||||
|
const extensions = new Set([
|
||||||
|
".zip",
|
||||||
|
".tar",
|
||||||
|
".gz",
|
||||||
|
".exe",
|
||||||
|
".dll",
|
||||||
|
".so",
|
||||||
|
".class",
|
||||||
|
".jar",
|
||||||
|
".war",
|
||||||
|
".7z",
|
||||||
|
".doc",
|
||||||
|
".docx",
|
||||||
|
".xls",
|
||||||
|
".xlsx",
|
||||||
|
".ppt",
|
||||||
|
".pptx",
|
||||||
|
".odt",
|
||||||
|
".ods",
|
||||||
|
".odp",
|
||||||
|
".bin",
|
||||||
|
".dat",
|
||||||
|
".obj",
|
||||||
|
".o",
|
||||||
|
".a",
|
||||||
|
".lib",
|
||||||
|
".wasm",
|
||||||
|
".pyc",
|
||||||
|
".pyo",
|
||||||
|
])
|
||||||
const startsWith = (bytes: Uint8Array, prefix: number[]) => prefix.every((value, index) => bytes[index] === value)
|
const startsWith = (bytes: Uint8Array, prefix: number[]) => prefix.every((value, index) => bytes[index] === value)
|
||||||
const mediaMime = (bytes: Uint8Array) => {
|
const mediaMime = (bytes: Uint8Array) => {
|
||||||
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png"
|
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png"
|
||||||
@@ -110,7 +148,8 @@ const mediaMime = (bytes: Uint8Array) => {
|
|||||||
return "image/webp"
|
return "image/webp"
|
||||||
if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d])) return "application/pdf"
|
if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d])) return "application/pdf"
|
||||||
}
|
}
|
||||||
const binary = (bytes: Uint8Array) => {
|
const binary = (resource: string, bytes: Uint8Array) => {
|
||||||
|
if (extensions.has(path.extname(resource).toLowerCase())) return true
|
||||||
if (bytes.length === 0) return false
|
if (bytes.length === 0) return false
|
||||||
let nonPrintable = 0
|
let nonPrintable = 0
|
||||||
for (const byte of bytes) {
|
for (const byte of bytes) {
|
||||||
@@ -119,9 +158,16 @@ const binary = (bytes: Uint8Array) => {
|
|||||||
}
|
}
|
||||||
return nonPrintable / bytes.length > 0.3
|
return nonPrintable / bytes.length > 0.3
|
||||||
}
|
}
|
||||||
const decodeUtf8 = (decoder: TextDecoder, bytes?: Uint8Array) => decoder.decode(bytes, { stream: bytes !== undefined })
|
const decodeUtf8 = (resource: string, decoder: TextDecoder, bytes?: Uint8Array) =>
|
||||||
|
Effect.try({
|
||||||
|
try: () => decoder.decode(bytes, { stream: bytes !== undefined }),
|
||||||
|
catch: (error) => {
|
||||||
|
if (error instanceof TypeError) return new MalformedUtf8Error({ resource })
|
||||||
|
throw error
|
||||||
|
},
|
||||||
|
})
|
||||||
const decodeChunk = (resource: string, decoder: TextDecoder, bytes: Uint8Array) =>
|
const decodeChunk = (resource: string, decoder: TextDecoder, bytes: Uint8Array) =>
|
||||||
bytes.includes(0) ? Effect.fail(new BinaryFileError({ resource })) : Effect.succeed(decodeUtf8(decoder, bytes))
|
bytes.includes(0) ? Effect.fail(new BinaryFileError({ resource })) : decodeUtf8(resource, decoder, bytes)
|
||||||
|
|
||||||
export const inspect = Effect.fn("ReadTool.inspect")(function* (fs: FSUtil.Interface, input: string) {
|
export const inspect = Effect.fn("ReadTool.inspect")(function* (fs: FSUtil.Interface, input: string) {
|
||||||
const info = yield* fs.stat(input)
|
const info = yield* fs.stat(input)
|
||||||
@@ -172,17 +218,19 @@ export const read = Effect.fn("ReadTool.read")(function* (
|
|||||||
mime,
|
mime,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (extensions.has(path.extname(resource).toLowerCase()))
|
||||||
|
return yield* Effect.fail(new BinaryFileError({ resource }))
|
||||||
const paged = info.size > MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined
|
const paged = info.size > MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined
|
||||||
if (!paged) {
|
if (!paged) {
|
||||||
if (binary(first)) return yield* Effect.fail(new BinaryFileError({ resource }))
|
if (binary(resource, first)) return yield* Effect.fail(new BinaryFileError({ resource }))
|
||||||
const decoder = new TextDecoder()
|
const decoder = new TextDecoder("utf-8", { fatal: true })
|
||||||
const text = [decodeUtf8(decoder, first)]
|
const text = [yield* decodeUtf8(resource, decoder, first)]
|
||||||
while (true) {
|
while (true) {
|
||||||
const chunk = yield* file.readAlloc(64 * 1024)
|
const chunk = yield* file.readAlloc(64 * 1024)
|
||||||
if (Option.isNone(chunk)) break
|
if (Option.isNone(chunk)) break
|
||||||
text.push(yield* decodeChunk(resource, decoder, chunk.value))
|
text.push(yield* decodeChunk(resource, decoder, chunk.value))
|
||||||
}
|
}
|
||||||
text.push(decodeUtf8(decoder))
|
text.push(yield* decodeUtf8(resource, decoder))
|
||||||
return {
|
return {
|
||||||
type: "file" as const,
|
type: "file" as const,
|
||||||
uri: pathToFileURL(real).href,
|
uri: pathToFileURL(real).href,
|
||||||
@@ -195,7 +243,7 @@ export const read = Effect.fn("ReadTool.read")(function* (
|
|||||||
const offset = page.offset || 1
|
const offset = page.offset || 1
|
||||||
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
|
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
|
||||||
const lines: string[] = []
|
const lines: string[] = []
|
||||||
const decoder = new TextDecoder()
|
const decoder = new TextDecoder("utf-8", { fatal: true })
|
||||||
let pending = ""
|
let pending = ""
|
||||||
let discard = false
|
let discard = false
|
||||||
let line = 1
|
let line = 1
|
||||||
@@ -253,8 +301,8 @@ export const read = Effect.fn("ReadTool.read")(function* (
|
|||||||
const newline = chunk.indexOf(10, start)
|
const newline = chunk.indexOf(10, start)
|
||||||
const end = newline === -1 ? chunk.length : newline + 1
|
const end = newline === -1 ? chunk.length : newline + 1
|
||||||
const segment = chunk.subarray(start, end)
|
const segment = chunk.subarray(start, end)
|
||||||
if (binary(segment)) return yield* Effect.fail(new BinaryFileError({ resource }))
|
if (binary(resource, segment)) return yield* Effect.fail(new BinaryFileError({ resource }))
|
||||||
if (!consume(decodeUtf8(decoder, segment))) return false
|
if (!consume(yield* decodeUtf8(resource, decoder, segment))) return false
|
||||||
start = end
|
start = end
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@@ -266,7 +314,7 @@ export const read = Effect.fn("ReadTool.read")(function* (
|
|||||||
done = !(yield* consumeChunk(chunk.value))
|
done = !(yield* consumeChunk(chunk.value))
|
||||||
}
|
}
|
||||||
if (!done) {
|
if (!done) {
|
||||||
const tail = decodeUtf8(decoder)
|
const tail = yield* decodeUtf8(resource, decoder)
|
||||||
if (!discard) pending += tail
|
if (!discard) pending += tail
|
||||||
if (pending) append(pending.endsWith("\r") ? pending.slice(0, -1) : pending)
|
if (pending) append(pending.endsWith("\r") ? pending.slice(0, -1) : pending)
|
||||||
}
|
}
|
||||||
@@ -288,26 +336,26 @@ export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface,
|
|||||||
const items = yield* fs.readDirectoryEntries(real)
|
const items = yield* fs.readDirectoryEntries(real)
|
||||||
const offset = page.offset || 1
|
const offset = page.offset || 1
|
||||||
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
|
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
|
||||||
const visible = items
|
const entries = yield* Effect.forEach(
|
||||||
.flatMap((item) =>
|
items,
|
||||||
item.type === "other"
|
(item) =>
|
||||||
? []
|
Effect.gen(function* () {
|
||||||
: [
|
const absolute = path.join(real, item.name)
|
||||||
ListEntry.make({
|
const target = yield* fs.realPath(absolute).pipe(Effect.catch(() => Effect.void))
|
||||||
path: RelativePath.make(item.name + (item.type === "directory" ? path.sep : "")),
|
if (!target || !FSUtil.contains(real, target)) return
|
||||||
type: item.type,
|
const info = yield* fs.stat(target).pipe(Effect.catch(() => Effect.void))
|
||||||
|
const type = info?.type === "Directory" ? "directory" : info?.type === "File" ? "file" : undefined
|
||||||
|
if (!type) return
|
||||||
|
return FileSystem.Entry.make({
|
||||||
|
path: RelativePath.make(item.name + (type === "directory" ? path.sep : "")),
|
||||||
|
type,
|
||||||
|
})
|
||||||
}),
|
}),
|
||||||
],
|
{ concurrency: 16 },
|
||||||
)
|
|
||||||
.sort((a, b) =>
|
|
||||||
a.type === "directory"
|
|
||||||
? b.type === "directory"
|
|
||||||
? a.path.localeCompare(b.path)
|
|
||||||
: -1
|
|
||||||
: b.type === "directory"
|
|
||||||
? 1
|
|
||||||
: a.path.localeCompare(b.path),
|
|
||||||
)
|
)
|
||||||
|
const visible = entries
|
||||||
|
.filter((item): item is FileSystem.Entry => item !== undefined)
|
||||||
|
.sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1))
|
||||||
const selected = visible.slice(offset - 1, offset - 1 + limit)
|
const selected = visible.slice(offset - 1, offset - 1 + limit)
|
||||||
const truncated = offset - 1 + selected.length < visible.length
|
const truncated = offset - 1 + selected.length < visible.length
|
||||||
return new ListPage({
|
return new ListPage({
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
export * as ConfigMigrateV1 from "./migrate"
|
export * as ConfigMigrateV1 from "./migrate"
|
||||||
|
|
||||||
import { Info } from "@opencode-ai/schema/config"
|
|
||||||
import { ConfigAgent } from "@opencode-ai/schema/config/agent"
|
|
||||||
import { Schema } from "effect"
|
|
||||||
import { ConfigV1 } from "./config"
|
import { ConfigV1 } from "./config"
|
||||||
import { ConfigAgentV1 } from "./agent"
|
import { ConfigAgentV1 } from "./agent"
|
||||||
import { ConfigCommandV1 } from "./command"
|
import { ConfigCommandV1 } from "./command"
|
||||||
@@ -13,15 +10,45 @@ import { ConfigProviderOptionsV1 } from "./provider-options"
|
|||||||
import { Provider } from "../../provider"
|
import { Provider } from "../../provider"
|
||||||
import { Model } from "../../model"
|
import { Model } from "../../model"
|
||||||
|
|
||||||
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
const keys = new Set([
|
||||||
const decodeInfo = Schema.decodeUnknownSync(Schema.fromJsonString(Info), decodeOptions)
|
"logLevel",
|
||||||
const encodeInfo = Schema.encodeSync(Info)
|
"server",
|
||||||
const decodeAgent = Schema.decodeUnknownSync(Schema.fromJsonString(ConfigAgent.Info), decodeOptions)
|
"command",
|
||||||
const encodeAgent = Schema.encodeSync(ConfigAgent.Info)
|
"reference",
|
||||||
|
"snapshot",
|
||||||
|
"plugin",
|
||||||
|
"autoshare",
|
||||||
|
"disabled_providers",
|
||||||
|
"enabled_providers",
|
||||||
|
"small_model",
|
||||||
|
"mode",
|
||||||
|
"agent",
|
||||||
|
"provider",
|
||||||
|
"permission",
|
||||||
|
"tools",
|
||||||
|
"attachment",
|
||||||
|
"layout",
|
||||||
|
])
|
||||||
|
|
||||||
|
export function isV1(input: unknown) {
|
||||||
|
if (typeof input !== "object" || input === null || Array.isArray(input)) return false
|
||||||
|
const record = input as Record<string, unknown>
|
||||||
|
if (Object.keys(record).some((key) => keys.has(key))) return true
|
||||||
|
// `mcp` exists in both versions, so presence alone is ambiguous: v1 lists servers directly under
|
||||||
|
// `mcp`, while v2 nests them under `mcp.servers`. Only the v1 shape (a server entry with `type`)
|
||||||
|
// counts, so a bare `mcp`-only file still migrates instead of silently parsing to zero servers.
|
||||||
|
const mcp = record.mcp
|
||||||
|
return (
|
||||||
|
typeof mcp === "object" &&
|
||||||
|
mcp !== null &&
|
||||||
|
!Array.isArray(mcp) &&
|
||||||
|
!("servers" in mcp) &&
|
||||||
|
Object.values(mcp).some((server) => typeof server === "object" && server !== null && "type" in server)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function migrate(info: typeof ConfigV1.Info.Type) {
|
export function migrate(info: typeof ConfigV1.Info.Type) {
|
||||||
return encodeInfo(
|
return {
|
||||||
decodeInfo(
|
|
||||||
JSON.stringify({
|
|
||||||
$schema: info.$schema,
|
$schema: info.$schema,
|
||||||
shell: info.shell,
|
shell: info.shell,
|
||||||
model: modelSelection(info.model),
|
model: modelSelection(info.model),
|
||||||
@@ -56,9 +83,7 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
|
|||||||
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
|
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
|
||||||
),
|
),
|
||||||
providers: providers(info.provider),
|
providers: providers(info.provider),
|
||||||
}),
|
}
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function experimental(info: typeof ConfigV1.Info.Type) {
|
function experimental(info: typeof ConfigV1.Info.Type) {
|
||||||
@@ -107,7 +132,7 @@ function permissions(info?: ConfigPermissionV1.Info, tools?: Readonly<Record<str
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Map v1 permission/tool keys onto their renamed v2 tool actions so migrated rules keep matching.
|
// Map v1 permission/tool keys onto their renamed v2 tool actions so migrated rules keep matching.
|
||||||
export function normalizeAction(action: string) {
|
function normalizeAction(action: string) {
|
||||||
if (action === "write" || action === "patch") return "edit"
|
if (action === "write" || action === "patch") return "edit"
|
||||||
if (action === "task") return "subagent"
|
if (action === "task") return "subagent"
|
||||||
if (action === "bash") return "shell"
|
if (action === "bash") return "shell"
|
||||||
@@ -119,16 +144,8 @@ function agents(info: typeof ConfigV1.Info.Type) {
|
|||||||
...Object.entries(info.agent ?? {}),
|
...Object.entries(info.agent ?? {}),
|
||||||
...Object.entries(info.mode ?? {}).map(([name, agent]) => [name, { ...agent, mode: "primary" as const }] as const),
|
...Object.entries(info.mode ?? {}).map(([name, agent]) => [name, { ...agent, mode: "primary" as const }] as const),
|
||||||
]
|
]
|
||||||
const result = Object.fromEntries(entries.flatMap(([name, agent]) => (agent ? [[name, migrateAgent(agent)]] : [])))
|
if (!entries.length) return undefined
|
||||||
const small = modelSelection(info.small_model)
|
return Object.fromEntries(entries.flatMap(([name, agent]) => (agent ? [[name, migrateAgent(agent)]] : [])))
|
||||||
if (!small) return entries.length ? result : undefined
|
|
||||||
return {
|
|
||||||
...result,
|
|
||||||
title: {
|
|
||||||
model: small,
|
|
||||||
...result.title,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function migrateAgent(info: ConfigAgentV1.Info) {
|
export function migrateAgent(info: ConfigAgentV1.Info) {
|
||||||
@@ -137,9 +154,7 @@ export function migrateAgent(info: ConfigAgentV1.Info) {
|
|||||||
...(info.temperature === undefined ? {} : { temperature: info.temperature }),
|
...(info.temperature === undefined ? {} : { temperature: info.temperature }),
|
||||||
...(info.top_p === undefined ? {} : { top_p: info.top_p }),
|
...(info.top_p === undefined ? {} : { top_p: info.top_p }),
|
||||||
}
|
}
|
||||||
return encodeAgent(
|
return {
|
||||||
decodeAgent(
|
|
||||||
JSON.stringify({
|
|
||||||
model: modelSelection(info.model, info.variant),
|
model: modelSelection(info.model, info.variant),
|
||||||
request: Object.keys(body).length ? { body } : undefined,
|
request: Object.keys(body).length ? { body } : undefined,
|
||||||
system: info.prompt,
|
system: info.prompt,
|
||||||
@@ -150,12 +165,10 @@ export function migrateAgent(info: ConfigAgentV1.Info) {
|
|||||||
steps: info.steps,
|
steps: info.steps,
|
||||||
disabled: info.disable,
|
disabled: info.disable,
|
||||||
permissions: permissions(info.permission),
|
permissions: permissions(info.permission),
|
||||||
}),
|
}
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function commands(info?: Readonly<Record<string, ConfigCommandV1.Info>>) {
|
function commands(info?: Readonly<Record<string, ConfigCommandV1.Info>>) {
|
||||||
if (!info) return undefined
|
if (!info) return undefined
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
Object.entries(info).map(([id, command]) => [
|
Object.entries(info).map(([id, command]) => [
|
||||||
@@ -192,7 +205,7 @@ function mcp(info: typeof ConfigV1.Info.Type) {
|
|||||||
return { timeout: timeout === undefined ? undefined : { catalog: timeout, execution: timeout }, servers }
|
return { timeout: timeout === undefined ? undefined : { catalog: timeout, execution: timeout }, servers }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function migrateMcp(info: ConfigMCPV1.Info) {
|
function migrateMcp(info: ConfigMCPV1.Info) {
|
||||||
const disabled = info.enabled === undefined ? undefined : !info.enabled
|
const disabled = info.enabled === undefined ? undefined : !info.enabled
|
||||||
if (info.type === "local")
|
if (info.type === "local")
|
||||||
return {
|
return {
|
||||||
@@ -231,7 +244,7 @@ function providers(info?: Readonly<Record<string, ConfigProviderV1.Info>>) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function migrateProvider(sourceID: string, info: ConfigProviderV1.Info) {
|
function migrateProvider(sourceID: string, info: ConfigProviderV1.Info) {
|
||||||
if (sourceID === "azure-cognitive-services") return migrateAzureCognitiveServicesProvider(info)
|
if (sourceID === "azure-cognitive-services") return migrateAzureCognitiveServicesProvider(info)
|
||||||
if (sourceID === "google-vertex-anthropic") return migrateGoogleVertexAnthropicProvider(info)
|
if (sourceID === "google-vertex-anthropic") return migrateGoogleVertexAnthropicProvider(info)
|
||||||
return migrateStandardProvider(info)
|
return migrateStandardProvider(info)
|
||||||
@@ -243,7 +256,7 @@ function migrateStandardProvider(info: ConfigProviderV1.Info) {
|
|||||||
name: info.name,
|
name: info.name,
|
||||||
env: info.env,
|
env: info.env,
|
||||||
package: info.npm ? Provider.aisdk(info.npm) : undefined,
|
package: info.npm ? Provider.aisdk(info.npm) : undefined,
|
||||||
settings: info.api ? { ...options.settings, baseURL: info.api } : info.options ? options.settings : undefined,
|
settings: info.api ? { ...options.settings, baseURL: info.api } : options.settings,
|
||||||
headers: info.options && options.headers,
|
headers: info.options && options.headers,
|
||||||
body: info.options && options.body,
|
body: info.options && options.body,
|
||||||
models:
|
models:
|
||||||
@@ -287,8 +300,8 @@ function migrateGoogleVertexAnthropicProvider(info: ConfigProviderV1.Info) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rename these only while migrating unambiguous V1 fields.
|
// Rename these only in files detected as V1 by a field that exists only in the old config format.
|
||||||
export function providerID(input: string) {
|
function providerID(input: string) {
|
||||||
if (input === "azure-cognitive-services") return "azure"
|
if (input === "azure-cognitive-services") return "azure"
|
||||||
if (input === "google-vertex-anthropic") return "google-vertex"
|
if (input === "google-vertex-anthropic") return "google-vertex"
|
||||||
return input
|
return input
|
||||||
|
|||||||
@@ -126,7 +126,6 @@ describe("Agent", () => {
|
|||||||
|
|
||||||
yield* agent.transform((editor) => editor.update(id, () => {}))
|
yield* agent.transform((editor) => editor.update(id, () => {}))
|
||||||
const info = yield* agent.get(id)
|
const info = yield* agent.get(id)
|
||||||
expect(info?.mode).toBe("primary")
|
|
||||||
expect(info?.permissions.slice(0, Agent.Info.default(id).permissions.length)).toEqual(
|
expect(info?.permissions.slice(0, Agent.Info.default(id).permissions.length)).toEqual(
|
||||||
Agent.Info.default(id).permissions,
|
Agent.Info.default(id).permissions,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
|||||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
import { Global } from "@opencode-ai/util/global"
|
import { Global } from "@opencode-ai/util/global"
|
||||||
import { Permission } from "@opencode-ai/core/permission"
|
import { Permission } from "@opencode-ai/core/permission"
|
||||||
import { AgentPlugin } from "@opencode-ai/core/plugin/agent"
|
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
|
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
|
||||||
import { advance, drain } from "../lib/clock"
|
import { advance, drain } from "../lib/clock"
|
||||||
@@ -51,11 +50,6 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
|||||||
it.effect("matches Windows paths against home-relative permissions", () =>
|
it.effect("matches Windows paths against home-relative permissions", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const permissions = yield* loadHomePermissions("C:\\Users\\test")
|
const permissions = yield* loadHomePermissions("C:\\Users\\test")
|
||||||
expect(permissions).toContainEqual({
|
|
||||||
action: "external_directory",
|
|
||||||
resource: "C:\\Users\\test\\p\\**",
|
|
||||||
effect: "allow",
|
|
||||||
})
|
|
||||||
expect(
|
expect(
|
||||||
Permission.evaluate("external_directory", "C:\\Users\\test\\p\\opencode\\src\\*", permissions).effect,
|
Permission.evaluate("external_directory", "C:\\Users\\test\\p\\opencode\\src\\*", permissions).effect,
|
||||||
).toBe("allow")
|
).toBe("allow")
|
||||||
@@ -65,96 +59,6 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("applies remote permission defaults before explicit global and build rules", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const agents = yield* Agent.Service
|
|
||||||
const global = yield* Global.Service
|
|
||||||
yield* AgentPlugin.Plugin.effect(host({ agent: agentHost(agents) }))
|
|
||||||
|
|
||||||
const entries = [
|
|
||||||
new Document({
|
|
||||||
type: "document",
|
|
||||||
info: decode(
|
|
||||||
ConfigMigrateV1.migrate({
|
|
||||||
permission: {
|
|
||||||
bash: "ask",
|
|
||||||
edit: "ask",
|
|
||||||
webfetch: "ask",
|
|
||||||
read: {
|
|
||||||
"*": "allow",
|
|
||||||
"*.env": "deny",
|
|
||||||
"*.env.*": "deny",
|
|
||||||
"*.env.example": "allow",
|
|
||||||
"*.dev.vars": "deny",
|
|
||||||
"~/.local/share/opencode/mcp-auth.json": "deny",
|
|
||||||
"$HOME/.local/share/opencode/mcp-auth.json": "deny",
|
|
||||||
},
|
|
||||||
external_directory: {
|
|
||||||
"*": "ask",
|
|
||||||
"~/.local/share/opencode/*": "deny",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
new Document({
|
|
||||||
type: "document",
|
|
||||||
info: decode({
|
|
||||||
permissions: [{ action: "*", resource: "*", effect: "allow" }],
|
|
||||||
agents: {
|
|
||||||
build: {
|
|
||||||
permissions: [
|
|
||||||
{ action: "external_directory", resource: "*", effect: "allow" },
|
|
||||||
{
|
|
||||||
action: "external_directory",
|
|
||||||
resource: "~/.local/share/opencode/*",
|
|
||||||
effect: "deny",
|
|
||||||
},
|
|
||||||
{ action: "read", resource: "*.env", effect: "deny" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
|
|
||||||
yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
|
|
||||||
Effect.provide(Config.testLayer(entries)),
|
|
||||||
)
|
|
||||||
|
|
||||||
const build = yield* agents.get(Agent.defaultID)
|
|
||||||
if (!build) throw new Error("expected configured build agent")
|
|
||||||
const opencodeData = path.join(global.home, ".local", "share", "opencode", "*")
|
|
||||||
const mcpAuth = path.join(global.home, ".local", "share", "opencode", "mcp-auth.json")
|
|
||||||
expect(build.permissions).toEqual([
|
|
||||||
...defaultPermissions(global),
|
|
||||||
{ action: "question", resource: "*", effect: "allow" },
|
|
||||||
{ action: "shell", resource: "*", effect: "ask" },
|
|
||||||
{ action: "edit", resource: "*", effect: "ask" },
|
|
||||||
{ action: "webfetch", resource: "*", effect: "ask" },
|
|
||||||
{ action: "read", resource: "*", effect: "allow" },
|
|
||||||
{ action: "read", resource: "*.env", effect: "deny" },
|
|
||||||
{ action: "read", resource: "*.env.*", effect: "deny" },
|
|
||||||
{ action: "read", resource: "*.env.example", effect: "allow" },
|
|
||||||
{ action: "read", resource: "*.dev.vars", effect: "deny" },
|
|
||||||
{ action: "read", resource: mcpAuth, effect: "deny" },
|
|
||||||
{ action: "read", resource: mcpAuth, effect: "deny" },
|
|
||||||
{ action: "external_directory", resource: "*", effect: "ask" },
|
|
||||||
{ action: "external_directory", resource: opencodeData, effect: "deny" },
|
|
||||||
{ action: "*", resource: "*", effect: "allow" },
|
|
||||||
{ action: "external_directory", resource: "*", effect: "allow" },
|
|
||||||
{ action: "external_directory", resource: opencodeData, effect: "deny" },
|
|
||||||
{ action: "read", resource: "*.env", effect: "deny" },
|
|
||||||
])
|
|
||||||
expect(Permission.evaluate("shell", "bun test", build.permissions).effect).toBe("allow")
|
|
||||||
expect(Permission.evaluate("edit", "src/index.ts", build.permissions).effect).toBe("allow")
|
|
||||||
expect(Permission.evaluate("webfetch", "https://example.com", build.permissions).effect).toBe("allow")
|
|
||||||
expect(Permission.evaluate("read", ".env", build.permissions).effect).toBe("deny")
|
|
||||||
expect(Permission.evaluate("external_directory", opencodeData, build.permissions).effect).toBe("deny")
|
|
||||||
expect(Permission.evaluate("external_directory", "/outside/*", build.permissions).effect).toBe("allow")
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("applies all global permissions before agent-specific permissions", () =>
|
it.effect("applies all global permissions before agent-specific permissions", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const agents = yield* Agent.Service
|
const agents = yield* Agent.Service
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect, Fiber, Layer, Logger, PubSub, Schema, Stream } from "effect"
|
import { Effect, Fiber, Layer, PubSub, Schema, Stream } from "effect"
|
||||||
import { FastCheck } from "effect/testing"
|
import { FastCheck } from "effect/testing"
|
||||||
import { Config } from "@opencode-ai/core/config"
|
import { Config } from "@opencode-ai/core/config"
|
||||||
import { AgentsDirectory, Directory, Document, Event, Info } from "@opencode-ai/schema/config"
|
import { AgentsDirectory, Directory, Document, Event, Info } from "@opencode-ai/schema/config"
|
||||||
@@ -307,7 +307,7 @@ describe("Config", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("loads authenticated wellknown config before user configuration", () =>
|
it.live("loads authenticated wellknown config at highest priority", () =>
|
||||||
Effect.acquireUseRelease(
|
Effect.acquireUseRelease(
|
||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
(tmp) =>
|
(tmp) =>
|
||||||
@@ -370,13 +370,7 @@ describe("Config", () => {
|
|||||||
return yield* Effect.gen(function* () {
|
return yield* Effect.gen(function* () {
|
||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
const bus = yield* Bus.Service
|
const bus = yield* Bus.Service
|
||||||
const initial = yield* config.entries()
|
expect(Config.latest(yield* config.entries(), "shell")).toBe("secret")
|
||||||
expect(Config.latest(initial, "shell")).toBe("project")
|
|
||||||
expect(
|
|
||||||
initial.flatMap((entry) =>
|
|
||||||
entry.type === "document" && entry.info.shell ? [entry.info.shell] : [],
|
|
||||||
),
|
|
||||||
).toEqual(["secret", "global", "project"])
|
|
||||||
const updated = yield* bus
|
const updated = yield* bus
|
||||||
.subscribe(Event.Updated)
|
.subscribe(Event.Updated)
|
||||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||||
@@ -384,13 +378,7 @@ describe("Config", () => {
|
|||||||
key = "next"
|
key = "next"
|
||||||
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID })
|
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID })
|
||||||
expect(yield* Fiber.join(updated)).toHaveLength(1)
|
expect(yield* Fiber.join(updated)).toHaveLength(1)
|
||||||
const refreshed = yield* config.entries()
|
expect(Config.latest(yield* config.entries(), "shell")).toBe("next")
|
||||||
expect(Config.latest(refreshed, "shell")).toBe("project")
|
|
||||||
expect(
|
|
||||||
refreshed.flatMap((entry) =>
|
|
||||||
entry.type === "document" && entry.info.shell ? [entry.info.shell] : [],
|
|
||||||
),
|
|
||||||
).toEqual(["next", "global", "project"])
|
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.provide(testLayer(project, global, project, undefined, undefined, credentialNode, wellknownNode)),
|
Effect.provide(testLayer(project, global, project, undefined, undefined, credentialNode, wellknownNode)),
|
||||||
)
|
)
|
||||||
@@ -399,96 +387,27 @@ describe("Config", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("logs redacted source-aware diagnostics for every config source", () => {
|
it.effect("detects v1 configuration from any v1-only top-level key", () =>
|
||||||
const output: Array<Record<string, unknown>> = []
|
Effect.sync(() => {
|
||||||
const logger = Logger.map(Logger.formatStructured, (entry) => {
|
expect(ConfigMigrateV1.isV1({ snapshot: false })).toBe(true)
|
||||||
if (!Array.isArray(entry.message) || entry.message[0] !== "configuration normalization diagnostic") return
|
expect(ConfigMigrateV1.isV1({ snapshot: false, agents: {} })).toBe(true)
|
||||||
const details = entry.message[1]
|
expect(ConfigMigrateV1.isV1({ reference: {} })).toBe(true)
|
||||||
if (typeof details === "object" && details !== null) output.push(details as Record<string, unknown>)
|
expect(ConfigMigrateV1.isV1({ shell: "/bin/zsh", model: "anthropic/claude" })).toBe(false)
|
||||||
})
|
expect(ConfigMigrateV1.isV1({ references: {} })).toBe(false)
|
||||||
return Effect.acquireUseRelease(
|
|
||||||
Effect.promise(() => tmpdir()),
|
|
||||||
(tmp) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const global = path.join(tmp.path, "global")
|
|
||||||
const project = path.join(tmp.path, "project")
|
|
||||||
const malformed = path.join(tmp.path, "malformed.json")
|
|
||||||
yield* Effect.promise(async () => {
|
|
||||||
await fs.mkdir(global, { recursive: true })
|
|
||||||
await fs.mkdir(project, { recursive: true })
|
|
||||||
await fs.writeFile(path.join(global, "opencode.json"), "null")
|
|
||||||
await fs.writeFile(path.join(project, "opencode.json"), "")
|
|
||||||
await fs.writeFile(malformed, '{ "credential": "file-secret"')
|
|
||||||
})
|
|
||||||
const integrationID = Integration.ID.make("https://invalid.example.com")
|
|
||||||
const entry: WellKnown.Entry = {
|
|
||||||
origin: "https://invalid.example.com",
|
|
||||||
integrationID,
|
|
||||||
manifest: { auth: { command: ["login"], env: "TOKEN" } },
|
|
||||||
}
|
|
||||||
const credentialNode = makeGlobalNode({
|
|
||||||
service: Credential.Service,
|
|
||||||
layer: Layer.succeed(
|
|
||||||
Credential.Service,
|
|
||||||
Credential.Service.of({
|
|
||||||
all: () => Effect.die("unused Credential.all"),
|
|
||||||
list: () =>
|
|
||||||
Effect.succeed([
|
|
||||||
new Credential.Info({
|
|
||||||
id: Credential.ID.create(),
|
|
||||||
integrationID,
|
|
||||||
label: "default",
|
|
||||||
value: Credential.Key.make({ type: "key", key: "wellknown-secret" }),
|
|
||||||
}),
|
}),
|
||||||
]),
|
|
||||||
get: () => Effect.die("unused Credential.get"),
|
|
||||||
create: () => Effect.die("unused Credential.create"),
|
|
||||||
update: () => Effect.die("unused Credential.update"),
|
|
||||||
remove: () => Effect.die("unused Credential.remove"),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
deps: [],
|
|
||||||
})
|
|
||||||
const wellknownNode = makeGlobalNode({
|
|
||||||
service: WellKnown.Service,
|
|
||||||
layer: Layer.succeed(
|
|
||||||
WellKnown.Service,
|
|
||||||
WellKnown.Service.of({
|
|
||||||
entries: () => Effect.succeed([entry]),
|
|
||||||
snapshot: () => [entry],
|
|
||||||
refresh: () => Effect.succeed(false),
|
|
||||||
add: () => Effect.die("unused Wellknown.add"),
|
|
||||||
remove: () => Effect.die("unused Wellknown.remove"),
|
|
||||||
// Exercise the loader boundary against a malformed implementation response.
|
|
||||||
resolve: () => Effect.succeed([null as unknown as WellKnown.Config]),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
deps: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
yield* Config.Service.use((config) => config.entries()).pipe(
|
|
||||||
Effect.provide(
|
|
||||||
testLayer(project, global, project, undefined, undefined, credentialNode, wellknownNode, {
|
|
||||||
file: malformed,
|
|
||||||
content: "",
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(output.map((item) => `${item.source}:${item.path}:${item.kind}`).toSorted()).toEqual(
|
it.effect("detects a bare v1-shaped mcp block while leaving v2 mcp config alone", () =>
|
||||||
[
|
Effect.sync(() => {
|
||||||
`${path.join(global, "opencode.json")}:$:invalid`,
|
// V1 lists servers directly under `mcp`, so a file with only `$schema` + `mcp` still migrates.
|
||||||
`${path.join(project, "opencode.json")}:$:invalid`,
|
expect(ConfigMigrateV1.isV1({ mcp: { context7: { type: "local", command: ["npx"] } } })).toBe(true)
|
||||||
`${malformed}:$:invalid`,
|
expect(ConfigMigrateV1.isV1({ $schema: "x", mcp: { executor: { type: "remote", url: "https://x" } } })).toBe(true)
|
||||||
"https://invalid.example.com:$:invalid",
|
// Current config nests under `mcp.servers`, so it must not be misdetected and re-migrated.
|
||||||
"OPENCODE_CONFIG_CONTENT:$:invalid",
|
expect(ConfigMigrateV1.isV1({ mcp: { servers: { context7: { type: "local", command: ["npx"] } } } })).toBe(false)
|
||||||
].toSorted(),
|
expect(ConfigMigrateV1.isV1({ mcp: {} })).toBe(false)
|
||||||
)
|
expect(ConfigMigrateV1.isV1({ mcp: { timeout: { execution: 1000 } } })).toBe(false)
|
||||||
expect(JSON.stringify(output)).not.toContain("secret")
|
|
||||||
}),
|
}),
|
||||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
)
|
||||||
).pipe(Effect.provide(Logger.layer([logger])))
|
|
||||||
})
|
|
||||||
|
|
||||||
it.effect("migrates arbitrary v1 configuration into valid v2 configuration", () =>
|
it.effect("migrates arbitrary v1 configuration into valid v2 configuration", () =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
@@ -512,20 +431,6 @@ describe("Config", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("migrates the v1 small model to the title agent", () =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
expect(
|
|
||||||
ConfigMigrateV1.migrate({
|
|
||||||
small_model: "anthropic/claude-haiku-4-5",
|
|
||||||
agent: { title: { prompt: "Custom title prompt" } },
|
|
||||||
}).agents?.title,
|
|
||||||
).toEqual({
|
|
||||||
model: { providerID: "anthropic", model: "claude-haiku-4-5" },
|
|
||||||
system: "Custom title prompt",
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("migrates v1 provider lists to policies", () =>
|
it.effect("migrates v1 provider lists to policies", () =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
expect(
|
expect(
|
||||||
@@ -611,12 +516,12 @@ describe("Config", () => {
|
|||||||
})
|
})
|
||||||
expect(migrated.providers?.["azure-cognitive-services"]).toBeUndefined()
|
expect(migrated.providers?.["azure-cognitive-services"]).toBeUndefined()
|
||||||
expect(migrated.providers?.["google-vertex"]).toMatchObject({
|
expect(migrated.providers?.["google-vertex"]).toMatchObject({
|
||||||
|
package: undefined,
|
||||||
settings: { project: "test-project", location: "us-central1" },
|
settings: { project: "test-project", location: "us-central1" },
|
||||||
models: {
|
models: {
|
||||||
"claude-sonnet": { package: Provider.aisdk("@ai-sdk/google-vertex/anthropic") },
|
"claude-sonnet": { package: Provider.aisdk("@ai-sdk/google-vertex/anthropic") },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
expect(migrated.providers?.["google-vertex"]).not.toHaveProperty("package")
|
|
||||||
expect(migrated.providers?.["google-vertex-anthropic"]).toBeUndefined()
|
expect(migrated.providers?.["google-vertex-anthropic"]).toBeUndefined()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,509 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
import { Duration, Schema } from "effect"
|
|
||||||
import { FastCheck } from "effect/testing"
|
|
||||||
import { ConfigNormalize } from "@opencode-ai/core/config/normalize"
|
|
||||||
import { Info } from "@opencode-ai/schema/config"
|
|
||||||
|
|
||||||
const options = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
|
||||||
|
|
||||||
function normalized(input: unknown) {
|
|
||||||
const result = ConfigNormalize.normalize(input)
|
|
||||||
expect(result.type).toBe("normalized")
|
|
||||||
if (result.type !== "normalized") throw new Error("expected normalized config")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
function decoded(input: unknown) {
|
|
||||||
return Schema.decodeUnknownSync(Info, options)(normalized(input).encoded)
|
|
||||||
}
|
|
||||||
|
|
||||||
function withoutEmptyCompatibilityContainers(input: Record<string, unknown>) {
|
|
||||||
const result = structuredClone(input)
|
|
||||||
if (typeof result.mcp === "object" && result.mcp !== null && !Array.isArray(result.mcp)) {
|
|
||||||
const mcp = result.mcp as Record<string, unknown>
|
|
||||||
const originallyEmpty = !Object.keys(mcp).length
|
|
||||||
for (const key of ["servers", "timeout"]) {
|
|
||||||
if (
|
|
||||||
typeof mcp[key] === "object" &&
|
|
||||||
mcp[key] !== null &&
|
|
||||||
!Array.isArray(mcp[key]) &&
|
|
||||||
!Object.keys(mcp[key]).length
|
|
||||||
)
|
|
||||||
delete mcp[key]
|
|
||||||
}
|
|
||||||
if (!originallyEmpty && !Object.keys(mcp).length) delete result.mcp
|
|
||||||
}
|
|
||||||
if (typeof result.compaction === "object" && result.compaction !== null && !Array.isArray(result.compaction)) {
|
|
||||||
const compaction = result.compaction as Record<string, unknown>
|
|
||||||
const originallyEmpty = !Object.keys(compaction).length
|
|
||||||
if (
|
|
||||||
typeof compaction.keep === "object" &&
|
|
||||||
compaction.keep !== null &&
|
|
||||||
!Array.isArray(compaction.keep) &&
|
|
||||||
!Object.keys(compaction.keep).length
|
|
||||||
)
|
|
||||||
delete compaction.keep
|
|
||||||
if (!originallyEmpty && !Object.keys(compaction).length) delete result.compaction
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("ConfigNormalize", () => {
|
|
||||||
test("rejects every non-object root with one root diagnostic", () => {
|
|
||||||
for (const input of [null, [], "config", true, 1]) {
|
|
||||||
expect(ConfigNormalize.normalize(input)).toEqual({
|
|
||||||
type: "rejected",
|
|
||||||
diagnostics: [
|
|
||||||
{
|
|
||||||
kind: "invalid",
|
|
||||||
path: ["$"],
|
|
||||||
message: "rejected configuration because its root is not an object",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("keeps unrelated native fields when a legacy field is present", () => {
|
|
||||||
const result = decoded({ snapshot: false, agents: { reviewer: { system: "Use V2" } } })
|
|
||||||
expect(result.snapshots).toBe(false)
|
|
||||||
expect(result.agents?.reviewer?.system).toBe("Use V2")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("canonicalizes transformed native values through decode then encode", () => {
|
|
||||||
const result = normalized({ warming: { interval: "4 minutes", duration: "30 minutes" } })
|
|
||||||
expect(result.encoded.warming).toEqual({ interval: "240000 millis", duration: "1800000 millis" })
|
|
||||||
const info = Schema.decodeUnknownSync(Info)(result.encoded)
|
|
||||||
if (typeof info.warming === "boolean" || info.warming === undefined) throw new Error("expected warming info")
|
|
||||||
expect(Duration.toMillis(info.warming.interval ?? Duration.zero)).toBe(240_000)
|
|
||||||
expect(Duration.toMillis(info.warming.duration ?? Duration.zero)).toBe(1_800_000)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("preserves arbitrary JSON-round-tripped native configuration", () => {
|
|
||||||
FastCheck.assert(
|
|
||||||
FastCheck.property(Schema.toArbitrary(Info), (info) => {
|
|
||||||
const source = JSON.parse(JSON.stringify(Schema.encodeSync(Info)(info)))
|
|
||||||
const result = normalized(source)
|
|
||||||
expect(Schema.decodeUnknownSync(Info)(result.encoded)).toEqual(
|
|
||||||
Schema.decodeUnknownSync(Info)(withoutEmptyCompatibilityContainers(source)),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("merges named maps by entry and gives valid native entries precedence", () => {
|
|
||||||
const result = normalized({
|
|
||||||
reference: { legacy: { path: "../legacy" }, duplicate: { path: "../old" } },
|
|
||||||
references: { native: { path: "../native" }, duplicate: { path: "../new" } },
|
|
||||||
command: { legacy: { template: "legacy" }, duplicate: { template: "old" } },
|
|
||||||
commands: { native: { template: "native" }, duplicate: { template: "new" } },
|
|
||||||
})
|
|
||||||
expect(result.encoded.references).toEqual({
|
|
||||||
legacy: { path: "../legacy" },
|
|
||||||
native: { path: "../native" },
|
|
||||||
duplicate: { path: "../new" },
|
|
||||||
})
|
|
||||||
expect(result.encoded.commands).toEqual({
|
|
||||||
legacy: { template: "legacy" },
|
|
||||||
native: { template: "native" },
|
|
||||||
duplicate: { template: "new" },
|
|
||||||
})
|
|
||||||
expect(result.diagnostics.filter((item) => item.kind === "conflict").map((item) => item.path)).toEqual([
|
|
||||||
["references", "duplicate"],
|
|
||||||
["commands", "duplicate"],
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("does not report canonical-equal duplicates as conflicts", () => {
|
|
||||||
const result = normalized({
|
|
||||||
snapshot: false,
|
|
||||||
snapshots: false,
|
|
||||||
reference: { docs: { path: "../docs" } },
|
|
||||||
references: { docs: { path: "../docs" } },
|
|
||||||
agent: { reviewer: { prompt: "same" } },
|
|
||||||
agents: { reviewer: { system: "same" } },
|
|
||||||
provider: { custom: { name: "same" } },
|
|
||||||
providers: { custom: { name: "same" } },
|
|
||||||
compaction: { preserve_recent_tokens: 1000, keep: { tokens: 1000 } },
|
|
||||||
})
|
|
||||||
expect(result.diagnostics.filter((item) => item.kind === "conflict")).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("uses agent then mode then native agent precedence", () => {
|
|
||||||
const result = normalized({
|
|
||||||
agent: { reviewer: { prompt: "agent" }, agentOnly: { prompt: "agent-only" } },
|
|
||||||
mode: { reviewer: { prompt: "mode" }, modeOnly: { prompt: "mode-only" } },
|
|
||||||
agents: { reviewer: { system: "native" }, nativeOnly: { system: "native-only" } },
|
|
||||||
})
|
|
||||||
expect(result.encoded.agents).toEqual({
|
|
||||||
reviewer: { system: "native" },
|
|
||||||
agentOnly: { system: "agent-only" },
|
|
||||||
modeOnly: { system: "mode-only", mode: "primary" },
|
|
||||||
nativeOnly: { system: "native-only" },
|
|
||||||
})
|
|
||||||
expect(result.diagnostics.filter((item) => item.kind === "conflict").map((item) => item.path)).toEqual([
|
|
||||||
["agents", "reviewer"],
|
|
||||||
["agents", "reviewer"],
|
|
||||||
])
|
|
||||||
expect(() => Schema.decodeUnknownSync(Info)(result.encoded)).not.toThrow()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("migrates the legacy small model to the title agent", () => {
|
|
||||||
const result = normalized({
|
|
||||||
small_model: "anthropic/claude-haiku-4-5",
|
|
||||||
agent: { title: { prompt: "Custom title prompt" } },
|
|
||||||
})
|
|
||||||
expect(result.encoded.agents).toEqual({
|
|
||||||
title: {
|
|
||||||
model: { providerID: "anthropic", model: "claude-haiku-4-5" },
|
|
||||||
system: "Custom title prompt",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
expect(result.diagnostics).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("omits an invalid legacy small model without exposing its value", () => {
|
|
||||||
const secret = "do-not-log-this-value"
|
|
||||||
const result = normalized({ small_model: secret })
|
|
||||||
expect(result.encoded.agents).toBeUndefined()
|
|
||||||
expect(result.diagnostics.map((item) => [item.kind, item.path])).toEqual([["unsupported", ["small_model"]]])
|
|
||||||
expect(JSON.stringify(result.diagnostics)).not.toContain(secret)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("recovers malformed named entries and retains a valid legacy collision", () => {
|
|
||||||
const result = normalized({
|
|
||||||
command: { fallback: { template: "legacy" } },
|
|
||||||
commands: {
|
|
||||||
fallback: { template: 1 },
|
|
||||||
valid: { template: "native" },
|
|
||||||
invalid: { template: false },
|
|
||||||
},
|
|
||||||
providers: {
|
|
||||||
valid: { name: "Valid" },
|
|
||||||
invalid: { env: [1] },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
expect(result.encoded.commands).toEqual({ fallback: { template: "legacy" }, valid: { template: "native" } })
|
|
||||||
expect(result.encoded.providers).toEqual({ valid: { name: "Valid" } })
|
|
||||||
expect(result.diagnostics.filter((item) => item.kind === "invalid").map((item) => item.path)).toEqual([
|
|
||||||
["commands", "fallback"],
|
|
||||||
["commands", "invalid"],
|
|
||||||
["providers", "invalid"],
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("uses a valid retired provider alias when the canonical legacy entry is malformed", () => {
|
|
||||||
const result = normalized({
|
|
||||||
provider: {
|
|
||||||
"azure-cognitive-services": { models: { deployment: {} } },
|
|
||||||
azure: { env: [1] },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
expect(result.encoded.providers).toHaveProperty("azure.models.deployment")
|
|
||||||
expect(result.diagnostics.filter((item) => item.kind === "invalid").map((item) => item.path)).toContainEqual([
|
|
||||||
"provider",
|
|
||||||
"azure",
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("preserves permission source order and appends native rules", () => {
|
|
||||||
expect(
|
|
||||||
normalized({
|
|
||||||
tools: { bash: true, write: false },
|
|
||||||
permission: { read: "allow", custom: { first: "deny", second: "ask" }, task: "allow" },
|
|
||||||
permissions: [{ action: "native", resource: "*", effect: "deny" }],
|
|
||||||
}).encoded.permissions,
|
|
||||||
).toEqual([
|
|
||||||
{ action: "shell", resource: "*", effect: "allow" },
|
|
||||||
{ action: "edit", resource: "*", effect: "deny" },
|
|
||||||
{ action: "read", resource: "*", effect: "allow" },
|
|
||||||
{ action: "custom", resource: "first", effect: "deny" },
|
|
||||||
{ action: "custom", resource: "second", effect: "ask" },
|
|
||||||
{ action: "subagent", resource: "*", effect: "allow" },
|
|
||||||
{ action: "native", resource: "*", effect: "deny" },
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("redacts permission resource keys from invalid diagnostics", () => {
|
|
||||||
const result = normalized({
|
|
||||||
permission: { bash: { "curl -H Authorization:Bearer TOPSECRET *": "bogus" } },
|
|
||||||
})
|
|
||||||
expect(result.diagnostics).toEqual([
|
|
||||||
{
|
|
||||||
kind: "invalid",
|
|
||||||
path: ["permission", "bash", "0"],
|
|
||||||
message: "skipped malformed recognized value",
|
|
||||||
},
|
|
||||||
])
|
|
||||||
expect(JSON.stringify(result.diagnostics)).not.toContain("TOPSECRET")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("recovers list items for skills, plugins, instructions, and permissions", () => {
|
|
||||||
const result = normalized({
|
|
||||||
skills: { paths: ["./skills", 1], urls: [false, "https://example.com/skills"] },
|
|
||||||
plugin: ["legacy", ["tuple", {}], [1, {}]],
|
|
||||||
plugins: ["native", { package: "object" }, { package: 1 }],
|
|
||||||
instructions: ["one", 2, "three"],
|
|
||||||
permissions: [
|
|
||||||
{ action: "read", resource: "*", effect: "allow" },
|
|
||||||
{ action: "read", resource: "*", effect: "invalid" },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
expect(result.encoded.skills).toEqual(["./skills", "https://example.com/skills"])
|
|
||||||
expect(result.encoded.plugins).toEqual([
|
|
||||||
"legacy",
|
|
||||||
{ package: "tuple", options: {} },
|
|
||||||
"native",
|
|
||||||
{ package: "object" },
|
|
||||||
])
|
|
||||||
expect(result.encoded.instructions).toEqual(["one", "three"])
|
|
||||||
expect(result.encoded.permissions).toEqual([{ action: "read", resource: "*", effect: "allow" }])
|
|
||||||
expect(result.diagnostics.filter((item) => item.kind === "invalid")).toHaveLength(6)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("omits malformed collection roots instead of synthesizing empty values", () => {
|
|
||||||
const result = normalized({
|
|
||||||
commands: [],
|
|
||||||
providers: "invalid",
|
|
||||||
references: false,
|
|
||||||
agents: 1,
|
|
||||||
plugins: {},
|
|
||||||
permissions: {},
|
|
||||||
instructions: {},
|
|
||||||
})
|
|
||||||
expect(result.encoded).toEqual({})
|
|
||||||
expect(result.diagnostics.filter((item) => item.kind === "invalid").map((item) => item.path)).toEqual([
|
|
||||||
["references"],
|
|
||||||
["commands"],
|
|
||||||
["agents"],
|
|
||||||
["providers"],
|
|
||||||
["permissions"],
|
|
||||||
["plugins"],
|
|
||||||
["instructions"],
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("omits all-invalid formatter and LSP maps while preserving explicit empty maps", () => {
|
|
||||||
const invalid = normalized({
|
|
||||||
formatter: { prettier: { command: [1] } },
|
|
||||||
lsp: { typescript: { command: [1] } },
|
|
||||||
})
|
|
||||||
expect(invalid.encoded).not.toHaveProperty("formatter")
|
|
||||||
expect(invalid.encoded).not.toHaveProperty("lsp")
|
|
||||||
expect(invalid.diagnostics.filter((item) => item.kind === "invalid").map((item) => item.path)).toEqual([
|
|
||||||
["formatter", "prettier"],
|
|
||||||
["lsp", "typescript"],
|
|
||||||
])
|
|
||||||
|
|
||||||
expect(normalized({ formatter: {}, lsp: {} }).encoded).toMatchObject({ formatter: {}, lsp: {} })
|
|
||||||
})
|
|
||||||
|
|
||||||
test("combines legacy and native MCP servers and merges timeout leaves", () => {
|
|
||||||
const result = normalized({
|
|
||||||
experimental: { mcp_timeout: 5000 },
|
|
||||||
mcp: {
|
|
||||||
legacy: { type: "local", command: ["legacy"] },
|
|
||||||
duplicate: { type: "remote", url: "https://legacy.example.com" },
|
|
||||||
servers: {
|
|
||||||
native: { type: "local", command: ["native"] },
|
|
||||||
duplicate: { type: "remote", url: "https://native.example.com" },
|
|
||||||
invalid: { type: "local", command: [1] },
|
|
||||||
},
|
|
||||||
timeout: { startup: 1000, catalog: 6000 },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
expect(result.encoded.mcp).toEqual({
|
|
||||||
timeout: { catalog: 6000, execution: 5000, startup: 1000 },
|
|
||||||
servers: {
|
|
||||||
legacy: { type: "local", command: ["legacy"], disabled: undefined, timeout: undefined },
|
|
||||||
duplicate: { type: "remote", url: "https://native.example.com" },
|
|
||||||
native: { type: "local", command: ["native"] },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
expect(
|
|
||||||
result.diagnostics.some((item) => item.kind === "conflict" && item.path.join(".") === "mcp.servers.duplicate"),
|
|
||||||
).toBe(true)
|
|
||||||
expect(
|
|
||||||
result.diagnostics.some((item) => item.kind === "conflict" && item.path.join(".") === "mcp.timeout.catalog"),
|
|
||||||
).toBe(true)
|
|
||||||
expect(
|
|
||||||
result.diagnostics.some((item) => item.kind === "invalid" && item.path.join(".") === "mcp.servers.invalid"),
|
|
||||||
).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("uses raw MCP discriminators for reserved server names", () => {
|
|
||||||
const result = normalized({
|
|
||||||
mcp: {
|
|
||||||
servers: { type: "local", command: ["reserved-servers"] },
|
|
||||||
timeout: { type: "remote", url: "https://reserved.example.com" },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
expect((result.encoded.mcp as { servers: Record<string, unknown> }).servers).toEqual({
|
|
||||||
servers: { type: "local", command: ["reserved-servers"], disabled: undefined, timeout: undefined },
|
|
||||||
timeout: { type: "remote", url: "https://reserved.example.com", disabled: undefined, timeout: undefined },
|
|
||||||
})
|
|
||||||
|
|
||||||
const enabledOnly = normalized({ mcp: { servers: { enabled: true }, timeout: { enabled: false } } })
|
|
||||||
expect(enabledOnly.encoded.mcp).toBeUndefined()
|
|
||||||
expect(enabledOnly.diagnostics.map((item) => [item.kind, item.path])).toEqual([
|
|
||||||
["unsupported", ["mcp", "servers"]],
|
|
||||||
["unsupported", ["mcp", "timeout"]],
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("merges bounded compaction leaves and omits unsupported leaves", () => {
|
|
||||||
const result = normalized({
|
|
||||||
compaction: {
|
|
||||||
auto: false,
|
|
||||||
preserve_recent_tokens: 1000,
|
|
||||||
keep: { tokens: 2000 },
|
|
||||||
reserved: 3000,
|
|
||||||
buffer: 4000,
|
|
||||||
tail_turns: 2,
|
|
||||||
prune: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
expect(result.encoded.compaction).toEqual({ auto: false, keep: { tokens: 2000 }, buffer: 4000 })
|
|
||||||
expect(result.diagnostics.map((item) => [item.kind, item.path])).toEqual([
|
|
||||||
["unsupported", ["compaction", "tail_turns"]],
|
|
||||||
["unsupported", ["compaction", "prune"]],
|
|
||||||
["conflict", ["compaction", "keep", "tokens"]],
|
|
||||||
["conflict", ["compaction", "buffer"]],
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("distinguishes empty, mixed, and wholly malformed enabled provider lists", () => {
|
|
||||||
expect(normalized({ enabled_providers: [] }).encoded.experimental).toEqual({
|
|
||||||
policies: [{ action: "provider.use", resource: "*", effect: "deny" }],
|
|
||||||
})
|
|
||||||
expect(normalized({ enabled_providers: [1, "anthropic", false] }).encoded.experimental).toEqual({
|
|
||||||
policies: [
|
|
||||||
{ action: "provider.use", resource: "*", effect: "deny" },
|
|
||||||
{ action: "provider.use", resource: "anthropic", effect: "allow" },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
expect(normalized({ enabled_providers: [1, false] }).encoded.experimental).toBeUndefined()
|
|
||||||
expect(normalized({ enabled_providers: "anthropic" }).encoded.experimental).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("appends native policies after migrated provider policies", () => {
|
|
||||||
expect(
|
|
||||||
normalized({
|
|
||||||
enabled_providers: ["anthropic"],
|
|
||||||
disabled_providers: ["openai"],
|
|
||||||
experimental: {
|
|
||||||
subagent_depth: 0,
|
|
||||||
policies: [{ action: "provider.use", resource: "custom", effect: "allow" }],
|
|
||||||
},
|
|
||||||
}).encoded.experimental,
|
|
||||||
).toEqual({
|
|
||||||
subagent_depth: 0,
|
|
||||||
policies: [
|
|
||||||
{ action: "provider.use", resource: "*", effect: "deny" },
|
|
||||||
{ action: "provider.use", resource: "anthropic", effect: "allow" },
|
|
||||||
{ action: "provider.use", resource: "openai", effect: "deny" },
|
|
||||||
{ action: "provider.use", resource: "custom", effect: "allow" },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("reports unsupported legacy settings without including their values", () => {
|
|
||||||
const secret = "do-not-log-this-value"
|
|
||||||
const result = normalized({
|
|
||||||
logLevel: "DEBUG",
|
|
||||||
agent: { reviewer: { name: secret, prompt: "review" } },
|
|
||||||
provider: {
|
|
||||||
custom: {
|
|
||||||
id: secret,
|
|
||||||
whitelist: ["model"],
|
|
||||||
models: {
|
|
||||||
model: {
|
|
||||||
release_date: secret,
|
|
||||||
status: "active",
|
|
||||||
interleaved: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
experimental: { openTelemetry: true },
|
|
||||||
})
|
|
||||||
expect(result.diagnostics.filter((item) => item.kind === "unsupported").map((item) => item.path)).toEqual([
|
|
||||||
["logLevel"],
|
|
||||||
["agent", "reviewer", "name"],
|
|
||||||
["provider", "custom", "id"],
|
|
||||||
["provider", "custom", "whitelist"],
|
|
||||||
["provider", "custom", "models", "model", "release_date"],
|
|
||||||
["provider", "custom", "models", "model", "status"],
|
|
||||||
["provider", "custom", "models", "model", "interleaved"],
|
|
||||||
["experimental", "openTelemetry"],
|
|
||||||
])
|
|
||||||
expect(JSON.stringify(result.diagnostics)).not.toContain(secret)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("diagnoses unsupported legacy model selections without dropping their entries", () => {
|
|
||||||
const result = normalized({
|
|
||||||
command: {
|
|
||||||
invalidModel: { template: "one", model: "invalid" },
|
|
||||||
invalidVariant: { template: "two", model: "anthropic/model", variant: "bad#variant" },
|
|
||||||
missingModel: { template: "three", variant: "high" },
|
|
||||||
},
|
|
||||||
agent: { invalid: { prompt: "agent", model: "invalid", variant: "" } },
|
|
||||||
})
|
|
||||||
expect(Object.keys(result.encoded.commands as Record<string, unknown>)).toEqual([
|
|
||||||
"invalidModel",
|
|
||||||
"invalidVariant",
|
|
||||||
"missingModel",
|
|
||||||
])
|
|
||||||
expect(Object.keys(result.encoded.agents as Record<string, unknown>)).toEqual(["invalid"])
|
|
||||||
expect(result.diagnostics.filter((item) => item.kind === "unsupported").map((item) => item.path)).toEqual([
|
|
||||||
["command", "invalidModel", "model"],
|
|
||||||
["command", "invalidVariant", "variant"],
|
|
||||||
["command", "missingModel", "variant"],
|
|
||||||
["agent", "invalid", "model"],
|
|
||||||
["agent", "invalid", "variant"],
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("invalid legacy provider overlays skip only that provider", () => {
|
|
||||||
const result = normalized({
|
|
||||||
provider: {
|
|
||||||
headers: { options: { headers: { valid: "yes", invalid: 1 } } },
|
|
||||||
body: { options: { body: "not-an-object" } },
|
|
||||||
valid: { options: { headers: { valid: "yes" }, body: { trace: true } } },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
expect(result.encoded.providers).toEqual({
|
|
||||||
valid: { settings: {}, headers: { valid: "yes" }, body: { trace: true } },
|
|
||||||
})
|
|
||||||
expect(result.diagnostics.filter((item) => item.kind === "invalid").map((item) => item.path)).toEqual([
|
|
||||||
["provider", "headers", "options", "headers"],
|
|
||||||
["provider", "body", "options", "body"],
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("preserves explicit false, zero, empty list, and empty map presence", () => {
|
|
||||||
const result = normalized({
|
|
||||||
snapshot: false,
|
|
||||||
autoshare: false,
|
|
||||||
references: {},
|
|
||||||
commands: {},
|
|
||||||
agents: {},
|
|
||||||
providers: {},
|
|
||||||
plugins: [],
|
|
||||||
instructions: [],
|
|
||||||
experimental: { subagent_depth: 0 },
|
|
||||||
})
|
|
||||||
expect(result.encoded).toMatchObject({
|
|
||||||
snapshots: false,
|
|
||||||
references: {},
|
|
||||||
commands: {},
|
|
||||||
agents: {},
|
|
||||||
providers: {},
|
|
||||||
plugins: [],
|
|
||||||
instructions: [],
|
|
||||||
experimental: { subagent_depth: 0 },
|
|
||||||
})
|
|
||||||
expect(result.encoded.share).toBeUndefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -237,11 +237,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
|||||||
models: {
|
models: {
|
||||||
chat: {
|
chat: {
|
||||||
name: "First",
|
name: "First",
|
||||||
compatibility: {
|
compatibility: { reasoningField: "vendor_reasoning" },
|
||||||
reasoningField: "vendor_reasoning",
|
|
||||||
maxTokensField: "max_completion_tokens",
|
|
||||||
requireFinishReason: false,
|
|
||||||
},
|
|
||||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||||
disabled: true,
|
disabled: true,
|
||||||
limit: { context: 100, output: 50 },
|
limit: { context: 100, output: 50 },
|
||||||
@@ -322,11 +318,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
|||||||
expect(model.id).toBe(modelID)
|
expect(model.id).toBe(modelID)
|
||||||
expect(model.modelID).toBe(Model.ID.make("api-chat"))
|
expect(model.modelID).toBe(Model.ID.make("api-chat"))
|
||||||
expect(model.name).toBe("Last")
|
expect(model.name).toBe("Last")
|
||||||
expect(model.compatibility).toEqual({
|
expect(model.compatibility).toEqual({ reasoningField: "vendor_reasoning" })
|
||||||
reasoningField: "vendor_reasoning",
|
|
||||||
maxTokensField: "max_completion_tokens",
|
|
||||||
requireFinishReason: false,
|
|
||||||
})
|
|
||||||
expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
|
expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
|
||||||
expect(model.enabled).toBe(false)
|
expect(model.enabled).toBe(false)
|
||||||
expect(model.limit).toEqual({ context: 100, output: 75 })
|
expect(model.limit).toEqual({ context: 100, output: 75 })
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ describe("FileMutation", () => {
|
|||||||
|
|
||||||
expect(yield* (yield* FileMutation.Service).write({ target, content: "after" })).toEqual({
|
expect(yield* (yield* FileMutation.Service).write({ target, content: "after" })).toEqual({
|
||||||
operation: "write",
|
operation: "write",
|
||||||
target: target.absolute,
|
target: target.canonical,
|
||||||
resource: "hello.txt",
|
resource: "hello.txt",
|
||||||
existed: true,
|
existed: true,
|
||||||
})
|
})
|
||||||
@@ -62,11 +62,11 @@ describe("FileMutation", () => {
|
|||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
operation: "write",
|
operation: "write",
|
||||||
target: target.absolute,
|
target: target.canonical,
|
||||||
resource: "src/nested/hello.txt",
|
resource: "src/nested/hello.txt",
|
||||||
existed: false,
|
existed: false,
|
||||||
})
|
})
|
||||||
expect(yield* Effect.promise(() => fs.readFile(target.absolute, "utf8"))).toBe("hello")
|
expect(yield* Effect.promise(() => fs.readFile(result.target, "utf8"))).toBe("hello")
|
||||||
}).pipe(provide(directory)),
|
}).pipe(provide(directory)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -84,7 +84,7 @@ describe("FileMutation", () => {
|
|||||||
yield* files.writeTextPreservingBom({ target: created, content: "\uFEFF\uFEFF\uFEFFcreated" })
|
yield* files.writeTextPreservingBom({ target: created, content: "\uFEFF\uFEFF\uFEFFcreated" })
|
||||||
|
|
||||||
expect(yield* Effect.promise(() => fs.readFile(preservedPath, "utf8"))).toBe("\uFEFFafter")
|
expect(yield* Effect.promise(() => fs.readFile(preservedPath, "utf8"))).toBe("\uFEFFafter")
|
||||||
expect(yield* Effect.promise(() => fs.readFile(created.absolute, "utf8"))).toBe("\uFEFFcreated")
|
expect(yield* Effect.promise(() => fs.readFile(created.canonical, "utf8"))).toBe("\uFEFFcreated")
|
||||||
}).pipe(provide(directory)),
|
}).pipe(provide(directory)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -99,7 +99,7 @@ describe("FileMutation", () => {
|
|||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
operation: "write",
|
operation: "write",
|
||||||
target: target.absolute,
|
target: target.canonical,
|
||||||
resource: target.resource,
|
resource: target.resource,
|
||||||
existed: false,
|
existed: false,
|
||||||
})
|
})
|
||||||
@@ -109,7 +109,7 @@ describe("FileMutation", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("serializes concurrent writes to the same absolute target", () =>
|
it.live("serializes concurrent writes to the same canonical target", () =>
|
||||||
withTmp((directory) =>
|
withTmp((directory) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const targetPath = path.join(directory, "shared.txt")
|
const targetPath = path.join(directory, "shared.txt")
|
||||||
@@ -152,7 +152,7 @@ describe("FileMutation", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("allows distinct absolute targets to proceed independently", () =>
|
it.live("allows distinct canonical targets to proceed independently", () =>
|
||||||
withTmp((directory) =>
|
withTmp((directory) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const firstStarted = yield* Deferred.make<void>()
|
const firstStarted = yield* Deferred.make<void>()
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const input = {
|
|||||||
id: formID,
|
id: formID,
|
||||||
sessionID: SessionSchema.ID.make("ses_test"),
|
sessionID: SessionSchema.ID.make("ses_test"),
|
||||||
title: "Test form",
|
title: "Test form",
|
||||||
|
coalesce: "test-form",
|
||||||
fields: [{ key: "name", type: "string", required: true }],
|
fields: [{ key: "name", type: "string", required: true }],
|
||||||
} satisfies Form.CreateInput
|
} satisfies Form.CreateInput
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ describe("Form", () => {
|
|||||||
yield* Effect.addFinalizer(() => unsubscribe)
|
yield* Effect.addFinalizer(() => unsubscribe)
|
||||||
const fiber = yield* service.ask(input).pipe(Effect.forkScoped)
|
const fiber = yield* service.ask(input).pipe(Effect.forkScoped)
|
||||||
const form = yield* Deferred.await(created)
|
const form = yield* Deferred.await(created)
|
||||||
|
expect(form.coalesce).toBe("test-form")
|
||||||
|
|
||||||
yield* service.cancel(form.id)
|
yield* service.cancel(form.id)
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ describe("LocationMutation", () => {
|
|||||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "hello.txt" })
|
const target = yield* (yield* LocationMutation.Service).resolve({ path: "hello.txt" })
|
||||||
|
|
||||||
expect(target).toMatchObject({
|
expect(target).toMatchObject({
|
||||||
absolute: targetPath,
|
canonical: yield* Effect.promise(() => fs.realpath(targetPath)),
|
||||||
resource: "hello.txt",
|
resource: "hello.txt",
|
||||||
})
|
})
|
||||||
expect(target.externalDirectory).toBeUndefined()
|
expect(target.externalDirectory).toBeUndefined()
|
||||||
@@ -50,8 +50,10 @@ describe("LocationMutation", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
|
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
|
||||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("src", "new.txt") })
|
const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("src", "new.txt") })
|
||||||
|
const root = yield* Effect.promise(() => fs.realpath(directory))
|
||||||
|
|
||||||
expect(target).toMatchObject({
|
expect(target).toMatchObject({
|
||||||
absolute: path.join(directory, "src", "new.txt"),
|
canonical: path.join(root, "src", "new.txt"),
|
||||||
resource: "src/new.txt",
|
resource: "src/new.txt",
|
||||||
})
|
})
|
||||||
}).pipe(provide(directory)),
|
}).pipe(provide(directory)),
|
||||||
@@ -62,9 +64,9 @@ describe("LocationMutation", () => {
|
|||||||
withTmp((directory) =>
|
withTmp((directory) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "../outside.txt" })
|
const target = yield* (yield* LocationMutation.Service).resolve({ path: "../outside.txt" })
|
||||||
const root = path.dirname(directory)
|
const root = yield* Effect.promise(() => fs.realpath(path.dirname(directory)))
|
||||||
expect(target).toMatchObject({
|
expect(target).toMatchObject({
|
||||||
absolute: path.join(root, "outside.txt"),
|
canonical: path.join(root, "outside.txt"),
|
||||||
resource: path.join(root, "outside.txt").replaceAll("\\", "/"),
|
resource: path.join(root, "outside.txt").replaceAll("\\", "/"),
|
||||||
})
|
})
|
||||||
expect(target.externalDirectory).toMatchObject({
|
expect(target.externalDirectory).toMatchObject({
|
||||||
@@ -75,7 +77,7 @@ describe("LocationMutation", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("resolves a prospective target below an external symlink lexically", () =>
|
it.live("authorizes a prospective target below an external symlink by its in-location path", () =>
|
||||||
withTmp((directory) => {
|
withTmp((directory) => {
|
||||||
const outside = `${directory}-outside`
|
const outside = `${directory}-outside`
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
@@ -86,7 +88,7 @@ describe("LocationMutation", () => {
|
|||||||
})
|
})
|
||||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("escape", "new.txt") })
|
const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("escape", "new.txt") })
|
||||||
expect(target).toMatchObject({
|
expect(target).toMatchObject({
|
||||||
absolute: path.join(directory, "escape", "new.txt"),
|
canonical: path.join(yield* Effect.promise(() => fs.realpath(outside)), "new.txt"),
|
||||||
resource: "escape/new.txt",
|
resource: "escape/new.txt",
|
||||||
})
|
})
|
||||||
expect(target.externalDirectory).toBeUndefined()
|
expect(target.externalDirectory).toBeUndefined()
|
||||||
@@ -105,7 +107,7 @@ describe("LocationMutation", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
expect(yield* (yield* LocationMutation.Service).resolve({ path: "linked/new.txt" })).toMatchObject({
|
expect(yield* (yield* LocationMutation.Service).resolve({ path: "linked/new.txt" })).toMatchObject({
|
||||||
absolute: path.join(directory, "linked", "new.txt"),
|
canonical: path.join(yield* Effect.promise(() => fs.realpath(directory)), "actual", "new.txt"),
|
||||||
resource: "linked/new.txt",
|
resource: "linked/new.txt",
|
||||||
})
|
})
|
||||||
}).pipe(provide(directory)),
|
}).pipe(provide(directory)),
|
||||||
@@ -118,7 +120,7 @@ describe("LocationMutation", () => {
|
|||||||
const targetPath = path.join(directory, "new.txt")
|
const targetPath = path.join(directory, "new.txt")
|
||||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||||
expect(target).toMatchObject({
|
expect(target).toMatchObject({
|
||||||
absolute: targetPath,
|
canonical: path.join(yield* Effect.promise(() => fs.realpath(directory)), "new.txt"),
|
||||||
resource: "new.txt",
|
resource: "new.txt",
|
||||||
})
|
})
|
||||||
expect(target.externalDirectory).toBeUndefined()
|
expect(target.externalDirectory).toBeUndefined()
|
||||||
@@ -132,9 +134,9 @@ describe("LocationMutation", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const targetPath = path.join(outside, "new.txt")
|
const targetPath = path.join(outside, "new.txt")
|
||||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||||
const root = outside
|
const root = yield* Effect.promise(() => fs.realpath(outside))
|
||||||
expect(target).toMatchObject({
|
expect(target).toMatchObject({
|
||||||
absolute: path.join(root, "new.txt"),
|
canonical: path.join(root, "new.txt"),
|
||||||
resource: path.join(root, "new.txt").replaceAll("\\", "/"),
|
resource: path.join(root, "new.txt").replaceAll("\\", "/"),
|
||||||
})
|
})
|
||||||
expect(target.externalDirectory).toMatchObject({
|
expect(target.externalDirectory).toMatchObject({
|
||||||
@@ -153,23 +155,24 @@ describe("LocationMutation", () => {
|
|||||||
const targetPath = path.join(outside, "existing.txt")
|
const targetPath = path.join(outside, "existing.txt")
|
||||||
yield* Effect.promise(() => fs.writeFile(targetPath, "existing"))
|
yield* Effect.promise(() => fs.writeFile(targetPath, "existing"))
|
||||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||||
expect(target).toMatchObject({ absolute: targetPath })
|
const root = yield* Effect.promise(() => fs.realpath(outside))
|
||||||
expect(target.externalDirectory?.directory).toBe(outside)
|
expect(target).toMatchObject({ canonical: path.join(root, "existing.txt") })
|
||||||
|
expect(target.externalDirectory?.directory).toBe(root)
|
||||||
}).pipe(provide(directory)),
|
}).pipe(provide(directory)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("authorizes prospective external descendants at their lexical parent", () =>
|
it.live("anchors prospective external descendants at their stable existing directory", () =>
|
||||||
withTmp((directory) =>
|
withTmp((directory) =>
|
||||||
withTmp((outside) =>
|
withTmp((outside) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const targetPath = path.join(outside, "new", "nested", "file.txt")
|
const targetPath = path.join(outside, "new", "nested", "file.txt")
|
||||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||||
const parent = path.dirname(targetPath)
|
const root = yield* Effect.promise(() => fs.realpath(outside))
|
||||||
expect(target.externalDirectory).toMatchObject({
|
expect(target.externalDirectory).toMatchObject({
|
||||||
directory: parent,
|
directory: root,
|
||||||
resource: path.join(parent, "*").replaceAll("\\", "/"),
|
resource: path.join(root, "*").replaceAll("\\", "/"),
|
||||||
})
|
})
|
||||||
}).pipe(provide(directory)),
|
}).pipe(provide(directory)),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -151,9 +151,6 @@ describe("ModelResolver", () => {
|
|||||||
http: { body: { custom_extension: { enabled: true } } },
|
http: { body: { custom_extension: { enabled: true } } },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
|
|
||||||
expect(prepared.body.max_output_tokens).toBeUndefined()
|
|
||||||
expect(JSON.stringify(prepared.body)).not.toContain("max_output_tokens")
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -194,11 +191,7 @@ describe("ModelResolver", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||||
model(Provider.aisdk("@ai-sdk/openai-compatible"), {
|
model(Provider.aisdk("@ai-sdk/openai-compatible"), {
|
||||||
compatibility: {
|
compatibility: { reasoningField: "vendor_reasoning" },
|
||||||
reasoningField: "vendor_reasoning",
|
|
||||||
maxTokensField: "max_completion_tokens",
|
|
||||||
requireFinishReason: false,
|
|
||||||
},
|
|
||||||
settings: {
|
settings: {
|
||||||
apiKey: "settings-secret",
|
apiKey: "settings-secret",
|
||||||
baseURL: "https://compatible.example/v1",
|
baseURL: "https://compatible.example/v1",
|
||||||
@@ -208,8 +201,7 @@ describe("ModelResolver", () => {
|
|||||||
body: {},
|
body: {},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const request = LLM.request({ model: resolved, prompt: "Hello", generation: { maxTokens: 10 } })
|
const request = LLM.request({ model: resolved, prompt: "Hello" })
|
||||||
const prepared = yield* compileRequest(request)
|
|
||||||
const headers = yield* resolved.route.auth.apply({
|
const headers = yield* resolved.route.auth.apply({
|
||||||
request,
|
request,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -221,10 +213,6 @@ describe("ModelResolver", () => {
|
|||||||
expect(headers.authorization).toBe("Bearer settings-secret")
|
expect(headers.authorization).toBe("Bearer settings-secret")
|
||||||
expect(resolved.route.id).toBe("openai-compatible-chat")
|
expect(resolved.route.id).toBe("openai-compatible-chat")
|
||||||
expect(resolved.compatibility?.reasoningField).toBe("vendor_reasoning")
|
expect(resolved.compatibility?.reasoningField).toBe("vendor_reasoning")
|
||||||
expect(resolved.compatibility?.maxTokensField).toBe("max_completion_tokens")
|
|
||||||
expect(resolved.compatibility?.requireFinishReason).toBe(false)
|
|
||||||
expect(prepared.body).toMatchObject({ max_completion_tokens: 10 })
|
|
||||||
expect(prepared.body).not.toHaveProperty("max_tokens")
|
|
||||||
expect(resolved.route.endpoint.baseURL).toBe("https://compatible.example/v1")
|
expect(resolved.route.endpoint.baseURL).toBe("https://compatible.example/v1")
|
||||||
expect(resolved.route.defaults.http?.body).toEqual({})
|
expect(resolved.route.defaults.http?.body).toEqual({})
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Message, SystemPart } from "@opencode-ai/ai"
|
import { Message, SystemPart } from "@opencode-ai/ai"
|
||||||
import { DateTime, Effect, Schema } from "effect"
|
import { DateTime, Deferred, Effect, Fiber, Schema } from "effect"
|
||||||
import { Agent } from "@opencode-ai/core/agent"
|
import { Agent } from "@opencode-ai/core/agent"
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
import { Model } from "@opencode-ai/core/model"
|
import { Model } from "@opencode-ai/core/model"
|
||||||
@@ -15,7 +15,7 @@ import { SessionPending } from "@opencode-ai/core/session/pending"
|
|||||||
import { Tool } from "@opencode-ai/core/tool"
|
import { Tool } from "@opencode-ai/core/tool"
|
||||||
import { Provider } from "@opencode-ai/core/provider"
|
import { Provider } from "@opencode-ai/core/provider"
|
||||||
import { define } from "@opencode-ai/plugin/promise/plugin"
|
import { define } from "@opencode-ai/plugin/promise/plugin"
|
||||||
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
|
import type { SessionHooks, SessionHttpHandler } from "@opencode-ai/plugin/effect/session"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
import { PluginTestLayer } from "./fixture"
|
import { PluginTestLayer } from "./fixture"
|
||||||
import { host as testHost } from "./host"
|
import { host as testHost } from "./host"
|
||||||
@@ -223,45 +223,102 @@ describe("fromPromise", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("adapts promise session HTTP request and response hooks", () =>
|
it.effect("adapts promise session HTTP hooks", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const plugin = yield* Plugin.Service
|
||||||
|
const hooks = yield* PluginHooks.Service
|
||||||
|
const host = yield* PluginHost.make(plugin)
|
||||||
|
const bodies: string[] = []
|
||||||
|
yield* PluginPromise.fromPromise(
|
||||||
|
define({
|
||||||
|
id: "promise-session-http",
|
||||||
|
setup: async (ctx) => {
|
||||||
|
await ctx.session.hook("http", (event) => {
|
||||||
|
event.use(async (request, next) => {
|
||||||
|
request.headers.set("x-hook", "promise")
|
||||||
|
await next(request)
|
||||||
|
const response = await next(request)
|
||||||
|
return new Response(`${await response.text()}-response`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await ctx.session.hook("http", (event) => {
|
||||||
|
event.use(async (request, next) => {
|
||||||
|
const response = await next(request)
|
||||||
|
return new Response(`${await response.text()}-outer`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).effect(host)
|
||||||
|
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
|
||||||
|
const event: PluginHooks.Domains["session"]["http"] = {
|
||||||
|
sessionID: Session.ID.make("ses_promise_session_http"),
|
||||||
|
agent: Agent.ID.make("build"),
|
||||||
|
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
|
||||||
|
use: (item) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
middlewares.push(item)
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* hooks.trigger("session", "http", event)
|
||||||
|
const request = middlewares.reduce<SessionHttpHandler>(
|
||||||
|
(next, item) => (input: Request) => item(input, next),
|
||||||
|
(input: Request) =>
|
||||||
|
Effect.promise(() => input.text()).pipe(
|
||||||
|
Effect.tap((body) => Effect.sync(() => bodies.push(body))),
|
||||||
|
Effect.as(new Response(input.headers.get("x-hook") ?? "missing")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const response = yield* request(new Request("https://provider.test", { method: "POST", body: "payload" }))
|
||||||
|
|
||||||
|
expect(bodies).toEqual(["payload", "payload"])
|
||||||
|
expect(yield* Effect.promise(() => response.text())).toBe("promise-response-outer")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("interrupts the Effect request through a promise session HTTP hook", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* Plugin.Service
|
const plugin = yield* Plugin.Service
|
||||||
const hooks = yield* PluginHooks.Service
|
const hooks = yield* PluginHooks.Service
|
||||||
const host = yield* PluginHost.make(plugin)
|
const host = yield* PluginHost.make(plugin)
|
||||||
yield* PluginPromise.fromPromise(
|
yield* PluginPromise.fromPromise(
|
||||||
define({
|
define({
|
||||||
id: "promise-session-http",
|
id: "promise-session-http-interrupt",
|
||||||
setup: async (ctx) => {
|
setup: async (ctx) => {
|
||||||
await ctx.session.hook("http.request", (event) => {
|
await ctx.session.hook("http", (event) => {
|
||||||
event.request = new Request("https://provider.test/changed", event.request)
|
event.use((request, next) => next(request))
|
||||||
event.request.headers.set("x-hook", "promise")
|
|
||||||
})
|
|
||||||
await ctx.session.hook("http.response", async (event) => {
|
|
||||||
event.response = new Response(`${await event.response.text()}-response`, {
|
|
||||||
status: event.response.status,
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
).effect(host)
|
).effect(host)
|
||||||
const context = {
|
const started = yield* Deferred.make<void>()
|
||||||
sessionID: Session.ID.make("ses_promise_session_http"),
|
const interrupted = yield* Deferred.make<void>()
|
||||||
|
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
|
||||||
|
const event: PluginHooks.Domains["session"]["http"] = {
|
||||||
|
sessionID: Session.ID.make("ses_promise_session_http_interrupt"),
|
||||||
agent: Agent.ID.make("build"),
|
agent: Agent.ID.make("build"),
|
||||||
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
|
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
|
||||||
|
use: (item) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
middlewares.push(item)
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
const request = yield* hooks.trigger("session", "http.request", {
|
yield* hooks.trigger("session", "http", event)
|
||||||
...context,
|
const request = middlewares.reduce<SessionHttpHandler>(
|
||||||
request: new Request("https://provider.test", { method: "POST", body: "payload" }),
|
(next, item) => (input: Request) => item(input, next),
|
||||||
})
|
() =>
|
||||||
const response = yield* hooks.trigger("session", "http.response", {
|
Deferred.succeed(started, undefined).pipe(
|
||||||
...context,
|
Effect.andThen(Effect.never),
|
||||||
request: request.request,
|
Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)),
|
||||||
response: new Response(request.request.headers.get("x-hook") ?? "missing"),
|
),
|
||||||
})
|
)
|
||||||
|
const fiber = yield* request(new Request("https://provider.test")).pipe(Effect.forkChild)
|
||||||
|
yield* Deferred.await(started)
|
||||||
|
yield* Fiber.interrupt(fiber)
|
||||||
|
|
||||||
expect(request.request.url).toBe("https://provider.test/changed")
|
expect(yield* Deferred.isDone(interrupted)).toBeTrue()
|
||||||
expect(yield* Effect.promise(() => response.response.text())).toBe("promise-response")
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { PluginHost } from "@opencode-ai/core/plugin/host"
|
|||||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||||
import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai"
|
import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai"
|
||||||
import { Provider } from "@opencode-ai/core/provider"
|
import { Provider } from "@opencode-ai/core/provider"
|
||||||
|
import type { SessionHttpHandler } from "@opencode-ai/plugin/effect/session"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
import { PluginTestLayer } from "./fixture"
|
import { PluginTestLayer } from "./fixture"
|
||||||
|
|
||||||
@@ -30,13 +31,26 @@ function required<T>(value: T | undefined): T {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const http = Effect.fn(function* (providerID: Provider.ID, url: string) {
|
const http = Effect.fn(function* (providerID: Provider.ID, url: string) {
|
||||||
const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", {
|
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
|
||||||
|
yield* (yield* PluginHooks.Service).trigger("session", "http", {
|
||||||
sessionID: Session.ID.make("ses_test"),
|
sessionID: Session.ID.make("ses_test"),
|
||||||
agent: Agent.ID.make("build"),
|
agent: Agent.ID.make("build"),
|
||||||
model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }),
|
model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }),
|
||||||
request: new Request(url, { method: "POST", body: "{}" }),
|
use: (item) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
middlewares.push(item)
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
return { url: event.request.url, headers: Object.fromEntries(event.request.headers.entries()) }
|
const request = middlewares.reduce<SessionHttpHandler>(
|
||||||
|
(next, item) => (input: Request) => item(input, next),
|
||||||
|
(input: Request) => {
|
||||||
|
const headers = new Headers(input.headers)
|
||||||
|
headers.set("x-seen-url", input.url)
|
||||||
|
return Effect.succeed(new Response(null, { headers }))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const response = yield* request(new Request(url, { method: "POST", body: "{}" }))
|
||||||
|
return { url: response.headers.get("x-seen-url"), headers: Object.fromEntries(response.headers.entries()) }
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("OpenAIPlugin", () => {
|
describe("OpenAIPlugin", () => {
|
||||||
@@ -126,7 +140,7 @@ describe("OpenAIPlugin", () => {
|
|||||||
const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
|
const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
|
||||||
expect(eligible.package).toBe("@opencode-ai/ai/providers/openai")
|
expect(eligible.package).toBe("@opencode-ai/ai/providers/openai")
|
||||||
expect(eligible.cost).toEqual([])
|
expect(eligible.cost).toEqual([])
|
||||||
expect(eligible.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
|
expect(eligible.limit).toEqual({ context: 272_000, input: 272_000, output: 128_000 })
|
||||||
expect(eligible.enabled).toBe(true)
|
expect(eligible.enabled).toBe(true)
|
||||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5-pro"))).enabled).toBe(
|
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5-pro"))).enabled).toBe(
|
||||||
false,
|
false,
|
||||||
@@ -135,14 +149,14 @@ describe("OpenAIPlugin", () => {
|
|||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.4"))).limit).toEqual({
|
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.4"))).limit).toEqual({
|
||||||
context: 400_000,
|
context: 272_000,
|
||||||
input: 272_000,
|
input: 272_000,
|
||||||
output: 64_000,
|
output: 64_000,
|
||||||
})
|
})
|
||||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6"))).enabled).toBe(false)
|
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6"))).enabled).toBe(false)
|
||||||
const gpt56 = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6-sol")))
|
const gpt56 = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6-sol")))
|
||||||
expect(gpt56.enabled).toBe(true)
|
expect(gpt56.enabled).toBe(true)
|
||||||
expect(gpt56.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
|
expect(gpt56.limit).toEqual({ context: 272_000, input: 272_000, output: 128_000 })
|
||||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(false)
|
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(false)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import { SessionPending } from "@opencode-ai/core/session/pending"
|
|||||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||||
import { SessionTransfer } from "@opencode-ai/core/session/transfer"
|
|
||||||
import { Workspace } from "@opencode-ai/core/workspace"
|
import { Workspace } from "@opencode-ai/core/workspace"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
import { tmpdir } from "./fixture/tmpdir"
|
import { tmpdir } from "./fixture/tmpdir"
|
||||||
@@ -38,14 +37,7 @@ const projects = Layer.succeed(
|
|||||||
)
|
)
|
||||||
const it = testEffect(
|
const it = testEffect(
|
||||||
AppNodeBuilder.build(
|
AppNodeBuilder.build(
|
||||||
LayerNode.group([
|
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||||
Database.node,
|
|
||||||
Bus.node,
|
|
||||||
SessionProjector.node,
|
|
||||||
SessionStore.node,
|
|
||||||
Session.node,
|
|
||||||
SessionTransfer.node,
|
|
||||||
]),
|
|
||||||
[
|
[
|
||||||
[Bus.node, Bus.configured({ persist: true })],
|
[Bus.node, Bus.configured({ persist: true })],
|
||||||
[Project.node, projects],
|
[Project.node, projects],
|
||||||
@@ -748,77 +740,3 @@ describe("Session.create", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("SessionTransfer", () => {
|
|
||||||
it.effect("imports projected messages and reserves their aggregate sequence", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const session = yield* Session.Service
|
|
||||||
const transfer = yield* SessionTransfer.Service
|
|
||||||
const bus = yield* Bus.Service
|
|
||||||
const { db } = yield* Database.Service
|
|
||||||
const template = yield* session.create({ location, title: "Exported" })
|
|
||||||
const sessionID = Session.ID.create()
|
|
||||||
const sourceMessageID = SessionMessage.ID.create()
|
|
||||||
const errorMessageID = SessionMessage.ID.create()
|
|
||||||
|
|
||||||
const imported = yield* transfer.import({
|
|
||||||
data: {
|
|
||||||
info: { ...template, id: sessionID },
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
id: sourceMessageID,
|
|
||||||
type: "user",
|
|
||||||
text: "Imported message",
|
|
||||||
time: { created: DateTime.makeUnsafe(100) },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: errorMessageID,
|
|
||||||
type: "compaction",
|
|
||||||
status: "failed",
|
|
||||||
reason: "manual",
|
|
||||||
error: { type: "test_error", message: "Original error" },
|
|
||||||
time: { created: DateTime.makeUnsafe(101) },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
location,
|
|
||||||
})
|
|
||||||
const messages = yield* session.messages({ sessionID, order: "asc" })
|
|
||||||
|
|
||||||
expect(imported).toMatchObject({ id: sessionID, title: "Exported", location })
|
|
||||||
expect(messages).toMatchObject([
|
|
||||||
{ id: sourceMessageID, type: "user", text: "Imported message" },
|
|
||||||
{ id: errorMessageID, type: "compaction", error: { type: "test_error", message: "Original error" } },
|
|
||||||
])
|
|
||||||
expect(yield* Bus.latestSequence(db, sessionID)).toBe(2)
|
|
||||||
expect((yield* transfer.export({ sessionID })).messages).toEqual(messages)
|
|
||||||
expect((yield* transfer.export({ sessionID, sanitize: true })).messages).toMatchObject([
|
|
||||||
{ id: sourceMessageID, text: `[redacted:text:${sourceMessageID}]` },
|
|
||||||
{ id: errorMessageID, error: { type: "test_error", message: "Original error" } },
|
|
||||||
])
|
|
||||||
|
|
||||||
yield* session.prompt({ sessionID, text: "Continue", resume: false })
|
|
||||||
yield* SessionPending.promote(db, bus, sessionID, "steer")
|
|
||||||
|
|
||||||
expect((yield* session.messages({ sessionID, order: "asc" })).map((message) => message.type)).toEqual([
|
|
||||||
"user",
|
|
||||||
"compaction",
|
|
||||||
"user",
|
|
||||||
])
|
|
||||||
expect(yield* Bus.latestSequence(db, sessionID)).toBe(4)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("rejects an existing session ID without changing its transcript", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const session = yield* Session.Service
|
|
||||||
const transfer = yield* SessionTransfer.Service
|
|
||||||
const existing = yield* session.create({ location, title: "Existing" })
|
|
||||||
const exit = yield* Effect.exit(transfer.import({ data: { info: existing, messages: [] }, location }))
|
|
||||||
|
|
||||||
expect(exit._tag).toBe("Failure")
|
|
||||||
expect((yield* session.get(existing.id)).title).toBe("Existing")
|
|
||||||
expect(yield* session.messages({ sessionID: existing.id })).toEqual([])
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -255,7 +255,6 @@ describe("SessionRunnerLLM recorded", () => {
|
|||||||
describe("SessionModelRequest HTTP bridge", () => {
|
describe("SessionModelRequest HTTP bridge", () => {
|
||||||
const bodies: Uint8Array[] = []
|
const bodies: Uint8Array[] = []
|
||||||
const methods: string[] = []
|
const methods: string[] = []
|
||||||
const headers: Array<string | undefined> = []
|
|
||||||
const response = [
|
const response = [
|
||||||
'data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":0,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello!"},"finish_reason":null}]}',
|
'data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":0,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello!"},"finish_reason":null}]}',
|
||||||
'data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":0,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}',
|
'data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":0,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}',
|
||||||
@@ -269,7 +268,6 @@ describe("SessionModelRequest HTTP bridge", () => {
|
|||||||
if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`)
|
if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`)
|
||||||
methods.push(request.method)
|
methods.push(request.method)
|
||||||
bodies.push(request.body.body.slice())
|
bodies.push(request.body.body.slice())
|
||||||
headers.push(request.headers["x-hook"])
|
|
||||||
return HttpClientResponse.fromWeb(
|
return HttpClientResponse.fromWeb(
|
||||||
request,
|
request,
|
||||||
new Response(response, { headers: { "content-type": "text/event-stream" } }),
|
new Response(response, { headers: { "content-type": "text/event-stream" } }),
|
||||||
@@ -277,16 +275,14 @@ describe("SessionModelRequest HTTP bridge", () => {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const httpIt = testEffect(
|
const retryIt = testEffect(
|
||||||
testLayer(LLMClient.layer.pipe(Layer.provide(RequestExecutor.layer.pipe(Layer.provide(transport))))),
|
testLayer(LLMClient.layer.pipe(Layer.provide(RequestExecutor.layer.pipe(Layer.provide(transport))))),
|
||||||
)
|
)
|
||||||
|
|
||||||
httpIt.effect("runs Effect HTTP request and response hooks around one provider request", () =>
|
retryIt.effect("lets an Effect plugin send the same POST Request twice", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
bodies.length = 0
|
bodies.length = 0
|
||||||
methods.length = 0
|
methods.length = 0
|
||||||
headers.length = 0
|
|
||||||
const seen: string[] = []
|
|
||||||
const agents = yield* Agent.Service
|
const agents = yield* Agent.Service
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
const hooks = yield* PluginHooks.Service
|
const hooks = yield* PluginHooks.Service
|
||||||
@@ -301,20 +297,13 @@ describe("SessionModelRequest HTTP bridge", () => {
|
|||||||
catalog: catalogHost(catalog),
|
catalog: catalogHost(catalog),
|
||||||
session: { hook: (name, callback) => hooks.register("session", name, callback) },
|
session: { hook: (name, callback) => hooks.register("session", name, callback) },
|
||||||
})
|
})
|
||||||
yield* pluginHost.session.hook("http.request", (event) =>
|
yield* pluginHost.session.hook("http", (event) =>
|
||||||
Effect.sync(() => {
|
event.use((request, next) =>
|
||||||
seen.push("request")
|
|
||||||
event.request.headers.set("x-hook", "effect")
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
yield* pluginHost.session.hook("http.response", (event) =>
|
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
seen.push(`response:${event.response.status}:${event.request.headers.get("x-hook")}`)
|
yield* next(request).pipe(Effect.flatMap((response) => Effect.promise(() => response.text())))
|
||||||
event.response = new Response(
|
return yield* next(request)
|
||||||
(yield* Effect.promise(() => event.response.text())).replace("Hello!", "Hooked!"),
|
|
||||||
event.response,
|
|
||||||
)
|
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
yield* Effect.forEach(SystemPromptPlugin.Plugins, (plugin) => plugin.effect(pluginHost), { discard: true })
|
yield* Effect.forEach(SystemPromptPlugin.Plugins, (plugin) => plugin.effect(pluginHost), { discard: true })
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
@@ -342,15 +331,10 @@ describe("SessionModelRequest HTTP bridge", () => {
|
|||||||
|
|
||||||
yield* session.resume(retrySessionID)
|
yield* session.resume(retrySessionID)
|
||||||
|
|
||||||
expect(methods).toEqual(["POST"])
|
expect(methods).toEqual(["POST", "POST"])
|
||||||
expect(headers).toEqual(["effect"])
|
expect(bodies).toHaveLength(2)
|
||||||
expect(seen).toEqual(["request", "response:200:effect"])
|
|
||||||
expect(bodies).toHaveLength(1)
|
|
||||||
expect(bodies[0]?.byteLength).toBeGreaterThan(0)
|
expect(bodies[0]?.byteLength).toBeGreaterThan(0)
|
||||||
expect((yield* session.context(retrySessionID))[1]).toMatchObject({
|
expect(bodies[1]).toEqual(bodies[0])
|
||||||
type: "assistant",
|
|
||||||
content: [{ type: "text", text: "Hooked!" }],
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
import { describe, expect } from "bun:test"
|
|
||||||
import path from "path"
|
|
||||||
import { Effect, Layer, Stream } from "effect"
|
|
||||||
import { Config } from "@opencode-ai/core/config"
|
|
||||||
import { Document, Info } from "@opencode-ai/schema/config"
|
|
||||||
import { ConfigToolOutput } from "@opencode-ai/schema/config/tool-output"
|
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
|
||||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
|
||||||
import { ToolOutput } from "@opencode-ai/core/tool-output"
|
|
||||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
|
||||||
import { Global } from "@opencode-ai/util/global"
|
|
||||||
import { Identifier } from "@opencode-ai/core/id/id"
|
|
||||||
import { tmpdir } from "./fixture/tmpdir"
|
|
||||||
import { it } from "./lib/effect"
|
|
||||||
|
|
||||||
const withStore = <A, E, R>(
|
|
||||||
body: (output: ToolOutput.Interface, fs: FSUtil.Interface, root: string) => Effect.Effect<A, E, R>,
|
|
||||||
info = new Info(),
|
|
||||||
) =>
|
|
||||||
Effect.acquireUseRelease(
|
|
||||||
Effect.promise(() => tmpdir()),
|
|
||||||
(tmp) => {
|
|
||||||
const config = Layer.succeed(
|
|
||||||
Config.Service,
|
|
||||||
Config.Service.of({
|
|
||||||
entries: () => Effect.succeed([new Document({ type: "document", info })]),
|
|
||||||
changes: () => Stream.empty,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const layer = AppNodeBuilder.build(LayerNode.group([ToolOutput.node, FSUtil.node]), [
|
|
||||||
[Config.node, config],
|
|
||||||
[Global.node, Global.layerWith({ data: tmp.path })],
|
|
||||||
])
|
|
||||||
return Effect.gen(function* () {
|
|
||||||
return yield* body(yield* ToolOutput.Service, yield* FSUtil.Service, tmp.path)
|
|
||||||
}).pipe(Effect.provide(layer))
|
|
||||||
},
|
|
||||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
|
||||||
)
|
|
||||||
|
|
||||||
describe("ToolOutput", () => {
|
|
||||||
it.live("writes oversized text and returns a bounded preview", () =>
|
|
||||||
withStore(
|
|
||||||
(service, fs) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const output = { items: [1, 2, 3] }
|
|
||||||
const result = yield* service.truncate({ output, content: "one\ntwo\nthree" })
|
|
||||||
expect(result.output).toBe(output)
|
|
||||||
expect(result.metadata).toMatchObject({ truncated: true })
|
|
||||||
const outputPath = result.metadata?.outputPath
|
|
||||||
expect(typeof outputPath).toBe("string")
|
|
||||||
if (typeof outputPath !== "string") return
|
|
||||||
expect(yield* fs.readFileString(outputPath)).toBe("one\ntwo\nthree")
|
|
||||||
expect(result.content).toEqual([
|
|
||||||
{ type: "text", text: "one\ntwo" },
|
|
||||||
{ type: "text", text: `... 1 line truncated; full content saved to ${outputPath} ...` },
|
|
||||||
])
|
|
||||||
}),
|
|
||||||
new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("reports bytes omitted by the byte limit", () =>
|
|
||||||
withStore(
|
|
||||||
(output) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const result = yield* output.truncate({ content: "one\ntwo" })
|
|
||||||
expect(result.content).toEqual([
|
|
||||||
{ type: "text", text: "one" },
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: expect.stringMatching(/^\.\.\. 4 bytes truncated; full content saved to .+ \.\.\.$/),
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}),
|
|
||||||
new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 100, max_bytes: 5 }) }),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("preserves mixed content ordering", () =>
|
|
||||||
withStore(
|
|
||||||
(output) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const file = { type: "file" as const, uri: "file:///image.png", mime: "image/png" }
|
|
||||||
const result = yield* output.truncate({
|
|
||||||
content: [{ type: "text", text: "before" }, file, { type: "text", text: "after\nomitted" }],
|
|
||||||
})
|
|
||||||
expect(result.content).toEqual([
|
|
||||||
{ type: "text", text: "before" },
|
|
||||||
file,
|
|
||||||
{ type: "text", text: "after" },
|
|
||||||
{ type: "text", text: expect.stringMatching(/^\.\.\. 1 line truncated; full content saved to /) },
|
|
||||||
])
|
|
||||||
}),
|
|
||||||
new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("skips results that report a truncation state", () =>
|
|
||||||
withStore((output) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const truncated = { content: "one\ntwo", metadata: { truncated: true, source: "tool" } }
|
|
||||||
const retained = { content: "one\ntwo", metadata: { truncated: false, source: "tool" } }
|
|
||||||
expect(yield* output.truncate(truncated)).toBe(truncated)
|
|
||||||
expect(yield* output.truncate(retained)).toBe(retained)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("marks results that fit without changing their content", () =>
|
|
||||||
withStore((output) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const content = [{ type: "text" as const, text: "small" }]
|
|
||||||
expect(yield* output.truncate({ content })).toEqual({ content, metadata: { truncated: false } })
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("does not count a trailing newline as another line", () =>
|
|
||||||
withStore(
|
|
||||||
(output) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
expect(yield* output.truncate({ content: "one\ntwo\n" })).toEqual({
|
|
||||||
content: "one\ntwo\n",
|
|
||||||
metadata: { truncated: false },
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("reports a trailing newline omitted by the byte limit", () =>
|
|
||||||
withStore(
|
|
||||||
(output) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const result = yield* output.truncate({ content: "one\n" })
|
|
||||||
expect(result.content).toEqual([
|
|
||||||
{ type: "text", text: "one" },
|
|
||||||
{ type: "text", text: expect.stringMatching(/^\.\.\. 1 byte truncated; full content saved to /) },
|
|
||||||
])
|
|
||||||
}),
|
|
||||||
new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 3 }) }),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("removes expired managed files", () =>
|
|
||||||
withStore((output, fs, root) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const directory = path.join(root, ToolOutput.DIRECTORY)
|
|
||||||
const old = path.join(
|
|
||||||
directory,
|
|
||||||
Identifier.create("tool", "ascending", Date.now() - 8 * 24 * 60 * 60 * 1_000),
|
|
||||||
)
|
|
||||||
const recent = path.join(directory, Identifier.ascending("tool"))
|
|
||||||
yield* fs.ensureDir(directory)
|
|
||||||
yield* fs.writeFileString(old, "old")
|
|
||||||
yield* fs.writeFileString(recent, "recent")
|
|
||||||
yield* output.cleanup()
|
|
||||||
expect(yield* fs.exists(old)).toBe(false)
|
|
||||||
expect(yield* fs.exists(recent)).toBe(true)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import fs from "fs/promises"
|
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Effect, FileSystem } from "effect"
|
import { Effect, FileSystem } from "effect"
|
||||||
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
|
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
|
||||||
@@ -51,53 +50,22 @@ describe("ReadToolFileSystem", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("reads malformed UTF-8 lossily and still rejects null-byte binary content", () =>
|
it.effect("reports binary and malformed UTF-8 content as typed errors", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { fs, files, directory } = yield* fixture
|
const { fs, files, directory } = yield* fixture
|
||||||
const binary = path.join(directory, "archive.dat")
|
const binary = path.join(directory, "archive.dat")
|
||||||
const malformed = path.join(directory, "malformed.txt")
|
const malformed = path.join(directory, "malformed.txt")
|
||||||
yield* files.writeFile(binary, Uint8Array.of(0, 1, 2, 3))
|
yield* files.writeFile(binary, Uint8Array.of(0, 1, 2, 3))
|
||||||
yield* files.writeFile(malformed, Uint8Array.of(0x68, 0x69, 0x80))
|
const malformedContent = new Uint8Array(64 * 1024 + 1).fill(97)
|
||||||
|
malformedContent[64 * 1024] = 0x80
|
||||||
|
yield* files.writeFile(malformed, malformedContent)
|
||||||
|
|
||||||
const binaryError = yield* ReadToolFileSystem.read(fs, binary, "archive.dat").pipe(Effect.flip)
|
const binaryError = yield* ReadToolFileSystem.read(fs, binary, "archive.dat").pipe(Effect.flip)
|
||||||
const malformedResult = yield* ReadToolFileSystem.read(fs, malformed, "malformed.txt")
|
const malformedError = yield* ReadToolFileSystem.read(fs, malformed, "malformed.txt").pipe(Effect.flip)
|
||||||
|
|
||||||
expect(binaryError).toBeInstanceOf(ReadToolFileSystem.BinaryFileError)
|
expect(binaryError).toBeInstanceOf(ReadToolFileSystem.BinaryFileError)
|
||||||
expect(binaryError.message).toBe("Cannot read binary file: archive.dat")
|
expect(binaryError.message).toBe("Cannot read binary file: archive.dat")
|
||||||
expect(malformedResult).toMatchObject({ type: "file", content: "hi\uFFFD", encoding: "utf8" })
|
expect(malformedError).toBeInstanceOf(ReadToolFileSystem.MalformedUtf8Error)
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("reads text despite a binary-associated extension", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const { fs, files, directory } = yield* fixture
|
|
||||||
const file = path.join(directory, "notes.docx")
|
|
||||||
yield* files.writeFileString(file, "plain text")
|
|
||||||
|
|
||||||
const result = yield* ReadToolFileSystem.read(fs, file, "notes.docx")
|
|
||||||
|
|
||||||
expect(result).toMatchObject({ type: "file", content: "plain text", encoding: "utf8" })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("lists unresolved symlinks, including broken and escaping links", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
if (process.platform === "win32") return
|
|
||||||
const { fs: service, files, directory } = yield* fixture
|
|
||||||
const outside = yield* files.makeTempDirectoryScoped()
|
|
||||||
yield* files.makeDirectory(path.join(directory, "folder"))
|
|
||||||
yield* files.writeFileString(path.join(directory, "file.txt"), "hello")
|
|
||||||
yield* Effect.promise(() => fs.symlink(path.join(outside, "target.txt"), path.join(directory, "escape")))
|
|
||||||
yield* Effect.promise(() => fs.symlink(path.join(directory, "missing.txt"), path.join(directory, "broken")))
|
|
||||||
|
|
||||||
const result = yield* ReadToolFileSystem.list(service, directory)
|
|
||||||
|
|
||||||
expect(result.entries.map((entry) => ({ ...entry, path: String(entry.path) }))).toEqual([
|
|
||||||
{ path: `folder${path.sep}`, type: "directory" },
|
|
||||||
{ path: "broken", type: "symlink" },
|
|
||||||
{ path: "escape", type: "symlink" },
|
|
||||||
{ path: "file.txt", type: "file" },
|
|
||||||
])
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -148,13 +148,13 @@ const mutation = Layer.succeed(
|
|||||||
LocationMutation.Service,
|
LocationMutation.Service,
|
||||||
LocationMutation.Service.of({
|
LocationMutation.Service.of({
|
||||||
resolve: (input) => {
|
resolve: (input) => {
|
||||||
const absolute = path.resolve(process.cwd(), input.path)
|
const canonical = path.resolve(process.cwd(), input.path)
|
||||||
const external = path.isAbsolute(input.path) && !FSUtil.contains(process.cwd(), absolute)
|
const external = path.isAbsolute(input.path) && !FSUtil.contains(process.cwd(), canonical)
|
||||||
const resource = external ? absolute.replaceAll("\\", "/") : path.relative(process.cwd(), absolute) || "."
|
const resource = external ? canonical.replaceAll("\\", "/") : path.relative(process.cwd(), canonical) || "."
|
||||||
const directory = path.dirname(absolute)
|
const directory = path.dirname(canonical)
|
||||||
const externalResource = path.join(directory, "*").replaceAll("\\", "/")
|
const externalResource = path.join(directory, "*").replaceAll("\\", "/")
|
||||||
return Effect.succeed({
|
return Effect.succeed({
|
||||||
absolute,
|
canonical,
|
||||||
resource,
|
resource,
|
||||||
externalDirectory: external
|
externalDirectory: external
|
||||||
? {
|
? {
|
||||||
@@ -311,7 +311,9 @@ describe("ReadTool", () => {
|
|||||||
})
|
})
|
||||||
expect(settled.status).toBe("completed")
|
expect(settled.status).toBe("completed")
|
||||||
if (settled.status !== "completed") return
|
if (settled.status !== "completed") return
|
||||||
expect(settled.metadata).toEqual({ truncated: false })
|
// Image base64 is carried by the content file item only; read produces no
|
||||||
|
// metadata, so the original bytes are never persisted twice.
|
||||||
|
expect(settled.metadata).toBeUndefined()
|
||||||
expect(settled.content).toMatchObject([
|
expect(settled.content).toMatchObject([
|
||||||
{ type: "text", text: "Image read successfully" },
|
{ type: "text", text: "Image read successfully" },
|
||||||
{ type: "file", mime: "image/png", uri: `data:image/png;base64,${png}` },
|
{ type: "file", mime: "image/png", uri: `data:image/png;base64,${png}` },
|
||||||
@@ -619,6 +621,10 @@ describe("ReadTool", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const registry = yield* Tool.Service
|
const registry = yield* Tool.Service
|
||||||
for (const [error, message] of [
|
for (const [error, message] of [
|
||||||
|
[
|
||||||
|
new ReadToolFileSystem.MalformedUtf8Error({ resource: "invalid.txt" }),
|
||||||
|
"File is not valid UTF-8: invalid.txt",
|
||||||
|
],
|
||||||
[new ReadToolFileSystem.OffsetOutOfRangeError({ offset: 10 }), "Offset 10 is out of range"],
|
[new ReadToolFileSystem.OffsetOutOfRangeError({ offset: 10 }), "Offset 10 is out of range"],
|
||||||
[
|
[
|
||||||
new ReadToolFileSystem.PathKindError({ resource: "socket", expected: "a file" }),
|
new ReadToolFileSystem.PathKindError({ resource: "socket", expected: "a file" }),
|
||||||
@@ -724,12 +730,8 @@ describe("ReadTool", () => {
|
|||||||
input: { path: "src", offset: 2, limit: 10 },
|
input: { path: "src", offset: 2, limit: 10 },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({ status: "completed", output: { entries: listResult.entries, truncated: true, next: 4 } })
|
||||||
status: "completed",
|
|
||||||
output: { entries: listResult.entries, truncated: true, next: 4 },
|
|
||||||
})
|
|
||||||
if (result.status !== "completed") return
|
if (result.status !== "completed") return
|
||||||
expect(result.metadata).toEqual({ truncated: true })
|
|
||||||
expect(result.content).toEqual([
|
expect(result.content).toEqual([
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
@@ -804,7 +806,6 @@ describe("ReadTool", () => {
|
|||||||
output: { type: "text-page", content: "hello", mime: "text/plain", offset: 2, truncated: true, next: 3 },
|
output: { type: "text-page", content: "hello", mime: "text/plain", offset: 2, truncated: true, next: 3 },
|
||||||
})
|
})
|
||||||
if (result.status !== "completed") return
|
if (result.status !== "completed") return
|
||||||
expect(result.metadata).toEqual({ truncated: true })
|
|
||||||
expect(result.content).toEqual([
|
expect(result.content).toEqual([
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
|||||||
import { Shell } from "@opencode-ai/core/shell"
|
import { Shell } from "@opencode-ai/core/shell"
|
||||||
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
|
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
|
||||||
import { ShellTool } from "@opencode-ai/core/tool/plugin/shell"
|
import { ShellTool } from "@opencode-ai/core/tool/plugin/shell"
|
||||||
import { ToolOutput } from "@opencode-ai/core/tool-output"
|
|
||||||
import { Tool } from "@opencode-ai/core/tool"
|
import { Tool } from "@opencode-ai/core/tool"
|
||||||
import { tmpdir } from "./fixture/tmpdir"
|
import { tmpdir } from "./fixture/tmpdir"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
@@ -172,9 +171,6 @@ const overflowCommand = (bytes: number) =>
|
|||||||
isWindows
|
isWindows
|
||||||
? `[Console]::Out.Write('output-start' + ('x' * ${bytes}) + 'output-end'); Start-Sleep -Milliseconds 100`
|
? `[Console]::Out.Write('output-start' + ('x' * ${bytes}) + 'output-end'); Start-Sleep -Milliseconds 100`
|
||||||
: `printf output-start; head -c ${bytes} /dev/zero | tr '\\0' 'x'; printf output-end`
|
: `printf output-start; head -c ${bytes} /dev/zero | tr '\\0' 'x'; printf output-end`
|
||||||
const lineOverflowCommand = isWindows
|
|
||||||
? "[Console]::Out.Write('one' + [Environment]::NewLine + 'two' + [Environment]::NewLine + 'three')"
|
|
||||||
: "printf 'one\\ntwo\\nthree'"
|
|
||||||
const progressOverflowCommand = (bytes: number, release: string) =>
|
const progressOverflowCommand = (bytes: number, release: string) =>
|
||||||
isWindows
|
isWindows
|
||||||
? `[Console]::Out.Write(('x' * ${bytes})); while (!(Test-Path -LiteralPath '${release}')) { Start-Sleep -Milliseconds 50 }`
|
? `[Console]::Out.Write(('x' * ${bytes})); while (!(Test-Path -LiteralPath '${release}')) { Start-Sleep -Milliseconds 50 }`
|
||||||
@@ -481,7 +477,7 @@ describe("ShellTool", () => {
|
|||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
(tmp) => {
|
(tmp) => {
|
||||||
reset()
|
reset()
|
||||||
const bytes = ToolOutput.MAX_BYTES + 1024
|
const bytes = ShellTool.MAX_CAPTURE_BYTES + 1024
|
||||||
return withSession(tmp.path, (registry) =>
|
return withSession(tmp.path, (registry) =>
|
||||||
executeTool(registry, call({ command: overflowCommand(bytes) }, "call-overflow")),
|
executeTool(registry, call({ command: overflowCommand(bytes) }, "call-overflow")),
|
||||||
).pipe(
|
).pipe(
|
||||||
@@ -505,33 +501,6 @@ describe("ShellTool", () => {
|
|||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("uses configured line limits", () =>
|
|
||||||
Effect.acquireUseRelease(
|
|
||||||
Effect.promise(() => tmpdir()),
|
|
||||||
(tmp) => {
|
|
||||||
reset()
|
|
||||||
return Effect.gen(function* () {
|
|
||||||
yield* Effect.promise(() =>
|
|
||||||
Bun.write(
|
|
||||||
path.join(tmp.path, "opencode.json"),
|
|
||||||
JSON.stringify({ tool_output: { max_lines: 2, max_bytes: 1_000 } }),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const settled = yield* withSession(tmp.path, (registry) =>
|
|
||||||
executeTool(registry, call({ command: lineOverflowCommand }, "call-line-overflow")),
|
|
||||||
)
|
|
||||||
expect(settled.metadata).toMatchObject({ exit: 0, truncated: true })
|
|
||||||
const content = settled.content?.[0]
|
|
||||||
if (!content || content.type !== "text") throw new Error("Expected text content")
|
|
||||||
expect(content.text).not.toContain("one")
|
|
||||||
expect(content.text).toStartWith("two\nthree")
|
|
||||||
expect(content.text).toContain("output truncated; full output saved to:")
|
|
||||||
})
|
|
||||||
},
|
|
||||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live(
|
it.live(
|
||||||
"reports the shell ID for a running command",
|
"reports the shell ID for a running command",
|
||||||
() =>
|
() =>
|
||||||
@@ -546,7 +515,7 @@ describe("ShellTool", () => {
|
|||||||
const observed = yield* Deferred.make<string>()
|
const observed = yield* Deferred.make<string>()
|
||||||
yield* executeTool(registry, {
|
yield* executeTool(registry, {
|
||||||
...call(
|
...call(
|
||||||
{ command: progressOverflowCommand(ToolOutput.MAX_BYTES + 1024, release) },
|
{ command: progressOverflowCommand(ShellTool.MAX_CAPTURE_BYTES + 1024, release) },
|
||||||
"call-progress",
|
"call-progress",
|
||||||
),
|
),
|
||||||
progress: (update) =>
|
progress: (update) =>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { beforeEach, describe, expect } from "bun:test"
|
import { beforeEach, describe, expect } from "bun:test"
|
||||||
import { Deferred, Effect, Layer } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||||
import { Permission } from "@opencode-ai/core/permission"
|
import { Permission } from "@opencode-ai/core/permission"
|
||||||
@@ -39,8 +39,6 @@ const providers = [
|
|||||||
let providerRequired = false
|
let providerRequired = false
|
||||||
let formResponse: Form.TerminalState = { status: "cancelled" }
|
let formResponse: Form.TerminalState = { status: "cancelled" }
|
||||||
const formResponses: Form.TerminalState[] = []
|
const formResponses: Form.TerminalState[] = []
|
||||||
let queryBarrier: Deferred.Deferred<void> | undefined
|
|
||||||
let synchronizedQueries = 0
|
|
||||||
let result = new WebSearch.Response({
|
let result = new WebSearch.Response({
|
||||||
providerID: WebSearch.ID.make("exa"),
|
providerID: WebSearch.ID.make("exa"),
|
||||||
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
|
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
|
||||||
@@ -54,8 +52,6 @@ beforeEach(() => {
|
|||||||
providerRequired = false
|
providerRequired = false
|
||||||
formResponse = { status: "cancelled" }
|
formResponse = { status: "cancelled" }
|
||||||
formResponses.length = 0
|
formResponses.length = 0
|
||||||
queryBarrier = undefined
|
|
||||||
synchronizedQueries = 0
|
|
||||||
result = new WebSearch.Response({
|
result = new WebSearch.Response({
|
||||||
providerID: WebSearch.ID.make("exa"),
|
providerID: WebSearch.ID.make("exa"),
|
||||||
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
|
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
|
||||||
@@ -79,21 +75,11 @@ const websearch = Layer.succeed(
|
|||||||
transform: () => Effect.die("unused"),
|
transform: () => Effect.die("unused"),
|
||||||
reload: () => Effect.die("unused"),
|
reload: () => Effect.die("unused"),
|
||||||
providers: () => Effect.succeed(providers),
|
providers: () => Effect.succeed(providers),
|
||||||
default: () =>
|
default: () => Effect.succeed(undefined),
|
||||||
Effect.gen(function* () {
|
|
||||||
const stored = values.get("websearch:provider")
|
|
||||||
if (stored === false) return yield* new WebSearch.DisabledError()
|
|
||||||
return typeof stored === "string" ? providers.find((provider) => provider.id === stored) : undefined
|
|
||||||
}),
|
|
||||||
query: (input) =>
|
query: (input) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
queries.push(input)
|
queries.push(input)
|
||||||
const stored = values.get("websearch:provider")
|
const stored = values.get("websearch:provider")
|
||||||
if (queryBarrier && synchronizedQueries < 5) {
|
|
||||||
synchronizedQueries++
|
|
||||||
if (synchronizedQueries === 5) yield* Deferred.succeed(queryBarrier, undefined)
|
|
||||||
yield* Deferred.await(queryBarrier)
|
|
||||||
}
|
|
||||||
if (providerRequired && typeof stored !== "string") return yield* new WebSearch.ProviderRequiredError()
|
if (providerRequired && typeof stored !== "string") return yield* new WebSearch.ProviderRequiredError()
|
||||||
if (typeof stored === "string")
|
if (typeof stored === "string")
|
||||||
return new WebSearch.Response({ providerID: WebSearch.ID.make(stored), results: result.results })
|
return new WebSearch.Response({ providerID: WebSearch.ID.make(stored), results: result.results })
|
||||||
@@ -255,6 +241,7 @@ describe("WebSearchTool registration", () => {
|
|||||||
{
|
{
|
||||||
sessionID,
|
sessionID,
|
||||||
title: "Web Search",
|
title: "Web Search",
|
||||||
|
coalesce: "msg_tool_test:websearch-consent",
|
||||||
metadata: { kind: "websearch.provider" },
|
metadata: { kind: "websearch.provider" },
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
@@ -312,6 +299,7 @@ describe("WebSearchTool registration", () => {
|
|||||||
expect(formRequests[1]).toEqual({
|
expect(formRequests[1]).toEqual({
|
||||||
sessionID,
|
sessionID,
|
||||||
title: "Choose a web search provider",
|
title: "Choose a web search provider",
|
||||||
|
coalesce: "msg_tool_test:websearch-provider",
|
||||||
metadata: { kind: "websearch.provider" },
|
metadata: { kind: "websearch.provider" },
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
@@ -330,35 +318,6 @@ describe("WebSearchTool registration", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("shares provider consent across concurrent searches", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
providerRequired = true
|
|
||||||
formResponse = { status: "answered", answer: { choice: "allow" } }
|
|
||||||
queryBarrier = yield* Deferred.make<void>()
|
|
||||||
const registry = yield* Tool.Service
|
|
||||||
|
|
||||||
const results = yield* Effect.all(
|
|
||||||
Array.from({ length: 5 }, (_, index) =>
|
|
||||||
executeTool(registry, {
|
|
||||||
sessionID,
|
|
||||||
...toolIdentity,
|
|
||||||
call: {
|
|
||||||
type: "tool-call",
|
|
||||||
id: `call-concurrent-${index}`,
|
|
||||||
name: "websearch",
|
|
||||||
input: { query: `effect ${index}` },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
{ concurrency: "unbounded" },
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(results.every((item) => item.status === "completed")).toBe(true)
|
|
||||||
expect(formRequests).toHaveLength(1)
|
|
||||||
expect(values.get("websearch:provider")).toBe("exa")
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("persists the choice to disable web search", () =>
|
it.effect("persists the choice to disable web search", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
providerRequired = true
|
providerRequired = true
|
||||||
|
|||||||
@@ -90,7 +90,13 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
|
|||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.provide(
|
Effect.provide(
|
||||||
AppNodeBuilder.build(
|
AppNodeBuilder.build(
|
||||||
LayerNode.group([Tool.node, Tool.node, LocationMutation.node, FileMutation.node, writeToolNode]),
|
LayerNode.group([
|
||||||
|
Tool.node,
|
||||||
|
Tool.node,
|
||||||
|
LocationMutation.node,
|
||||||
|
FileMutation.node,
|
||||||
|
writeToolNode,
|
||||||
|
]),
|
||||||
[
|
[
|
||||||
[FSUtil.node, filesystem],
|
[FSUtil.node, filesystem],
|
||||||
[Location.node, activeLocation],
|
[Location.node, activeLocation],
|
||||||
@@ -224,10 +230,7 @@ describe("WriteTool", () => {
|
|||||||
const deduplicated = path.join(tmp.path, "deduplicated.txt")
|
const deduplicated = path.join(tmp.path, "deduplicated.txt")
|
||||||
formatFile = (target) =>
|
formatFile = (target) =>
|
||||||
Effect.promise(async () => {
|
Effect.promise(async () => {
|
||||||
await fs.writeFile(
|
await fs.writeFile(target, `\uFEFF\uFEFF\uFEFF${(await fs.readFile(target, "utf8")).replace(/^\uFEFF+/, "")}`)
|
||||||
target,
|
|
||||||
`\uFEFF\uFEFF\uFEFF${(await fs.readFile(target, "utf8")).replace(/^\uFEFF+/, "")}`,
|
|
||||||
)
|
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
return Effect.promise(() =>
|
return Effect.promise(() =>
|
||||||
@@ -320,22 +323,24 @@ describe("WriteTool", () => {
|
|||||||
).pipe(
|
).pipe(
|
||||||
Effect.andThen((settled) =>
|
Effect.andThen((settled) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const absoluteTarget = target
|
const canonicalTarget = path.join(yield* Effect.promise(() => fs.realpath(outside.path)), "external.txt")
|
||||||
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
|
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
|
||||||
expect(assertions[0]).toMatchObject({
|
expect(assertions[0]).toMatchObject({
|
||||||
resources: [path.join(outside.path, "*").replaceAll("\\", "/")],
|
resources: [
|
||||||
|
path.join(yield* Effect.promise(() => fs.realpath(outside.path)), "*").replaceAll("\\", "/"),
|
||||||
|
],
|
||||||
})
|
})
|
||||||
expect(assertions[1]).toMatchObject({ resources: [absoluteTarget.replaceAll("\\", "/")], save: ["*"] })
|
expect(assertions[1]).toMatchObject({ resources: [canonicalTarget.replaceAll("\\", "/")], save: ["*"] })
|
||||||
expect(settled).toMatchObject({
|
expect(settled).toMatchObject({
|
||||||
status: "completed",
|
status: "completed",
|
||||||
output: {
|
output: {
|
||||||
target: absoluteTarget,
|
target: canonicalTarget,
|
||||||
resource: absoluteTarget.replaceAll("\\", "/"),
|
resource: canonicalTarget.replaceAll("\\", "/"),
|
||||||
existed: false,
|
existed: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("external")
|
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("external")
|
||||||
expect(writes).toEqual([absoluteTarget])
|
expect(writes).toEqual([canonicalTarget])
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -363,10 +368,12 @@ describe("WriteTool", () => {
|
|||||||
),
|
),
|
||||||
Effect.andThen(
|
Effect.andThen(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
const canonicalRepo = yield* Effect.promise(() => fs.realpath(repo))
|
||||||
|
const canonicalNested = yield* Effect.promise(() => fs.realpath(nested))
|
||||||
expect(assertions[0]).toMatchObject({
|
expect(assertions[0]).toMatchObject({
|
||||||
action: "external_directory",
|
action: "external_directory",
|
||||||
resources: [path.join(nested, "*").replaceAll("\\", "/")],
|
resources: [path.join(canonicalNested, "*").replaceAll("\\", "/")],
|
||||||
save: [path.join(repo, "*").replaceAll("\\", "/")],
|
save: [path.join(canonicalRepo, "*").replaceAll("\\", "/")],
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { ServerConnection, useServer, useTabs } from "@opencode-ai/app"
|
import { ServerConnection, useServer, useSettings, useTabs } from "@opencode-ai/app"
|
||||||
import { onMount } from "solid-js"
|
import { onMount } from "solid-js"
|
||||||
|
|
||||||
export function DesktopFirstLaunchOnboarding(props: { initialUrl: string; onLoaded: () => void }) {
|
export function DesktopFirstLaunchOnboarding(props: { initialUrl: string; onLoaded: () => void }) {
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
|
const settings = useSettings()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -15,6 +16,7 @@ export function DesktopFirstLaunchOnboarding(props: { initialUrl: string; onLoad
|
|||||||
[server.ready.promise, tabs.ready.promise, tabs.recentReady.promise].map((p) => p ?? Promise.resolve()),
|
[server.ready.promise, tabs.ready.promise, tabs.recentReady.promise].map((p) => p ?? Promise.resolve()),
|
||||||
)
|
)
|
||||||
const existingInstall = await window.api.isOldLayoutEligible()
|
const existingInstall = await window.api.isOldLayoutEligible()
|
||||||
|
settings.general.setOldLayoutEligible(existingInstall)
|
||||||
if (!server.isLocal()) return
|
if (!server.isLocal()) return
|
||||||
|
|
||||||
const pending = await window.api.isFirstLaunchOnboardingPending()
|
const pending = await window.api.isFirstLaunchOnboardingPending()
|
||||||
|
|||||||
@@ -44,12 +44,11 @@ export type SQLiteEffectSelectPrepare<
|
|||||||
TEffectHKT
|
TEffectHKT
|
||||||
>
|
>
|
||||||
|
|
||||||
// Explicit variance prevents comparisons from recursively scanning Drizzle's conditional select types.
|
|
||||||
export class SQLiteEffectSelectBuilder<
|
export class SQLiteEffectSelectBuilder<
|
||||||
out TSelection extends SelectedFields | undefined,
|
TSelection extends SelectedFields | undefined,
|
||||||
out TRunResult,
|
TRunResult,
|
||||||
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
|
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
|
||||||
out TBuilderMode extends "db" | "qb" = "db",
|
TBuilderMode extends "db" | "qb" = "db",
|
||||||
> {
|
> {
|
||||||
static readonly [entityKind]: string = "SQLiteEffectSelectBuilder"
|
static readonly [entityKind]: string = "SQLiteEffectSelectBuilder"
|
||||||
|
|
||||||
|
|||||||
@@ -303,11 +303,10 @@ export class SQLiteEffectPreparedQuery<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Explicit variance prevents comparisons from recursively scanning the full Drizzle query-builder graph.
|
|
||||||
export abstract class SQLiteEffectSession<
|
export abstract class SQLiteEffectSession<
|
||||||
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
|
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
|
||||||
out TRunResult = unknown,
|
TRunResult = unknown,
|
||||||
out TRelations extends AnyRelations = EmptyRelations,
|
TRelations extends AnyRelations = EmptyRelations,
|
||||||
> {
|
> {
|
||||||
static readonly [entityKind]: string = "SQLiteEffectSession"
|
static readonly [entityKind]: string = "SQLiteEffectSession"
|
||||||
|
|
||||||
@@ -405,9 +404,9 @@ export abstract class SQLiteEffectSession<
|
|||||||
}
|
}
|
||||||
|
|
||||||
export abstract class SQLiteEffectTransaction<
|
export abstract class SQLiteEffectTransaction<
|
||||||
out TEffectHKT extends QueryEffectHKTBase,
|
TEffectHKT extends QueryEffectHKTBase,
|
||||||
out TRunResult,
|
TRunResult,
|
||||||
out TRelations extends AnyRelations = EmptyRelations,
|
TRelations extends AnyRelations = EmptyRelations,
|
||||||
> extends SQLiteEffectDatabase<TEffectHKT, TRunResult, TRelations> {
|
> extends SQLiteEffectDatabase<TEffectHKT, TRunResult, TRelations> {
|
||||||
static override readonly [entityKind]: string = "SQLiteEffectTransaction"
|
static override readonly [entityKind]: string = "SQLiteEffectTransaction"
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user