mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-07 09:39:46 -04:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e46277b8c | |||
| 10c4154eb9 | |||
| 80f854b70f | |||
| 1959800fce | |||
| d46d591adc | |||
| 5ca0914560 | |||
| 3af5c2b3b9 | |||
| 63fcf63324 | |||
| 41681a9915 | |||
| cc877b0a25 | |||
| e6c9b6bef7 | |||
| 2092350cfa | |||
| 5fb0d7c99c | |||
| a1b4843a33 | |||
| cc7827fe08 | |||
| 76e4d88d21 | |||
| 439ed66c7b | |||
| 047d434aa2 | |||
| d7651519f3 | |||
| 1eb3a43add | |||
| dcae95e2bb | |||
| 0cc507b8a9 | |||
| 727beae2d5 | |||
| cd64a17e37 | |||
| d35ca49c31 | |||
| 5cf97f1b96 | |||
| 65c6a71903 | |||
| a779027303 | |||
| 20fa444f31 | |||
| c66d84169a | |||
| 7dbe8c4c13 | |||
| 8864b01d0b | |||
| ec95b27308 |
@@ -178,6 +178,7 @@ export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompat
|
||||
toolSchema: Schema.optional(LanguageModelToolSchemaCompatibility),
|
||||
reasoningField: Schema.optional(Schema.String),
|
||||
maxTokensField: Schema.optional(LanguageModelMaxTokensFieldCompatibility),
|
||||
requireFinishReason: Schema.optional(Schema.Boolean),
|
||||
}) {}
|
||||
|
||||
export namespace LanguageModelCompatibility {
|
||||
|
||||
@@ -102,7 +102,7 @@ describe("llm constructors", () => {
|
||||
const updated = LanguageModel.update(base, {
|
||||
route: responsesRoute,
|
||||
defaults: { generation: { maxTokens: 20 } },
|
||||
compatibility: { toolSchema: "gemini" },
|
||||
compatibility: { toolSchema: "gemini", requireFinishReason: false },
|
||||
})
|
||||
const updatedInput = LanguageModel.input(updated)
|
||||
|
||||
@@ -110,7 +110,7 @@ describe("llm constructors", () => {
|
||||
expect(String(updated.id)).toBe("fake-model")
|
||||
expect(updated.route).toBe(responsesRoute)
|
||||
expect(updated.defaults?.generation).toEqual({ maxTokens: 20 })
|
||||
expect(updated.compatibility).toEqual({ toolSchema: "gemini" })
|
||||
expect(updated.compatibility).toEqual({ toolSchema: "gemini", requireFinishReason: false })
|
||||
expect(updatedInput.defaults).toBe(updated.defaults)
|
||||
expect(updatedInput.compatibility).toBe(updated.compatibility)
|
||||
expect(String(updatedInput.provider)).toBe("fake")
|
||||
|
||||
+8
-10
@@ -1,19 +1,18 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
const draftID = "draft_legacy_new_session"
|
||||
const directory = "C:/OpenCode/LegacyNewSession"
|
||||
const draftID = "draft_removed_layout_preference"
|
||||
const directory = "C:/OpenCode/RemovedLayoutPreference"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test("redirects a draft to the legacy new-session route", async ({ page }) => {
|
||||
test("ignores persisted old layout preferences when opening drafts", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: "proj_legacy_new_session",
|
||||
id: "proj_removed_layout_preference",
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "legacy-new-session",
|
||||
name: "removed-layout-preference",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
@@ -24,7 +23,6 @@ test("redirects a draft to the legacy new-session route", async ({ page }) => {
|
||||
await page.addInitScript(
|
||||
({ directory, draftID, server }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } }))
|
||||
localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" }))
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "draft", draftID, server, directory }]),
|
||||
@@ -35,7 +33,7 @@ test("redirects a draft to the legacy new-session route", async ({ page }) => {
|
||||
|
||||
await page.goto(`/new-session?draftId=${draftID}`)
|
||||
|
||||
await expect(page).toHaveURL(`/${base64Encode(directory)}/session`)
|
||||
await expect(page.locator("header[data-tauri-drag-region]")).toBeVisible()
|
||||
await expect(page.locator('[data-component="prompt-input"]')).toBeVisible()
|
||||
await expect(page).toHaveURL(`/new-session?draftId=${draftID}`)
|
||||
await expect(page.locator("body")).toHaveAttribute("data-new-layout", "")
|
||||
await expect(page.getByRole("textbox", { name: "Prompt" })).toBeVisible()
|
||||
})
|
||||
@@ -20,7 +20,7 @@
|
||||
<meta property="twitter:image" content="/social-share.png" />
|
||||
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
|
||||
</head>
|
||||
<body class="antialiased overscroll-none text-12-regular overflow-hidden bg-v2-background-bg-deep">
|
||||
<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">
|
||||
<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>
|
||||
<script src="/src/entry.tsx" type="module"></script>
|
||||
|
||||
+46
-161
@@ -14,14 +14,11 @@ import {
|
||||
Navigate,
|
||||
Route,
|
||||
Router,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
useParams,
|
||||
useSearchParams,
|
||||
} from "@solidjs/router"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||
import { Effect } from "effect"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import {
|
||||
type Component,
|
||||
createEffect,
|
||||
@@ -43,7 +40,7 @@ import { CommandProvider, useCommand, type CommandOption } from "@/context/comma
|
||||
import { CommentsProvider } from "@/context/comments"
|
||||
import { FileProvider } from "@/context/file"
|
||||
import { ServerSDKProvider } from "@/context/server-sdk"
|
||||
import { ServerSyncProvider, useServerSync } from "@/context/server-sync"
|
||||
import { ServerSyncProvider } from "@/context/server-sync"
|
||||
import { GlobalProvider, useGlobal } from "@/context/global"
|
||||
import { HighlightsProvider } from "@/context/highlights"
|
||||
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
||||
@@ -54,58 +51,36 @@ import { PermissionProvider } from "@/context/permission"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { PromptProvider } from "@/context/prompt"
|
||||
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
||||
import { SettingsProvider, useSettings } from "@/context/settings"
|
||||
import { SettingsProvider } from "@/context/settings"
|
||||
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
|
||||
import { SDKProvider, useSDK } from "@/context/sdk"
|
||||
import { SDKProvider } from "@/context/sdk"
|
||||
import { WslServersProvider } from "@/wsl/context"
|
||||
import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout"
|
||||
import LegacyLayout from "@/pages/layout"
|
||||
import NewLayout from "@/pages/layout-new"
|
||||
import { DirectoryDataProvider } from "@/pages/directory-layout"
|
||||
import Layout from "@/pages/layout"
|
||||
import { ErrorPage } from "./pages/error"
|
||||
import { useCheckServerHealth } from "./utils/server-health"
|
||||
import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
||||
import { createSessionLineage } from "@/pages/session/session-lineage"
|
||||
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
|
||||
import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
|
||||
import { NewHome } from "@/pages/home"
|
||||
import { LegacyHome } from "@/pages/home/legacy-home"
|
||||
import { TargetSessionRouteContent } from "@/pages/session"
|
||||
import { Home } from "@/pages/home"
|
||||
|
||||
const NewSession = lazy(() => import("@/pages/new-session"))
|
||||
|
||||
const SessionRoute = () => {
|
||||
const settings = useSettings()
|
||||
const DirectoryDraftRedirect = () => {
|
||||
const params = useParams()
|
||||
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
||||
const sdk = useSDK()
|
||||
const server = useServer()
|
||||
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(() => {
|
||||
if (!settings.general.newLayoutDesigns()) return
|
||||
if (params.id || search.draftId) return
|
||||
if (!tabs.ready() || !sdk().directory) return
|
||||
tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt)
|
||||
if (search.draftId || !tabs.ready()) return
|
||||
const directory = decode64(params.dir)
|
||||
if (!directory) return
|
||||
tabs.newDraft({ server: server.key, directory }, search.prompt)
|
||||
})
|
||||
|
||||
return (
|
||||
<SessionRouteErrorBoundary sessionID={params.id}>
|
||||
<SessionPage />
|
||||
</SessionRouteErrorBoundary>
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
function TargetServerRoute(props: ParentProps) {
|
||||
@@ -117,9 +92,7 @@ function TargetServerRoute(props: ParentProps) {
|
||||
})
|
||||
|
||||
return (
|
||||
// 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.
|
||||
// Owns the server-identity remount. Session changes must not remount this subtree.
|
||||
<Show when={requireServerKey(params.serverKey)} keyed>
|
||||
<ServerSDKProvider server={conn}>
|
||||
<ServerSyncProvider server={conn}>{props.children}</ServerSyncProvider>
|
||||
@@ -134,35 +107,6 @@ const TargetSessionRoute = () => (
|
||||
</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
|
||||
// server via ServerKey, then provide the server-scoped shell for that server.
|
||||
function SelectedServerProviders(props: ParentProps) {
|
||||
@@ -175,17 +119,8 @@ function SelectedServerProviders(props: ParentProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function LegacyServerLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
|
||||
return (
|
||||
<SelectedServerProviders>
|
||||
<LegacyServerScopedShell serverScoped={props.serverScoped}>{props.children}</LegacyServerScopedShell>
|
||||
</SelectedServerProviders>
|
||||
)
|
||||
}
|
||||
|
||||
function DraftRoute() {
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const settings = useSettings()
|
||||
const tabs = useTabs()
|
||||
return (
|
||||
<Show when={tabs.ready()}>
|
||||
@@ -194,14 +129,7 @@ function DraftRoute() {
|
||||
keyed
|
||||
fallback={<Navigate href="/" />}
|
||||
>
|
||||
{(draft) => (
|
||||
<Show
|
||||
when={settings.general.newLayoutDesigns()}
|
||||
fallback={<Navigate href={`/${base64Encode(draft.directory)}/session`} />}
|
||||
>
|
||||
<ResolvedDraftRoute draft={draft} />
|
||||
</Show>
|
||||
)}
|
||||
{(draft) => <ResolvedDraftRoute draft={draft} />}
|
||||
</Show>
|
||||
</Show>
|
||||
)
|
||||
@@ -237,10 +165,6 @@ function UiI18nBridge(props: ParentProps) {
|
||||
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
|
||||
}
|
||||
|
||||
function LayoutCompatibility(props: ParentProps) {
|
||||
return <>{props.children}</>
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__OPENCODE__?: {
|
||||
@@ -267,17 +191,11 @@ function QueryProvider(props: ParentProps) {
|
||||
}
|
||||
|
||||
function BodyDesignClass() {
|
||||
const settings = useSettings()
|
||||
|
||||
createRenderEffect(() => {
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const enabled = settings.general.newLayoutDesigns()
|
||||
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)
|
||||
document.body.toggleAttribute("data-new-layout", true)
|
||||
document.body.classList.remove("text-12-regular")
|
||||
document.body.classList.add("font-(family-name:--font-family-text)", "text-[13px]", "font-[440]")
|
||||
})
|
||||
|
||||
return null
|
||||
@@ -320,7 +238,6 @@ function DesktopCommands() {
|
||||
return null
|
||||
}
|
||||
|
||||
// Server-scoped providers shared by the legacy shell and the top-level new shell.
|
||||
type ServerScopedShellProps = ParentProps<{
|
||||
directory?: () => string | undefined
|
||||
serverScoped?: JSX.Element
|
||||
@@ -335,19 +252,11 @@ function ServerScopedProviders(props: ServerScopedShellProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function LegacyServerScopedShell(props: ServerScopedShellProps) {
|
||||
return (
|
||||
<ServerScopedProviders directory={props.directory} serverScoped={props.serverScoped}>
|
||||
<LegacyLayout>{props.children}</LegacyLayout>
|
||||
</ServerScopedProviders>
|
||||
)
|
||||
}
|
||||
|
||||
function NewAppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
|
||||
function AppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
|
||||
return (
|
||||
<SelectedServerProviders>
|
||||
<ServerScopedProviders serverScoped={props.serverScoped}>
|
||||
<NewLayout>{props.children}</NewLayout>
|
||||
<Layout>{props.children}</Layout>
|
||||
</ServerScopedProviders>
|
||||
</SelectedServerProviders>
|
||||
)
|
||||
@@ -536,7 +445,7 @@ export function AppInterface(props: {
|
||||
startup?: Promise<void>
|
||||
serverScoped?: JSX.Element
|
||||
}) {
|
||||
// The visual new layout lives in the router root so it remains mounted across
|
||||
// The visual layout lives in the router root so it remains mounted across
|
||||
// route changes. Draft and session routes override only their server-bound data
|
||||
// providers beneath it.
|
||||
const ServerShell = (shellProps: ParentProps) => (
|
||||
@@ -557,26 +466,22 @@ export function AppInterface(props: {
|
||||
<GlobalProvider>
|
||||
<SettingsProvider>
|
||||
<ConnectionGate disableHealthCheck={props.disableHealthCheck} startup={props.startup}>
|
||||
<Show when={useSettings().general.newLayoutDesigns().toString()} keyed>
|
||||
<Dynamic
|
||||
component={props.router ?? Router}
|
||||
root={(routerProps) => (
|
||||
<TabsProvider>
|
||||
<PermissionProvider>
|
||||
<NotificationProvider>
|
||||
<ServerShell>
|
||||
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
||||
<NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
|
||||
</Show>
|
||||
</ServerShell>
|
||||
</NotificationProvider>
|
||||
</PermissionProvider>
|
||||
</TabsProvider>
|
||||
)}
|
||||
>
|
||||
<Routes serverScoped={props.serverScoped} />
|
||||
</Dynamic>
|
||||
</Show>
|
||||
<Dynamic
|
||||
component={props.router ?? Router}
|
||||
root={(routerProps) => (
|
||||
<TabsProvider>
|
||||
<PermissionProvider>
|
||||
<NotificationProvider>
|
||||
<ServerShell>
|
||||
<AppLayout serverScoped={props.serverScoped}>{routerProps.children}</AppLayout>
|
||||
</ServerShell>
|
||||
</NotificationProvider>
|
||||
</PermissionProvider>
|
||||
</TabsProvider>
|
||||
)}
|
||||
>
|
||||
<Routes />
|
||||
</Dynamic>
|
||||
</ConnectionGate>
|
||||
</SettingsProvider>
|
||||
</GlobalProvider>
|
||||
@@ -584,40 +489,20 @@ export function AppInterface(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function Routes(props: { serverScoped?: JSX.Element }) {
|
||||
const settings = useSettings()
|
||||
|
||||
function Routes() {
|
||||
return (
|
||||
<>
|
||||
<Route
|
||||
component={(routeProps) => (
|
||||
<LegacyServerLayout serverScoped={props.serverScoped}>{routeProps.children}</LegacyServerLayout>
|
||||
)}
|
||||
>
|
||||
<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} />
|
||||
</Show>
|
||||
<Route path="/" component={Home} />
|
||||
<Route path="/:dir" component={DirectoryDraftRedirect} />
|
||||
<Route path="/:dir/session" component={DirectoryDraftRedirect} />
|
||||
<Route path="/:dir/session/:id" component={LegacySessionRedirect} />
|
||||
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
|
||||
<Route path="/new-session" component={DraftRoute} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function NewLayoutLegacySessionRedirect() {
|
||||
function LegacySessionRedirect() {
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const params = useParams<{ id: string }>()
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 187 KiB |
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 163 KiB |
@@ -1,170 +0,0 @@
|
||||
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,7 +10,6 @@ const statusLabels = {
|
||||
connected: "mcp.status.connected",
|
||||
failed: "mcp.status.failed",
|
||||
needs_auth: "mcp.status.needs_auth",
|
||||
needs_client_registration: "mcp.status.needs_client_registration",
|
||||
disabled: "mcp.status.disabled",
|
||||
} as const
|
||||
|
||||
@@ -57,7 +56,7 @@ export const DialogSelectMcp: Component = () => {
|
||||
}
|
||||
const error = () => {
|
||||
const s = mcpStatus()
|
||||
if (s?.status === "failed" || s?.status === "needs_client_registration") return s.error
|
||||
if (s?.status === "failed") return s.error
|
||||
}
|
||||
const enabled = () => status() === "connected"
|
||||
return (
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
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,793 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
||||
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
@@ -27,7 +26,6 @@ import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
||||
import { Link } from "../link"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import { SettingsRowV2 } from "./parts/row"
|
||||
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
|
||||
import "./settings-v2.css"
|
||||
|
||||
let demoSoundState = {
|
||||
@@ -87,7 +85,6 @@ export const SettingsGeneralV2: Component<{
|
||||
const language = useLanguage()
|
||||
const permission = usePermission()
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const settings = useSettings()
|
||||
const serverSync = useServerSync()
|
||||
const mobile = createMediaQuery("(max-width: 767px)")
|
||||
@@ -225,31 +222,6 @@ 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 = () => (
|
||||
<div class="settings-v2-section">
|
||||
<SettingsListV2>
|
||||
@@ -689,14 +661,6 @@ export const SettingsGeneralV2: Component<{
|
||||
</div>
|
||||
|
||||
<div class="settings-v2-tab-body">
|
||||
<Show when={settings.general.layoutTransitionAvailable()}>
|
||||
<InterfaceSection />
|
||||
</Show>
|
||||
|
||||
<Show when={settings.general.newInterfaceNoticeVisible()}>
|
||||
<InterfaceNoticeSection />
|
||||
</Show>
|
||||
|
||||
<GeneralSection />
|
||||
|
||||
<AppearanceSection />
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
// @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>
|
||||
),
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -426,8 +426,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
"bg-icon-success-base": status() === "connected",
|
||||
"bg-icon-critical-base": status() === "failed",
|
||||
"bg-border-weak-base": status() === "disabled",
|
||||
"bg-icon-warning-base":
|
||||
status() === "needs_auth" || status() === "needs_client_registration",
|
||||
"bg-icon-warning-base": status() === "needs_auth",
|
||||
}}
|
||||
/>
|
||||
<span class="flex flex-col min-w-0 flex-1">
|
||||
|
||||
@@ -35,7 +35,6 @@ describe("hasNonBlockingServiceIssue", () => {
|
||||
test("detects MCP failures that do not block chatting", () => {
|
||||
expect(hasNonBlockingServiceIssue({ mcp: ["failed"], 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)
|
||||
})
|
||||
|
||||
@@ -48,7 +47,6 @@ describe("hasNonBlockingServiceIssue", () => {
|
||||
describe("hasServiceNeedingAttention", () => {
|
||||
test("detects MCP states that need user attention", () => {
|
||||
expect(hasServiceNeedingAttention({ mcp: ["needs_auth"] })).toBe(true)
|
||||
expect(hasServiceNeedingAttention({ mcp: ["needs_client_registration"] })).toBe(true)
|
||||
})
|
||||
|
||||
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"
|
||||
|
||||
export function hasServiceNeedingAttention(input: { mcp: Array<McpServer["status"]["status"]> }) {
|
||||
return input.mcp.some((status) => status === "needs_auth" || status === "needs_client_registration")
|
||||
return input.mcp.some((status) => status === "needs_auth")
|
||||
}
|
||||
|
||||
export function hasNonBlockingServiceIssue(input: {
|
||||
|
||||
@@ -13,7 +13,6 @@ export async function toggleMcp(input: {
|
||||
needs_auth: input.authenticate,
|
||||
disabled: input.connect,
|
||||
failed: input.connect,
|
||||
needs_client_registration: input.connect,
|
||||
}[input.status]()
|
||||
await input.refresh()
|
||||
}
|
||||
|
||||
@@ -153,4 +153,88 @@ describe("v2 session reducer", () => {
|
||||
|
||||
expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] })
|
||||
})
|
||||
|
||||
test("removes cancelled input from the pending promotion fold", () => {
|
||||
const reducer = createV2SessionReducer()
|
||||
reducer.reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_admitted",
|
||||
type: "session.input.admitted",
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
inputID: "msg_user",
|
||||
input: { type: "user", delivery: "queue", data: { text: "cancel me" } },
|
||||
},
|
||||
}),
|
||||
)
|
||||
reducer.reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_cancelled",
|
||||
type: "session.input.cancelled",
|
||||
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||
}),
|
||||
)
|
||||
const result = reducer.reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_promoted",
|
||||
type: "session.input.promoted",
|
||||
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({ missing: "msg_user" })
|
||||
})
|
||||
|
||||
test("keeps steered input available to the promotion fold", () => {
|
||||
const reducer = createV2SessionReducer()
|
||||
reducer.reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_admitted",
|
||||
type: "session.input.admitted",
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
inputID: "msg_user",
|
||||
input: { type: "user", delivery: "queue", data: { text: "steer me" } },
|
||||
},
|
||||
}),
|
||||
)
|
||||
reducer.reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_steered",
|
||||
type: "session.input.steered",
|
||||
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||
}),
|
||||
)
|
||||
reducer.reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_queued",
|
||||
type: "session.input.queued",
|
||||
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||
}),
|
||||
)
|
||||
|
||||
const result = reducer.reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_promoted",
|
||||
type: "session.input.promoted",
|
||||
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result?.messages).toMatchObject([{ id: "msg_user", type: "user", text: "steer me" }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,6 +29,9 @@ export function createV2SessionReducer() {
|
||||
case "session.input.admitted":
|
||||
pending.set(key(sessionID, event.data.inputID), event.data.input)
|
||||
return result([...source])
|
||||
case "session.input.cancelled":
|
||||
pending.delete(key(sessionID, event.data.inputID))
|
||||
return
|
||||
case "session.input.promoted": {
|
||||
const input = pending.get(key(sessionID, event.data.inputID))
|
||||
pending.delete(key(sessionID, event.data.inputID))
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
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,8 +1,7 @@
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import { createEffect, createMemo } from "solid-js"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { persisted } from "@/utils/persist"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
|
||||
export interface NotificationSettings {
|
||||
agent: boolean
|
||||
@@ -34,10 +33,6 @@ export interface Settings {
|
||||
editToolPartsExpanded: boolean
|
||||
showCustomAgents: boolean
|
||||
mobileTitlebarPosition: "top" | "bottom"
|
||||
newLayoutDesigns?: boolean
|
||||
layoutTransitionEligible?: boolean
|
||||
newInterfaceNoticeDismissed?: boolean
|
||||
shouldDisplayTabsToast?: boolean
|
||||
}
|
||||
appearance: {
|
||||
fontSize: number
|
||||
@@ -56,75 +51,6 @@ export interface Settings {
|
||||
export const monoDefault = "System Mono"
|
||||
export const sansDefault = "System Sans"
|
||||
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 =
|
||||
'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'
|
||||
@@ -223,17 +149,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
name: "Settings",
|
||||
gate: false,
|
||||
init: () => {
|
||||
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 [store, setStore, , ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
|
||||
const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
|
||||
const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
|
||||
const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
|
||||
@@ -241,93 +157,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
() => store.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(() => {
|
||||
if (typeof document === "undefined") return
|
||||
const root = document.documentElement
|
||||
@@ -413,34 +242,13 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setMobileTitlebarPosition(value: "top" | "bottom") {
|
||||
setStore("general", "mobileTitlebarPosition", value)
|
||||
},
|
||||
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)
|
||||
},
|
||||
newLayoutDesigns: () => true,
|
||||
},
|
||||
visibility: {
|
||||
fileTree: visible(showFileTree),
|
||||
search: visible(showSearch),
|
||||
status: visible(showStatus),
|
||||
customAgents: visible(showCustomAgents),
|
||||
fileTree: showFileTree,
|
||||
search: showSearch,
|
||||
status: showStatus,
|
||||
customAgents: showCustomAgents,
|
||||
},
|
||||
appearance: {
|
||||
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 { HomeSessions } from "./home/home-sessions"
|
||||
|
||||
export function NewHome() {
|
||||
export function Home() {
|
||||
const home = createHomeController()
|
||||
const projects = createHomeProjectsController(home)
|
||||
const sessions = createHomeSessionsController(home)
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
+30
-2396
File diff suppressed because it is too large
Load Diff
@@ -1,126 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -1,340 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,377 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,489 +0,0 @@
|
||||
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 { DockShell } from "@opencode-ai/ui/dock-surface"
|
||||
import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock"
|
||||
import { SettingsProvider, useSettings } from "@/context/settings"
|
||||
import { SettingsProvider } from "@/context/settings"
|
||||
|
||||
export default {
|
||||
title: "Composer/Revert Dock",
|
||||
@@ -21,9 +21,6 @@ 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 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
|
||||
- \`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.`,
|
||||
@@ -53,11 +50,9 @@ const btn = (accent?: boolean) =>
|
||||
}) as const
|
||||
|
||||
function Stage(props: { count: number }) {
|
||||
const settings = useSettings()
|
||||
const seed = () => messages.slice(0, props.count).map((text, index) => ({ id: `rolled-${index}`, text }))
|
||||
const [store, setStore] = createStore({ items: seed() })
|
||||
|
||||
const v2 = () => settings.general.newLayoutDesigns()
|
||||
const reset = () => setStore("items", seed())
|
||||
const restore = (id: string) =>
|
||||
setStore(
|
||||
@@ -71,22 +66,15 @@ function Stage(props: { count: number }) {
|
||||
<button style={btn()} onClick={reset}>
|
||||
Reset ({props.count})
|
||||
</button>
|
||||
<button style={btn(v2())} onClick={() => settings.general.setNewLayoutDesigns(!v2())}>
|
||||
Layout: {v2() ? "v2" : "v1"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Reproduce the real composer stack: dock + card overlapping the dock's bottom by lift() = 18px */}
|
||||
<div style={{ display: "flex", "flex-direction": "column" }}>
|
||||
<SessionRevertDock items={store.items} onRestore={restore} />
|
||||
<DockShell
|
||||
data-dock-border-underlay={v2() ? "v2" : "legacy"}
|
||||
data-dock-border-underlay="v2"
|
||||
style={{ position: "relative", "z-index": 70, "margin-top": "-18px" }}
|
||||
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(),
|
||||
}}
|
||||
class="min-h-24 w-full rounded-[12px] bg-v2-background-bg-base px-4 py-3 text-[13px] text-v2-text-text-faint"
|
||||
>
|
||||
Ask anything...
|
||||
</DockShell>
|
||||
|
||||
@@ -140,6 +140,32 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
||||
description: "List all available models",
|
||||
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", {
|
||||
description: "Start the minimal interactive interface",
|
||||
params: {
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
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,7 +34,6 @@ function icon(status: McpServer["status"]) {
|
||||
case "needs_auth":
|
||||
return "⚠"
|
||||
case "failed":
|
||||
case "needs_client_registration":
|
||||
return "✗"
|
||||
default:
|
||||
return "○"
|
||||
@@ -45,8 +44,6 @@ function describe(status: McpServer["status"]) {
|
||||
switch (status.status) {
|
||||
case "needs_auth":
|
||||
return "needs authentication"
|
||||
case "needs_client_registration":
|
||||
return `needs client registration: ${status.error}`
|
||||
case "failed":
|
||||
return `failed: ${status.error}`
|
||||
default:
|
||||
|
||||
@@ -37,6 +37,8 @@ const Handlers = Runtime.handlers(Commands, {
|
||||
list: () => import("./commands/handlers/plugin/list"),
|
||||
},
|
||||
models: () => import("./commands/handlers/models"),
|
||||
export: () => import("./commands/handlers/export"),
|
||||
import: () => import("./commands/handlers/import"),
|
||||
mini: () => import("./commands/handlers/mini"),
|
||||
run: () => import("./commands/handlers/run"),
|
||||
pair: () => import("./commands/handlers/pair"),
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
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,42 +127,54 @@ export type Endpoint5_1Input = {
|
||||
export type Endpoint5_1Output = Session.Info
|
||||
export type SessionCreateOperation<E = never> = (input?: Endpoint5_1Input) => Effect.Effect<Endpoint5_1Output, E>
|
||||
|
||||
export type Endpoint5_2Output = { readonly [x: Session.ID]: { readonly type: "running" } }
|
||||
export type SessionActiveOperation<E = never> = () => Effect.Effect<Endpoint5_2Output, E>
|
||||
export type Endpoint5_2Input = {
|
||||
readonly info: Session.Info
|
||||
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 }
|
||||
export type Endpoint5_3Output = Session.Info
|
||||
export type SessionGetOperation<E = never> = (input: Endpoint5_3Input) => Effect.Effect<Endpoint5_3Output, E>
|
||||
export type Endpoint5_3Input = { readonly sessionID: Session.ID; readonly sanitize?: boolean | undefined }
|
||||
export type Endpoint5_3Output = { readonly info: Session.Info; readonly messages: ReadonlyArray<SessionMessage.Info> }
|
||||
export type SessionExportOperation<E = never> = (input: Endpoint5_3Input) => Effect.Effect<Endpoint5_3Output, E>
|
||||
|
||||
export type Endpoint5_4Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_4Output = void
|
||||
export type SessionRemoveOperation<E = never> = (input: Endpoint5_4Input) => Effect.Effect<Endpoint5_4Output, E>
|
||||
export type Endpoint5_4Output = { readonly [x: Session.ID]: { readonly type: "running" } }
|
||||
export type SessionActiveOperation<E = never> = () => Effect.Effect<Endpoint5_4Output, E>
|
||||
|
||||
export type Endpoint5_5Input = { readonly sessionID: Session.ID; readonly boundary: Session.ForkRequestBoundary }
|
||||
export type Endpoint5_5Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_5Output = Session.Info
|
||||
export type SessionForkOperation<E = never> = (input: Endpoint5_5Input) => Effect.Effect<Endpoint5_5Output, E>
|
||||
export type SessionGetOperation<E = never> = (input: Endpoint5_5Input) => Effect.Effect<Endpoint5_5Output, E>
|
||||
|
||||
export type Endpoint5_6Input = { readonly sessionID: Session.ID; readonly agent: Agent.ID }
|
||||
export type Endpoint5_6Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_6Output = void
|
||||
export type SessionSwitchAgentOperation<E = never> = (input: Endpoint5_6Input) => Effect.Effect<Endpoint5_6Output, E>
|
||||
export type SessionRemoveOperation<E = never> = (input: Endpoint5_6Input) => Effect.Effect<Endpoint5_6Output, E>
|
||||
|
||||
export type Endpoint5_7Input = { readonly sessionID: Session.ID; readonly model: Model.Ref }
|
||||
export type Endpoint5_7Output = void
|
||||
export type SessionSwitchModelOperation<E = never> = (input: Endpoint5_7Input) => Effect.Effect<Endpoint5_7Output, E>
|
||||
export type Endpoint5_7Input = { readonly sessionID: Session.ID; readonly boundary: Session.ForkRequestBoundary }
|
||||
export type Endpoint5_7Output = Session.Info
|
||||
export type SessionForkOperation<E = never> = (input: Endpoint5_7Input) => Effect.Effect<Endpoint5_7Output, E>
|
||||
|
||||
export type Endpoint5_8Input = { readonly sessionID: Session.ID; readonly title: string }
|
||||
export type Endpoint5_8Input = { readonly sessionID: Session.ID; readonly agent: Agent.ID }
|
||||
export type Endpoint5_8Output = void
|
||||
export type SessionRenameOperation<E = never> = (input: Endpoint5_8Input) => Effect.Effect<Endpoint5_8Output, E>
|
||||
export type SessionSwitchAgentOperation<E = never> = (input: Endpoint5_8Input) => Effect.Effect<Endpoint5_8Output, E>
|
||||
|
||||
export type Endpoint5_9Input = {
|
||||
export type Endpoint5_9Input = { readonly sessionID: Session.ID; readonly model: Model.Ref }
|
||||
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 directory: AbsolutePath
|
||||
readonly workspaceID?: Workspace.ID | undefined
|
||||
}
|
||||
export type Endpoint5_9Output = void
|
||||
export type SessionMoveOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
|
||||
export type Endpoint5_11Output = void
|
||||
export type SessionMoveOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
|
||||
|
||||
export type Endpoint5_10Input = {
|
||||
export type Endpoint5_12Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly id?: SessionMessage.ID | undefined
|
||||
readonly text: string
|
||||
@@ -172,10 +184,10 @@ export type Endpoint5_10Input = {
|
||||
readonly delivery?: "steer" | "queue" | undefined
|
||||
readonly resume?: boolean | undefined
|
||||
}
|
||||
export type Endpoint5_10Output = SessionPending.User
|
||||
export type SessionPromptOperation<E = never> = (input: Endpoint5_10Input) => Effect.Effect<Endpoint5_10Output, E>
|
||||
export type Endpoint5_12Output = SessionPending.User
|
||||
export type SessionPromptOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
|
||||
|
||||
export type Endpoint5_11Input = {
|
||||
export type Endpoint5_13Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly id?: SessionMessage.ID | undefined
|
||||
readonly command: string
|
||||
@@ -187,19 +199,19 @@ export type Endpoint5_11Input = {
|
||||
readonly delivery?: "steer" | "queue" | undefined
|
||||
readonly resume?: boolean | undefined
|
||||
}
|
||||
export type Endpoint5_11Output = SessionPending.User
|
||||
export type SessionCommandOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
|
||||
export type Endpoint5_13Output = SessionPending.User
|
||||
export type SessionCommandOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
|
||||
|
||||
export type Endpoint5_12Input = {
|
||||
export type Endpoint5_14Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly id?: SessionMessage.ID | undefined
|
||||
readonly skill: Skill.ID
|
||||
readonly resume?: boolean | undefined
|
||||
}
|
||||
export type Endpoint5_12Output = void
|
||||
export type SessionSkillOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
|
||||
export type Endpoint5_14Output = void
|
||||
export type SessionSkillOperation<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, E>
|
||||
|
||||
export type Endpoint5_13Input = {
|
||||
export type Endpoint5_15Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly id?: SessionMessage.ID | undefined
|
||||
readonly text: string
|
||||
@@ -208,81 +220,95 @@ export type Endpoint5_13Input = {
|
||||
readonly delivery?: "steer" | "queue" | undefined
|
||||
readonly resume?: boolean | undefined
|
||||
}
|
||||
export type Endpoint5_13Output = SessionPending.Synthetic
|
||||
export type SessionSyntheticOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
|
||||
export type Endpoint5_15Output = SessionPending.Synthetic
|
||||
export type SessionSyntheticOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
|
||||
|
||||
export type Endpoint5_14Input = {
|
||||
export type Endpoint5_16Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly id?: Event.ID | undefined
|
||||
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 SessionWaitOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
|
||||
export type SessionShellOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
|
||||
|
||||
export type Endpoint5_17Input = {
|
||||
export type Endpoint5_17Input = { readonly sessionID: Session.ID; readonly id?: SessionMessage.ID | undefined }
|
||||
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 messageID: SessionMessage.ID
|
||||
readonly files?: boolean | undefined
|
||||
}
|
||||
export type Endpoint5_17Output = Session.Revert
|
||||
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_19Output = Session.Revert
|
||||
export type SessionRevertStageOperation<E = never> = (input: Endpoint5_19Input) => Effect.Effect<Endpoint5_19Output, E>
|
||||
|
||||
export type Endpoint5_20Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_20Output = ReadonlyArray<SessionMessage.Info>
|
||||
export type SessionContextOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
|
||||
export type Endpoint5_20Output = void
|
||||
export type SessionRevertClearOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
|
||||
|
||||
export type Endpoint5_21Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_21Output = ReadonlyArray<SessionPending.Info>
|
||||
export type SessionPendingListOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
|
||||
export type Endpoint5_21Output = void
|
||||
export type SessionRevertCommitOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
|
||||
|
||||
export type Endpoint5_22Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_22Output = ReadonlyArray<InstructionEntry.Info>
|
||||
export type SessionInstructionsEntryListOperation<E = never> = (
|
||||
input: Endpoint5_22Input,
|
||||
) => Effect.Effect<Endpoint5_22Output, E>
|
||||
export type Endpoint5_22Output = ReadonlyArray<SessionMessage.Info>
|
||||
export type SessionContextOperation<E = never> = (input: Endpoint5_22Input) => Effect.Effect<Endpoint5_22Output, E>
|
||||
|
||||
export type Endpoint5_23Input = {
|
||||
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; readonly inputID: SessionMessage.ID }
|
||||
export type Endpoint5_24Output = void
|
||||
export type SessionPendingCancelOperation<E = never> = (
|
||||
input: Endpoint5_24Input,
|
||||
) => Effect.Effect<Endpoint5_24Output, E>
|
||||
|
||||
export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
|
||||
export type Endpoint5_25Output = void
|
||||
export type SessionPendingSteerOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
|
||||
|
||||
export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
|
||||
export type Endpoint5_26Output = void
|
||||
export type SessionPendingQueueOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
|
||||
|
||||
export type Endpoint5_27Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_27Output = ReadonlyArray<InstructionEntry.Info>
|
||||
export type SessionInstructionsEntryListOperation<E = never> = (
|
||||
input: Endpoint5_27Input,
|
||||
) => Effect.Effect<Endpoint5_27Output, E>
|
||||
|
||||
export type Endpoint5_28Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly key: InstructionEntry.Key
|
||||
readonly value: Schema.Json
|
||||
}
|
||||
export type Endpoint5_23Output = void
|
||||
export type Endpoint5_28Output = void
|
||||
export type SessionInstructionsEntryPutOperation<E = never> = (
|
||||
input: Endpoint5_23Input,
|
||||
) => Effect.Effect<Endpoint5_23Output, E>
|
||||
input: Endpoint5_28Input,
|
||||
) => Effect.Effect<Endpoint5_28Output, E>
|
||||
|
||||
export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
|
||||
export type Endpoint5_24Output = void
|
||||
export type Endpoint5_29Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
|
||||
export type Endpoint5_29Output = void
|
||||
export type SessionInstructionsEntryRemoveOperation<E = never> = (
|
||||
input: Endpoint5_24Input,
|
||||
) => Effect.Effect<Endpoint5_24Output, E>
|
||||
input: Endpoint5_29Input,
|
||||
) => Effect.Effect<Endpoint5_29Output, E>
|
||||
|
||||
export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly prompt: string }
|
||||
export type Endpoint5_25Output = { readonly text: string }
|
||||
export type SessionGenerateOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
|
||||
export type Endpoint5_30Input = { readonly sessionID: Session.ID; readonly prompt: string }
|
||||
export type Endpoint5_30Output = { readonly text: string }
|
||||
export type SessionGenerateOperation<E = never> = (input: Endpoint5_30Input) => Effect.Effect<Endpoint5_30Output, E>
|
||||
|
||||
export type Endpoint5_26Input = {
|
||||
export type Endpoint5_31Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly after?: Event.Seq | undefined
|
||||
readonly follow?: boolean | undefined
|
||||
}
|
||||
export type Endpoint5_26Output =
|
||||
export type Endpoint5_31Output =
|
||||
| (
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
@@ -392,6 +418,33 @@ export type Endpoint5_26Output =
|
||||
readonly input: SessionPending.Message
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: DateTime.Utc
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.input.cancelled"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: DateTime.Utc
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.input.steered"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: DateTime.Utc
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.input.queued"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: DateTime.Utc
|
||||
@@ -850,23 +903,25 @@ export type Endpoint5_26Output =
|
||||
}
|
||||
)
|
||||
| EventLog.Synced
|
||||
export type SessionLogOperation<E = never> = (input: Endpoint5_26Input) => Stream.Stream<Endpoint5_26Output, E>
|
||||
export type SessionLogOperation<E = never> = (input: Endpoint5_31Input) => Stream.Stream<Endpoint5_31Output, E>
|
||||
|
||||
export type Endpoint5_27Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_27Output = void
|
||||
export type SessionInterruptOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
|
||||
export type Endpoint5_32Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_32Output = void
|
||||
export type SessionInterruptOperation<E = never> = (input: Endpoint5_32Input) => Effect.Effect<Endpoint5_32Output, E>
|
||||
|
||||
export type Endpoint5_28Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_28Output = void
|
||||
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_28Input) => Effect.Effect<Endpoint5_28Output, E>
|
||||
export type Endpoint5_33Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_33Output = void
|
||||
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_33Input) => Effect.Effect<Endpoint5_33Output, E>
|
||||
|
||||
export type Endpoint5_29Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
|
||||
export type Endpoint5_29Output = SessionMessage.Info
|
||||
export type SessionMessageOperation<E = never> = (input: Endpoint5_29Input) => Effect.Effect<Endpoint5_29Output, E>
|
||||
export type Endpoint5_34Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
|
||||
export type Endpoint5_34Output = SessionMessage.Info
|
||||
export type SessionMessageOperation<E = never> = (input: Endpoint5_34Input) => Effect.Effect<Endpoint5_34Output, E>
|
||||
|
||||
export interface SessionApi<E = never> {
|
||||
readonly list: SessionListOperation<E>
|
||||
readonly create: SessionCreateOperation<E>
|
||||
readonly import: SessionImportOperation<E>
|
||||
readonly export: SessionExportOperation<E>
|
||||
readonly active: SessionActiveOperation<E>
|
||||
readonly get: SessionGetOperation<E>
|
||||
readonly remove: SessionRemoveOperation<E>
|
||||
@@ -888,7 +943,12 @@ export interface SessionApi<E = never> {
|
||||
readonly commit: SessionRevertCommitOperation<E>
|
||||
}
|
||||
readonly context: SessionContextOperation<E>
|
||||
readonly pending: { readonly list: SessionPendingListOperation<E> }
|
||||
readonly pending: {
|
||||
readonly list: SessionPendingListOperation<E>
|
||||
readonly cancel: SessionPendingCancelOperation<E>
|
||||
readonly steer: SessionPendingSteerOperation<E>
|
||||
readonly queue: SessionPendingQueueOperation<E>
|
||||
}
|
||||
readonly instructions: {
|
||||
readonly entry: {
|
||||
readonly list: SessionInstructionsEntryListOperation<E>
|
||||
|
||||
@@ -21,10 +21,10 @@ import type {
|
||||
Endpoint5_0Output,
|
||||
Endpoint5_1Input,
|
||||
Endpoint5_1Output,
|
||||
Endpoint5_2Input,
|
||||
Endpoint5_2Output,
|
||||
Endpoint5_3Input,
|
||||
Endpoint5_3Output,
|
||||
Endpoint5_4Input,
|
||||
Endpoint5_4Output,
|
||||
Endpoint5_5Input,
|
||||
Endpoint5_5Output,
|
||||
@@ -76,6 +76,16 @@ import type {
|
||||
Endpoint5_28Output,
|
||||
Endpoint5_29Input,
|
||||
Endpoint5_29Output,
|
||||
Endpoint5_30Input,
|
||||
Endpoint5_30Output,
|
||||
Endpoint5_31Input,
|
||||
Endpoint5_31Output,
|
||||
Endpoint5_32Input,
|
||||
Endpoint5_32Output,
|
||||
Endpoint5_33Input,
|
||||
Endpoint5_33Output,
|
||||
Endpoint5_34Input,
|
||||
Endpoint5_34Output,
|
||||
Endpoint6_0Input,
|
||||
Endpoint6_0Output,
|
||||
Endpoint7_0Input,
|
||||
@@ -317,9 +327,11 @@ const Endpoint5_1 = (raw: RawClient["server.session"]) => (input?: Endpoint5_1In
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_2 = (raw: RawClient["server.session"]) => () =>
|
||||
const Endpoint5_2 = (raw: RawClient["server.session"]) => (input: Endpoint5_2Input) =>
|
||||
preserveEffect<Endpoint5_2Output>()(
|
||||
raw["session.active"]({}).pipe(
|
||||
raw["session.import"]({
|
||||
payload: { info: input["info"], messages: input["messages"], location: input["location"] },
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
@@ -327,20 +339,23 @@ const Endpoint5_2 = (raw: RawClient["server.session"]) => () =>
|
||||
|
||||
const Endpoint5_3 = (raw: RawClient["server.session"]) => (input: Endpoint5_3Input) =>
|
||||
preserveEffect<Endpoint5_3Output>()(
|
||||
raw["session.get"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
raw["session.export"]({ params: { sessionID: input["sessionID"] }, query: { sanitize: input["sanitize"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_4 = (raw: RawClient["server.session"]) => (input: Endpoint5_4Input) =>
|
||||
const Endpoint5_4 = (raw: RawClient["server.session"]) => () =>
|
||||
preserveEffect<Endpoint5_4Output>()(
|
||||
raw["session.remove"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
raw["session.active"]({}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_5 = (raw: RawClient["server.session"]) => (input: Endpoint5_5Input) =>
|
||||
preserveEffect<Endpoint5_5Output>()(
|
||||
raw["session.fork"]({ params: { sessionID: input["sessionID"] }, payload: { boundary: input["boundary"] } }).pipe(
|
||||
raw["session.get"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
@@ -348,35 +363,48 @@ const Endpoint5_5 = (raw: RawClient["server.session"]) => (input: Endpoint5_5Inp
|
||||
|
||||
const Endpoint5_6 = (raw: RawClient["server.session"]) => (input: Endpoint5_6Input) =>
|
||||
preserveEffect<Endpoint5_6Output>()(
|
||||
raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
raw["session.remove"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_7 = (raw: RawClient["server.session"]) => (input: Endpoint5_7Input) =>
|
||||
preserveEffect<Endpoint5_7Output>()(
|
||||
raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe(
|
||||
raw["session.fork"]({ params: { sessionID: input["sessionID"] }, payload: { boundary: input["boundary"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_8 = (raw: RawClient["server.session"]) => (input: Endpoint5_8Input) =>
|
||||
preserveEffect<Endpoint5_8Output>()(
|
||||
raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe(
|
||||
raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_9 = (raw: RawClient["server.session"]) => (input: Endpoint5_9Input) =>
|
||||
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"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { directory: input["directory"], workspaceID: input["workspaceID"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10Input) =>
|
||||
preserveEffect<Endpoint5_10Output>()(
|
||||
const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) =>
|
||||
preserveEffect<Endpoint5_12Output>()(
|
||||
raw["session.prompt"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: {
|
||||
@@ -394,8 +422,8 @@ const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10I
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11Input) =>
|
||||
preserveEffect<Endpoint5_11Output>()(
|
||||
const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) =>
|
||||
preserveEffect<Endpoint5_13Output>()(
|
||||
raw["session.command"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: {
|
||||
@@ -415,16 +443,16 @@ const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11I
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) =>
|
||||
preserveEffect<Endpoint5_12Output>()(
|
||||
const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) =>
|
||||
preserveEffect<Endpoint5_14Output>()(
|
||||
raw["session.skill"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { id: input["id"], skill: input["skill"], resume: input["resume"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) =>
|
||||
preserveEffect<Endpoint5_13Output>()(
|
||||
const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) =>
|
||||
preserveEffect<Endpoint5_15Output>()(
|
||||
raw["session.synthetic"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: {
|
||||
@@ -441,29 +469,29 @@ const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13I
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) =>
|
||||
preserveEffect<Endpoint5_14Output>()(
|
||||
const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) =>
|
||||
preserveEffect<Endpoint5_16Output>()(
|
||||
raw["session.shell"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { id: input["id"], command: input["command"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) =>
|
||||
preserveEffect<Endpoint5_15Output>()(
|
||||
const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
|
||||
preserveEffect<Endpoint5_17Output>()(
|
||||
raw["session.compact"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) =>
|
||||
preserveEffect<Endpoint5_16Output>()(
|
||||
const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) =>
|
||||
preserveEffect<Endpoint5_18Output>()(
|
||||
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
|
||||
preserveEffect<Endpoint5_17Output>()(
|
||||
const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) =>
|
||||
preserveEffect<Endpoint5_19Output>()(
|
||||
raw["session.revert.stage"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { messageID: input["messageID"], files: input["files"] },
|
||||
@@ -473,35 +501,19 @@ const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17I
|
||||
),
|
||||
)
|
||||
|
||||
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) =>
|
||||
preserveEffect<Endpoint5_20Output>()(
|
||||
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_21 = (raw: RawClient["server.session"]) => (input: Endpoint5_21Input) =>
|
||||
preserveEffect<Endpoint5_21Output>()(
|
||||
raw["session.pending.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22Input) =>
|
||||
preserveEffect<Endpoint5_22Output>()(
|
||||
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
@@ -509,29 +521,66 @@ const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22I
|
||||
|
||||
const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) =>
|
||||
preserveEffect<Endpoint5_23Output>()(
|
||||
raw["session.instructions.entry.put"]({
|
||||
params: { sessionID: input["sessionID"], key: input["key"] },
|
||||
payload: { value: input["value"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
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.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
|
||||
raw["session.pending.cancel"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
|
||||
preserveEffect<Endpoint5_25Output>()(
|
||||
raw["session.pending.steer"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
|
||||
preserveEffect<Endpoint5_26Output>()(
|
||||
raw["session.pending.queue"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
|
||||
preserveEffect<Endpoint5_27Output>()(
|
||||
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
|
||||
preserveEffect<Endpoint5_28Output>()(
|
||||
raw["session.instructions.entry.put"]({
|
||||
params: { sessionID: input["sessionID"], key: input["key"] },
|
||||
payload: { value: input["value"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
|
||||
preserveEffect<Endpoint5_29Output>()(
|
||||
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
|
||||
preserveEffect<Endpoint5_30Output>()(
|
||||
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
|
||||
preserveStream<Endpoint5_26Output>()(
|
||||
const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) =>
|
||||
preserveStream<Endpoint5_31Output>()(
|
||||
Stream.unwrap(
|
||||
raw["session.log"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
@@ -543,18 +592,18 @@ const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26I
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
|
||||
preserveEffect<Endpoint5_27Output>()(
|
||||
const Endpoint5_32 = (raw: RawClient["server.session"]) => (input: Endpoint5_32Input) =>
|
||||
preserveEffect<Endpoint5_32Output>()(
|
||||
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
|
||||
preserveEffect<Endpoint5_28Output>()(
|
||||
const Endpoint5_33 = (raw: RawClient["server.session"]) => (input: Endpoint5_33Input) =>
|
||||
preserveEffect<Endpoint5_33Output>()(
|
||||
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
|
||||
preserveEffect<Endpoint5_29Output>()(
|
||||
const Endpoint5_34 = (raw: RawClient["server.session"]) => (input: Endpoint5_34Input) =>
|
||||
preserveEffect<Endpoint5_34Output>()(
|
||||
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
@@ -564,30 +613,32 @@ const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29I
|
||||
const adaptGroup5 = (raw: RawClient["server.session"]) => ({
|
||||
list: Endpoint5_0(raw),
|
||||
create: Endpoint5_1(raw),
|
||||
active: Endpoint5_2(raw),
|
||||
get: Endpoint5_3(raw),
|
||||
remove: Endpoint5_4(raw),
|
||||
fork: Endpoint5_5(raw),
|
||||
switchAgent: Endpoint5_6(raw),
|
||||
switchModel: Endpoint5_7(raw),
|
||||
rename: Endpoint5_8(raw),
|
||||
move: Endpoint5_9(raw),
|
||||
prompt: Endpoint5_10(raw),
|
||||
command: Endpoint5_11(raw),
|
||||
skill: Endpoint5_12(raw),
|
||||
synthetic: Endpoint5_13(raw),
|
||||
shell: Endpoint5_14(raw),
|
||||
compact: Endpoint5_15(raw),
|
||||
wait: Endpoint5_16(raw),
|
||||
revert: { stage: Endpoint5_17(raw), clear: Endpoint5_18(raw), commit: Endpoint5_19(raw) },
|
||||
context: Endpoint5_20(raw),
|
||||
pending: { list: Endpoint5_21(raw) },
|
||||
instructions: { entry: { list: Endpoint5_22(raw), put: Endpoint5_23(raw), remove: Endpoint5_24(raw) } },
|
||||
generate: Endpoint5_25(raw),
|
||||
log: Endpoint5_26(raw),
|
||||
interrupt: Endpoint5_27(raw),
|
||||
background: Endpoint5_28(raw),
|
||||
message: Endpoint5_29(raw),
|
||||
import: Endpoint5_2(raw),
|
||||
export: Endpoint5_3(raw),
|
||||
active: Endpoint5_4(raw),
|
||||
get: Endpoint5_5(raw),
|
||||
remove: Endpoint5_6(raw),
|
||||
fork: Endpoint5_7(raw),
|
||||
switchAgent: Endpoint5_8(raw),
|
||||
switchModel: Endpoint5_9(raw),
|
||||
rename: Endpoint5_10(raw),
|
||||
move: Endpoint5_11(raw),
|
||||
prompt: Endpoint5_12(raw),
|
||||
command: Endpoint5_13(raw),
|
||||
skill: Endpoint5_14(raw),
|
||||
synthetic: Endpoint5_15(raw),
|
||||
shell: Endpoint5_16(raw),
|
||||
compact: Endpoint5_17(raw),
|
||||
wait: Endpoint5_18(raw),
|
||||
revert: { stage: Endpoint5_19(raw), clear: Endpoint5_20(raw), commit: Endpoint5_21(raw) },
|
||||
context: Endpoint5_22(raw),
|
||||
pending: { list: Endpoint5_23(raw), cancel: Endpoint5_24(raw), steer: Endpoint5_25(raw), queue: Endpoint5_26(raw) },
|
||||
instructions: { entry: { list: Endpoint5_27(raw), put: Endpoint5_28(raw), remove: Endpoint5_29(raw) } },
|
||||
generate: Endpoint5_30(raw),
|
||||
log: Endpoint5_31(raw),
|
||||
interrupt: Endpoint5_32(raw),
|
||||
background: Endpoint5_33(raw),
|
||||
message: Endpoint5_34(raw),
|
||||
})
|
||||
|
||||
const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>
|
||||
|
||||
@@ -15,6 +15,10 @@ import type {
|
||||
SessionListOutput,
|
||||
SessionCreateInput,
|
||||
SessionCreateOutput,
|
||||
SessionImportInput,
|
||||
SessionImportOutput,
|
||||
SessionExportInput,
|
||||
SessionExportOutput,
|
||||
SessionActiveOutput,
|
||||
SessionGetInput,
|
||||
SessionGetOutput,
|
||||
@@ -54,6 +58,12 @@ import type {
|
||||
SessionContextOutput,
|
||||
SessionPendingListInput,
|
||||
SessionPendingListOutput,
|
||||
SessionPendingCancelInput,
|
||||
SessionPendingCancelOutput,
|
||||
SessionPendingSteerInput,
|
||||
SessionPendingSteerOutput,
|
||||
SessionPendingQueueInput,
|
||||
SessionPendingQueueOutput,
|
||||
SessionInstructionsEntryListInput,
|
||||
SessionInstructionsEntryListOutput,
|
||||
SessionInstructionsEntryPutInput,
|
||||
@@ -478,6 +488,30 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
).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) =>
|
||||
request<{ readonly data: SessionActiveOutput }>(
|
||||
{
|
||||
@@ -738,6 +772,39 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
cancel: (input: SessionPendingCancelInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionPendingCancelOutput>(
|
||||
{
|
||||
method: "DELETE",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [409, 404, 401, 400],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
steer: (input: SessionPendingSteerInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionPendingSteerOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}/steer`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [409, 404, 401, 400],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
queue: (input: SessionPendingQueueInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionPendingQueueOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}/queue`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [409, 404, 401, 400],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
instructions: {
|
||||
entry: {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,7 +19,7 @@ test("effect entrypoint exposes canonical Schema contracts", () => {
|
||||
test("generated Effect API names canonical and composed outputs", async () => {
|
||||
const source = await Bun.file(new URL("../src/effect/api/api.ts", import.meta.url)).text()
|
||||
|
||||
expect(source).toContain("export type Endpoint5_3Output = Session.Info")
|
||||
expect(source).toContain("export type Endpoint5_5Output = Session.Info")
|
||||
expect(source).toContain("export type Endpoint19_0Output = OpenCodeEvent")
|
||||
expect(source).not.toContain("HttpApiClient.ForApi")
|
||||
})
|
||||
|
||||
@@ -32,6 +32,7 @@ test("exposes every standard HTTP API group", () => {
|
||||
"projectCopy",
|
||||
"vcs",
|
||||
"debug",
|
||||
"migration",
|
||||
"websearch",
|
||||
"config",
|
||||
])
|
||||
@@ -356,6 +357,28 @@ test("session.pending.list uses the public HTTP contract", async () => {
|
||||
expect(requests).toEqual([{ method: "GET", url: "http://localhost:3000/api/session/ses_test/pending" }])
|
||||
})
|
||||
|
||||
test("session.pending mutations use the public HTTP contract", async () => {
|
||||
const requests: Array<{ method: string; url: string }> = []
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input, init) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
requests.push({ method: request.method, url: request.url })
|
||||
return new Response(null, { status: 204 })
|
||||
},
|
||||
})
|
||||
|
||||
await client.session.pending.cancel({ sessionID: "ses_test", inputID: "msg_cancel" })
|
||||
await client.session.pending.steer({ sessionID: "ses_test", inputID: "msg_steer" })
|
||||
await client.session.pending.queue({ sessionID: "ses_test", inputID: "msg_queue" })
|
||||
|
||||
expect(requests).toEqual([
|
||||
{ method: "DELETE", url: "http://localhost:3000/api/session/ses_test/pending/msg_cancel" },
|
||||
{ method: "POST", url: "http://localhost:3000/api/session/ses_test/pending/msg_steer/steer" },
|
||||
{ method: "POST", url: "http://localhost:3000/api/session/ses_test/pending/msg_queue/queue" },
|
||||
])
|
||||
})
|
||||
|
||||
test("event.subscribe exposes the Promise event stream wire projection", async () => {
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
|
||||
+34
-16
@@ -24,8 +24,7 @@ import { Global } from "@opencode-ai/util/global"
|
||||
import { Location } from "./location"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { ConfigVariable } from "./config/variable"
|
||||
import { ConfigV1 } from "./v1/config/config"
|
||||
import { ConfigMigrateV1 } from "./v1/config/migrate"
|
||||
import { ConfigNormalize } from "./config/normalize"
|
||||
import { WellKnown } from "./wellknown"
|
||||
|
||||
export function latest<K extends keyof Info>(entries: readonly Entry[], key: K): Info[K] | undefined {
|
||||
@@ -93,24 +92,43 @@ export const layer = (options?: Options) => Layer.effect(
|
||||
const reloadLock = Semaphore.makeUnsafe(1)
|
||||
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
||||
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
|
||||
const decodeV1Info = Schema.decodeUnknownOption(ConfigV1.Info, decodeOptions)
|
||||
|
||||
const parseInfo = (text: string) => {
|
||||
const parseInfo = Effect.fn("Config.parseInfo")(function* (text: string, source: string) {
|
||||
const errors: ParseError[] = []
|
||||
const input: unknown = parse(text, errors, { allowTrailingComma: true })
|
||||
if (errors.length) return
|
||||
return Option.getOrUndefined(
|
||||
ConfigMigrateV1.isV1(input)
|
||||
? decodeV1Info(input).pipe(Option.map(ConfigMigrateV1.migrate), Option.flatMap(decodeInfo))
|
||||
: decodeInfo(input),
|
||||
if (errors.length) {
|
||||
yield* Effect.logWarning("configuration normalization diagnostic", {
|
||||
source,
|
||||
path: "$",
|
||||
kind: "invalid",
|
||||
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 text = yield* fs.readFileStringSafe(filepath)
|
||||
if (!text) return
|
||||
if (text === undefined) return
|
||||
const substituted = yield* ConfigVariable.substitute({ type: "path", path: filepath, text })
|
||||
const info = parseInfo(substituted)
|
||||
const info = yield* parseInfo(substituted, filepath)
|
||||
if (!info) return
|
||||
return new Document({ type: "document", path: filepath, info })
|
||||
})
|
||||
@@ -141,7 +159,7 @@ export const layer = (options?: Options) => Layer.effect(
|
||||
text: JSON.stringify(config),
|
||||
env: variables,
|
||||
}).pipe(
|
||||
Effect.map(parseInfo),
|
||||
Effect.flatMap((text) => parseInfo(text, entry.origin)),
|
||||
Effect.map((info) => (info ? new Document({ type: "document", info }) : undefined)),
|
||||
),
|
||||
).pipe(Effect.map((documents) => documents.filter((document) => document !== undefined)))
|
||||
@@ -218,14 +236,14 @@ export const layer = (options?: Options) => Layer.effect(
|
||||
Effect.orDie,
|
||||
)
|
||||
: []
|
||||
const content = options?.content
|
||||
const content = options?.content !== undefined
|
||||
? yield* ConfigVariable.substitute({
|
||||
type: "virtual",
|
||||
source: "OPENCODE_CONFIG_CONTENT",
|
||||
dir: location.directory,
|
||||
text: options.content,
|
||||
}).pipe(
|
||||
Effect.map(parseInfo),
|
||||
Effect.flatMap((text) => parseInfo(text, "OPENCODE_CONFIG_CONTENT")),
|
||||
Effect.map((info) => (info ? [new Document({ type: "document", info })] : [])),
|
||||
Effect.orDie,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,813 @@
|
||||
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,11 +161,14 @@ function isPathAction(action: string): action is PathAction {
|
||||
}
|
||||
|
||||
function expandHome(resource: string, home: string) {
|
||||
if (resource.startsWith("~/")) return home + resource.slice(1)
|
||||
if (resource === "~") return home
|
||||
if (resource === "$HOME") return home
|
||||
if (resource.startsWith("$HOME/")) return home + resource.slice(5)
|
||||
if (resource.startsWith("$HOME\\")) return home + resource.slice(5)
|
||||
const relative = resource.startsWith("~/")
|
||||
? resource.slice(2)
|
||||
: 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
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Bom } from "@opencode-ai/util/bom"
|
||||
|
||||
export interface Target {
|
||||
readonly canonical: string
|
||||
readonly absolute: string
|
||||
readonly resource: string
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ export interface Interface {
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/FileMutation") {}
|
||||
|
||||
/**
|
||||
* Serialize file changes by canonical target. Conditional writes compare and
|
||||
* Serialize file changes by absolute target. Conditional writes compare and
|
||||
* write under the same process-local lock so cooperating OpenCode mutations do
|
||||
* not overwrite changes made from the same stale content.
|
||||
*/
|
||||
@@ -49,11 +49,11 @@ const layer = Layer.effect(
|
||||
const withTargetLock =
|
||||
(target: Target) =>
|
||||
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
locks.withLock(target.canonical)(Effect.uninterruptible(effect))
|
||||
locks.withLock(target.absolute)(Effect.uninterruptible(effect))
|
||||
|
||||
const writeResult = (target: Target, existed: boolean): WriteResult => ({
|
||||
operation: "write",
|
||||
target: target.canonical,
|
||||
target: target.absolute,
|
||||
resource: target.resource,
|
||||
existed,
|
||||
})
|
||||
@@ -61,8 +61,8 @@ const layer = Layer.effect(
|
||||
const write = Effect.fn("FileMutation.write")((input: WriteInput) =>
|
||||
withTargetLock(input.target)(
|
||||
Effect.gen(function* () {
|
||||
const existed = yield* fs.exists(input.target.canonical)
|
||||
yield* fs.writeWithDirs(input.target.canonical, input.content)
|
||||
const existed = yield* fs.exists(input.target.absolute)
|
||||
yield* fs.writeWithDirs(input.target.absolute, input.content)
|
||||
return writeResult(input.target, existed)
|
||||
}),
|
||||
),
|
||||
@@ -73,10 +73,10 @@ const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const next = Bom.split(input.content)
|
||||
const current = yield* fs
|
||||
.readFile(input.target.canonical)
|
||||
.readFile(input.target.absolute)
|
||||
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
|
||||
yield* fs.writeWithDirs(
|
||||
input.target.canonical,
|
||||
input.target.absolute,
|
||||
Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom),
|
||||
)
|
||||
return writeResult(input.target, current !== undefined)
|
||||
|
||||
@@ -23,14 +23,9 @@ export const ResolveInput = Schema.Struct({
|
||||
})
|
||||
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 {
|
||||
readonly action: "external_directory"
|
||||
/** Canonical existing directory used as the external approval boundary. */
|
||||
/** Lexical directory used as the external approval boundary. */
|
||||
readonly directory: string
|
||||
/** `external_directory` permission resource. */
|
||||
readonly resource: string
|
||||
@@ -44,9 +39,9 @@ export const externalDirectoryPermission = (input: ExternalDirectoryAuthorizatio
|
||||
})
|
||||
|
||||
export interface Target {
|
||||
/** Canonical existing path, or missing path below a canonical directory. */
|
||||
readonly canonical: string
|
||||
/** Permission resource: Location-relative for internal paths, canonical for external paths. */
|
||||
/** Absolute lexical path. */
|
||||
readonly absolute: string
|
||||
/** Permission resource: Location-relative for internal paths, absolute for external paths. */
|
||||
readonly resource: string
|
||||
readonly externalDirectory?: ExternalDirectoryAuthorization
|
||||
}
|
||||
@@ -57,25 +52,11 @@ export interface Interface {
|
||||
* from the Location. Paths outside it require separate `external_directory`
|
||||
* approval. This does not approve the mutation.
|
||||
*/
|
||||
readonly resolve: (input: ResolveInput) => Effect.Effect<Target, PathError | FSUtil.Error>
|
||||
readonly resolve: (input: ResolveInput) => Effect.Effect<Target, FSUtil.Error>
|
||||
}
|
||||
|
||||
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 layer = Layer.effect(
|
||||
@@ -84,65 +65,33 @@ const layer = Layer.effect(
|
||||
const fs = yield* FSUtil.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 absolute = path.resolve(location.directory, input.path)
|
||||
// External access follows the requested path boundary. Symlinks reached through an
|
||||
// internal path intentionally retain internal permission semantics after canonicalization.
|
||||
const lexicallyInternal = FSUtil.contains(location.directory, absolute)
|
||||
|
||||
const resolved = yield* resolvePath(absolute)
|
||||
const external = !lexicallyInternal
|
||||
const resource = external ? slash(resolved.canonical) : slash(path.relative(location.directory, absolute) || ".")
|
||||
const externalDirectory =
|
||||
input.kind === "directory" && resolved.type === "Directory" ? resolved.canonical : resolved.directory
|
||||
if (FSUtil.contains(location.directory, absolute)) {
|
||||
return {
|
||||
absolute,
|
||||
resource: slash(path.relative(location.directory, absolute) || "."),
|
||||
} satisfies Target
|
||||
}
|
||||
const type =
|
||||
input.kind === "directory"
|
||||
? "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, "*"))
|
||||
return {
|
||||
canonical: resolved.canonical,
|
||||
resource,
|
||||
externalDirectory: external
|
||||
? {
|
||||
action: "external_directory",
|
||||
directory: externalDirectory,
|
||||
resource: externalResource,
|
||||
save: slash(
|
||||
path.join((yield* Project.root(fs, AbsolutePath.make(externalDirectory))) ?? externalDirectory, "*"),
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
absolute,
|
||||
resource: slash(absolute),
|
||||
externalDirectory: {
|
||||
action: "external_directory",
|
||||
directory: externalDirectory,
|
||||
resource: externalResource,
|
||||
save: slash(
|
||||
path.join((yield* Project.root(fs, AbsolutePath.make(externalDirectory))) ?? externalDirectory, "*"),
|
||||
),
|
||||
},
|
||||
} satisfies Target
|
||||
})
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ import { SessionGenerateNode } from "./session/generate-node"
|
||||
import { McpTool } from "./tool/mcp"
|
||||
import { ReadToolFileSystem } from "./tool/read-filesystem"
|
||||
import { Tool } from "./tool"
|
||||
import { ToolOutput } from "./tool-output"
|
||||
import { Vcs } from "./vcs"
|
||||
|
||||
export { LocationServiceMap } from "./location-service-map"
|
||||
@@ -78,6 +79,7 @@ const locationServiceNodes = [
|
||||
MCP.node,
|
||||
Permission.node,
|
||||
Tool.node,
|
||||
ToolOutput.node,
|
||||
Image.node,
|
||||
SkillInstructions.node,
|
||||
ReferenceInstructions.node,
|
||||
|
||||
@@ -221,7 +221,7 @@ export const OpenAIPlugin = define({
|
||||
}
|
||||
draft.cost = []
|
||||
// Match Codex CLI so context consumption and subscription usage stay consistent between clients.
|
||||
draft.limit = { ...draft.limit, context: 272_000, input: 272_000 }
|
||||
draft.limit = { ...draft.limit, context: 400_000, input: 272_000 }
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
export * as WebSearchFirecrawl from "./firecrawl"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect, Option, Schema, Scope } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { App } from "../../app"
|
||||
import { WebSearchMcp } from "./mcp"
|
||||
|
||||
export const endpoint = "https://mcp.firecrawl.dev/v2/mcp"
|
||||
|
||||
const McpInput = Schema.Struct({
|
||||
query: Schema.String,
|
||||
limit: Schema.Number.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
const McpOutput = Schema.Struct({
|
||||
content: Schema.Array(Schema.Struct({ type: Schema.Literal("text"), text: Schema.String })),
|
||||
})
|
||||
|
||||
const SearchResponse = Schema.fromJsonString(
|
||||
Schema.Struct({
|
||||
success: Schema.Boolean,
|
||||
data: Schema.Struct({
|
||||
web: Schema.Array(
|
||||
Schema.Struct({
|
||||
url: Schema.String,
|
||||
title: Schema.NullOr(Schema.String).pipe(Schema.optional),
|
||||
description: Schema.NullOr(Schema.String).pipe(Schema.optional),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const decodeSearchResponse = Schema.decodeUnknownOption(SearchResponse)
|
||||
|
||||
export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
|
||||
id: "opencode.websearch.firecrawl",
|
||||
effect: Effect.fn("WebSearchFirecrawl.Plugin")(function* (ctx) {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
yield* ctx.integration.transform((draft) => {
|
||||
draft.update("firecrawl", (integration) => (integration.name = "Firecrawl"))
|
||||
draft.method.update({
|
||||
integrationID: "firecrawl",
|
||||
method: { type: "key", label: "API key (optional)" },
|
||||
})
|
||||
draft.method.update({
|
||||
integrationID: "firecrawl",
|
||||
method: { type: "env", names: ["FIRECRAWL_API_KEY"] },
|
||||
})
|
||||
})
|
||||
yield* ctx.websearch.transform((draft) => {
|
||||
draft.add({
|
||||
id: "firecrawl",
|
||||
name: "Firecrawl",
|
||||
execute: (input) =>
|
||||
Effect.gen(function* () {
|
||||
const connection = yield* ctx.integration.connection.active("firecrawl")
|
||||
const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined
|
||||
const result = yield* WebSearchMcp.call(
|
||||
http,
|
||||
endpoint,
|
||||
"firecrawl_search",
|
||||
{ input: McpInput, output: McpOutput },
|
||||
{ query: input.query, limit: 8 },
|
||||
{
|
||||
"User-Agent": App.useragent(ctx.app),
|
||||
...(credential?.type === "key" ? { Authorization: `Bearer ${credential.key}` } : {}),
|
||||
},
|
||||
)
|
||||
const content = result?.content.find((item) => item.text)
|
||||
const response = content ? Option.getOrUndefined(decodeSearchResponse(content.text)) : undefined
|
||||
return (
|
||||
response?.data.web.map((item) => ({
|
||||
url: item.url,
|
||||
...(item.title ? { title: item.title } : {}),
|
||||
...(item.description ? { content: item.description } : {}),
|
||||
time: {},
|
||||
})) ?? []
|
||||
)
|
||||
}),
|
||||
})
|
||||
})
|
||||
}),
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { WebSearchExa } from "./exa"
|
||||
import { WebSearchFirecrawl } from "./firecrawl"
|
||||
import { WebSearchParallel } from "./parallel"
|
||||
|
||||
export const WebSearchPlugins = [WebSearchExa.Plugin, WebSearchParallel.Plugin] as const
|
||||
export const WebSearchPlugins = [WebSearchExa.Plugin, WebSearchFirecrawl.Plugin, WebSearchParallel.Plugin] as const
|
||||
|
||||
@@ -133,6 +133,14 @@ export class CompactionConflictError extends Schema.TaggedErrorClass<CompactionC
|
||||
export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.BusyError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
}) {}
|
||||
export class PendingInputConflictError extends Schema.TaggedErrorClass<PendingInputConflictError>()(
|
||||
"Session.PendingInputConflictError",
|
||||
{
|
||||
sessionID: SessionSchema.ID,
|
||||
inputID: SessionMessage.ID,
|
||||
},
|
||||
) {}
|
||||
type PendingInputRef = { readonly sessionID: SessionSchema.ID; readonly inputID: SessionMessage.ID }
|
||||
export class SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundError>()("Session.SkillNotFoundError", {
|
||||
skill: Skill.ID,
|
||||
}) {}
|
||||
@@ -181,6 +189,9 @@ export interface Interface {
|
||||
* unhandled compaction barriers.
|
||||
*/
|
||||
readonly pending: (sessionID: SessionSchema.ID) => Effect.Effect<SessionPending.Info[], NotFoundError>
|
||||
readonly cancelPending: (input: PendingInputRef) => Effect.Effect<void, NotFoundError | PendingInputConflictError>
|
||||
readonly steerPending: (input: PendingInputRef) => Effect.Effect<void, NotFoundError | PendingInputConflictError>
|
||||
readonly queuePending: (input: PendingInputRef) => Effect.Effect<void, NotFoundError | PendingInputConflictError>
|
||||
/**
|
||||
* Durable, ordered session log read. Replays durable session bus after
|
||||
* the exclusive `after` cursor, emits a `Synced` marker at the captured
|
||||
@@ -318,6 +329,29 @@ const layer = Layer.effect(
|
||||
),
|
||||
)
|
||||
|
||||
const mutatePending = (
|
||||
input: PendingInputRef,
|
||||
mutation: (
|
||||
bus: Bus.Interface,
|
||||
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID },
|
||||
) => Effect.Effect<unknown>,
|
||||
wake = false,
|
||||
) =>
|
||||
Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
yield* mutation(bus, { sessionID: input.sessionID, id: input.inputID }).pipe(
|
||||
Effect.catchDefect((defect) =>
|
||||
defect instanceof SessionPending.LifecycleConflict
|
||||
? result
|
||||
.get(input.sessionID)
|
||||
.pipe(Effect.andThen(Effect.fail(new PendingInputConflictError(input))))
|
||||
: Effect.die(defect),
|
||||
),
|
||||
)
|
||||
if (wake) yield* execution.wake(input.sessionID)
|
||||
}),
|
||||
)
|
||||
|
||||
const result = Service.of({
|
||||
create: Effect.fn("Session.create")(function* (input) {
|
||||
const sessionID = input.id ?? SessionSchema.ID.create()
|
||||
@@ -507,6 +541,9 @@ const layer = Layer.effect(
|
||||
yield* result.get(sessionID)
|
||||
return yield* SessionPending.list(db, sessionID)
|
||||
}),
|
||||
cancelPending: Effect.fn("Session.cancelPending")((input) => mutatePending(input, SessionPending.cancel)),
|
||||
steerPending: Effect.fn("Session.steerPending")((input) => mutatePending(input, SessionPending.steer, true)),
|
||||
queuePending: Effect.fn("Session.queuePending")((input) => mutatePending(input, SessionPending.queue)),
|
||||
log: (input) =>
|
||||
Stream.unwrap(
|
||||
result
|
||||
|
||||
@@ -20,7 +20,7 @@ import type { Info } from "../model"
|
||||
import { SessionUsage } from "./usage"
|
||||
|
||||
const DEFAULT_BUFFER = 20_000
|
||||
const DEFAULT_KEEP_TOKENS = 8_000
|
||||
const DEFAULT_KEEP_TOKENS = 15_000
|
||||
const OUTPUT_TOKEN_MAX = 32_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.
|
||||
|
||||
@@ -90,6 +90,9 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
"session.forked": () => Effect.void,
|
||||
"session.input.promoted": () => Effect.void,
|
||||
"session.input.admitted": () => Effect.void,
|
||||
"session.input.cancelled": () => Effect.void,
|
||||
"session.input.steered": () => Effect.void,
|
||||
"session.input.queued": () => Effect.void,
|
||||
"session.execution.started": () => Effect.void,
|
||||
"session.execution.succeeded": () => clearCurrentRetry,
|
||||
"session.execution.failed": () => clearCurrentRetry,
|
||||
|
||||
@@ -312,6 +312,63 @@ export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(funct
|
||||
return stored
|
||||
})
|
||||
|
||||
export const projectCancelled = Effect.fn("SessionPending.projectCancelled")(function* (
|
||||
db: DatabaseService,
|
||||
input: {
|
||||
readonly id: SessionMessage.ID
|
||||
readonly sessionID: SessionSchema.ID
|
||||
},
|
||||
) {
|
||||
const deleted = yield* db
|
||||
.delete(SessionPendingTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionPendingTable.id, input.id),
|
||||
eq(SessionPendingTable.session_id, input.sessionID),
|
||||
or(eq(SessionPendingTable.delivery, "queue"), eq(SessionPendingTable.delivery, "steer")),
|
||||
),
|
||||
)
|
||||
.returning({ id: SessionPendingTable.id })
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!deleted) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||
})
|
||||
|
||||
const projectDelivery = Effect.fn("SessionPending.projectDelivery")(function* (
|
||||
db: DatabaseService,
|
||||
input: {
|
||||
readonly id: SessionMessage.ID
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly from: Delivery
|
||||
readonly to: Delivery
|
||||
},
|
||||
) {
|
||||
const updated = yield* db
|
||||
.update(SessionPendingTable)
|
||||
.set({ delivery: input.to })
|
||||
.where(
|
||||
and(
|
||||
eq(SessionPendingTable.id, input.id),
|
||||
eq(SessionPendingTable.session_id, input.sessionID),
|
||||
eq(SessionPendingTable.delivery, input.from),
|
||||
),
|
||||
)
|
||||
.returning({ id: SessionPendingTable.id })
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!updated) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||
})
|
||||
|
||||
export const projectSteered = Effect.fn("SessionPending.projectSteered")(
|
||||
(db: DatabaseService, input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID }) =>
|
||||
projectDelivery(db, { ...input, from: "queue", to: "steer" }),
|
||||
)
|
||||
|
||||
export const projectQueued = Effect.fn("SessionPending.projectQueued")(
|
||||
(db: DatabaseService, input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID }) =>
|
||||
projectDelivery(db, { ...input, from: "steer", to: "queue" }),
|
||||
)
|
||||
|
||||
export const settleCompaction = Effect.fn("SessionPending.settleCompaction")(function* (
|
||||
db: DatabaseService,
|
||||
input: { readonly sessionID: SessionSchema.ID },
|
||||
@@ -389,6 +446,42 @@ export const equivalent = (
|
||||
return false
|
||||
}
|
||||
|
||||
export const cancel = Effect.fn("SessionPending.cancel")(function* (
|
||||
bus: Bus.Interface,
|
||||
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID },
|
||||
) {
|
||||
yield* inboxLocks.withLock(input.sessionID)(
|
||||
bus.publish(SessionEvent.InputCancelled, {
|
||||
sessionID: input.sessionID,
|
||||
inputID: input.id,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
export const steer = Effect.fn("SessionPending.steer")(function* (
|
||||
bus: Bus.Interface,
|
||||
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID },
|
||||
) {
|
||||
yield* inboxLocks.withLock(input.sessionID)(
|
||||
bus.publish(SessionEvent.InputSteered, {
|
||||
sessionID: input.sessionID,
|
||||
inputID: input.id,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
export const queue = Effect.fn("SessionPending.queue")(function* (
|
||||
bus: Bus.Interface,
|
||||
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID },
|
||||
) {
|
||||
yield* inboxLocks.withLock(input.sessionID)(
|
||||
bus.publish(SessionEvent.InputQueued, {
|
||||
sessionID: input.sessionID,
|
||||
inputID: input.id,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const publish = Effect.fn("SessionPending.publish")(function* (
|
||||
db: DatabaseService,
|
||||
bus: Bus.Interface,
|
||||
|
||||
@@ -485,6 +485,24 @@ const layer = Layer.effectDiscard(
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
yield* bus.project(SessionEvent.InputCancelled, (event) =>
|
||||
SessionPending.projectCancelled(db, {
|
||||
id: event.data.inputID,
|
||||
sessionID: event.data.sessionID,
|
||||
}),
|
||||
)
|
||||
yield* bus.project(SessionEvent.InputSteered, (event) =>
|
||||
SessionPending.projectSteered(db, {
|
||||
id: event.data.inputID,
|
||||
sessionID: event.data.sessionID,
|
||||
}),
|
||||
)
|
||||
yield* bus.project(SessionEvent.InputQueued, (event) =>
|
||||
SessionPending.projectQueued(db, {
|
||||
id: event.data.inputID,
|
||||
sessionID: event.data.sessionID,
|
||||
}),
|
||||
)
|
||||
yield* bus.project(SessionEvent.Compaction.Admitted, (event) =>
|
||||
Effect.gen(function* () {
|
||||
if (event.durable === undefined)
|
||||
|
||||
@@ -32,6 +32,7 @@ import { StepFailedError } from "../error"
|
||||
import { toSessionError } from "../to-session-error"
|
||||
import { SessionRunnerRetry } from "./retry"
|
||||
import { SessionUsage } from "../usage"
|
||||
import { ToolOutput } from "../../tool-output"
|
||||
|
||||
/** How one model call ended: settled, awaiting a scheduled retry, or restarted by compaction. */
|
||||
type CallOutcome = Data.TaggedEnum<{
|
||||
@@ -107,6 +108,7 @@ const layer = Layer.effect(
|
||||
const db = (yield* Database.Service).db
|
||||
const compaction = yield* SessionCompaction.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.
|
||||
// The in-flight set coalesces overlapping steps while title presence records success durably.
|
||||
const titlesRunning = new Set<SessionSchema.ID>()
|
||||
@@ -334,6 +336,7 @@ const layer = Layer.effect(
|
||||
).pipe(
|
||||
// The fiber owns its call: it publishes its own completion, masked so a
|
||||
// finished execution always reaches its durable settlement.
|
||||
Effect.flatMap(toolOutput.truncate),
|
||||
Effect.flatMap((outcome) => publisher.toolExecution(event.id, event.name, outcome)),
|
||||
Effect.catchTag("Tool.Error", (error) =>
|
||||
publisher.failTool(event.id, toSessionError(error)).pipe(Effect.asVoid),
|
||||
@@ -562,6 +565,7 @@ export const node = makeLocationNode({
|
||||
SessionCompaction.node,
|
||||
SessionTitle.node,
|
||||
Snapshot.node,
|
||||
ToolOutput.node,
|
||||
Database.node,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
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),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
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,124 +116,120 @@ export const Plugin = {
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((draft) =>
|
||||
draft.add(
|
||||
({
|
||||
name,
|
||||
options: { codemode: false, permission: "edit" },
|
||||
description:
|
||||
"Edit the contents of a file by finding and replacing exact text. When editing text from Read output, preserve the exact indentation (tabs or spaces) and omit the line-number prefix, such as `1: `. Never include the prefix in oldString or newString. The edit fails if oldString is not found. By default, oldString must identify a UNIQUE location. Multiple matches FAIL unless replaceAll is true. Add more surrounding context to disambiguate, or set replaceAll to true to replace every occurrence. Use replaceAll when the change should apply to every occurrence, such as renaming a variable.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) => {
|
||||
return Effect.gen(function* () {
|
||||
const permissionSource = {
|
||||
type: "tool" as const,
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
}
|
||||
if (input.oldString === input.newString) {
|
||||
return yield* new ToolFailure({
|
||||
message: "No changes to apply: oldString and newString are identical.",
|
||||
})
|
||||
}
|
||||
if (input.oldString === "") {
|
||||
return yield* new ToolFailure({
|
||||
message: "oldString must not be empty. Use write to create or overwrite a file.",
|
||||
})
|
||||
}
|
||||
draft.add({
|
||||
name,
|
||||
options: { codemode: false, permission: "edit" },
|
||||
description:
|
||||
"Edit the contents of a file by finding and replacing exact text. When editing text from Read output, preserve the exact indentation (tabs or spaces) and omit the line-number prefix, such as `1: `. Never include the prefix in oldString or newString. The edit fails if oldString is not found. By default, oldString must identify a UNIQUE location. Multiple matches FAIL unless replaceAll is true. Add more surrounding context to disambiguate, or set replaceAll to true to replace every occurrence. Use replaceAll when the change should apply to every occurrence, such as renaming a variable.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) => {
|
||||
return Effect.gen(function* () {
|
||||
const permissionSource = {
|
||||
type: "tool" as const,
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
}
|
||||
if (input.oldString === input.newString) {
|
||||
return yield* new ToolFailure({
|
||||
message: "No changes to apply: oldString and newString are identical.",
|
||||
})
|
||||
}
|
||||
if (input.oldString === "") {
|
||||
return yield* new ToolFailure({
|
||||
message: "oldString must not be empty. Use write to create or overwrite a file.",
|
||||
})
|
||||
}
|
||||
|
||||
const target = yield* mutation.resolve({ path: input.path, kind: "file" })
|
||||
const external = target.externalDirectory
|
||||
if (external) {
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: permissionSource,
|
||||
})
|
||||
}
|
||||
const target = yield* mutation.resolve({ path: input.path, kind: "file" })
|
||||
const external = target.externalDirectory
|
||||
if (external) {
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: permissionSource,
|
||||
})
|
||||
}
|
||||
|
||||
const info = yield* fs.stat(target.canonical).pipe(
|
||||
Effect.catchReason("PlatformError", "NotFound", () =>
|
||||
Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })),
|
||||
),
|
||||
)
|
||||
if (info.type === "Directory") {
|
||||
return yield* new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` })
|
||||
}
|
||||
const original = yield* Bom.readFile(fs, target.canonical)
|
||||
const source = original.text
|
||||
const ending = source.includes(crlf) ? crlf : "\n"
|
||||
const oldString = input.oldString.replaceAll(crlf, "\n").replaceAll("\n", ending)
|
||||
const newString = input.newString.replaceAll(crlf, "\n").replaceAll("\n", ending)
|
||||
const exact = findOccurrences(source, oldString)
|
||||
// These one-to-one mappings preserve offsets into the original source.
|
||||
const unicode =
|
||||
exact.length > 0 ? [] : findOccurrences(normalizeForMatch(source), normalizeForMatch(oldString))
|
||||
const trailing =
|
||||
exact.length > 0 || unicode.length > 0
|
||||
? []
|
||||
: findLineOccurrences(source, oldString)
|
||||
const matches = exact.length > 0 ? exact : unicode.length > 0 ? unicode : trailing
|
||||
const replacements = matches.length
|
||||
const replaced = (input.replaceAll === true ? matches : matches.slice(0, 1))
|
||||
.toReversed()
|
||||
.reduce(
|
||||
(content, match) =>
|
||||
`${content.slice(0, match.start)}${newString}${content.slice(match.end)}`,
|
||||
source,
|
||||
)
|
||||
const preview =
|
||||
replacements > 0 && (replacements === 1 || input.replaceAll === true)
|
||||
? fileDiff(target.resource, source, replaced)
|
||||
: undefined
|
||||
yield* permission.assert({
|
||||
action: "edit",
|
||||
resources: [target.resource],
|
||||
save: ["*"],
|
||||
metadata: preview ? { files: [preview] } : undefined,
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: permissionSource,
|
||||
})
|
||||
if (replacements === 0) {
|
||||
return yield* new ToolFailure({
|
||||
message: `Could not find oldString in ${input.path}. It must match exactly, including whitespace and indentation.`,
|
||||
})
|
||||
}
|
||||
if (replacements > 1 && input.replaceAll !== true) {
|
||||
return yield* new ToolFailure({
|
||||
message: `Found ${replacements} matches for oldString, but expected exactly one. Add more surrounding context to make oldString unique, or set replaceAll to true to replace every occurrence.`,
|
||||
})
|
||||
}
|
||||
const replacementBom = replaced.startsWith("\uFEFF")
|
||||
const result = yield* files.write({
|
||||
target,
|
||||
content: Bom.join(replaced, original.bom || replacementBom),
|
||||
})
|
||||
const bom = original.bom || replacementBom
|
||||
const formatted = (yield* formatter.file(target.canonical))
|
||||
? yield* Bom.syncFile(fs, target.canonical, bom)
|
||||
: (yield* Bom.readFile(fs, target.canonical)).text
|
||||
return {
|
||||
files: [fileDiff(result.resource, source, formatted)],
|
||||
replacements,
|
||||
} satisfies Output
|
||||
}).pipe(
|
||||
Effect.map((output) => ({
|
||||
output,
|
||||
content: `Edited ${output.files[0]?.file} (${output.replacements} replacement${output.replacements === 1 ? "" : "s"})`,
|
||||
metadata: { files: output.files },
|
||||
})),
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure
|
||||
? error
|
||||
: new ToolFailure({ message: `Unable to edit ${input.path}`, error }),
|
||||
const info = yield* fs
|
||||
.stat(target.absolute)
|
||||
.pipe(
|
||||
Effect.catchReason("PlatformError", "NotFound", () =>
|
||||
Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })),
|
||||
),
|
||||
)
|
||||
},
|
||||
}),
|
||||
),
|
||||
if (info.type === "Directory") {
|
||||
return yield* new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` })
|
||||
}
|
||||
const original = yield* Bom.readFile(fs, target.absolute)
|
||||
const source = original.text
|
||||
const ending = source.includes(crlf) ? crlf : "\n"
|
||||
const oldString = input.oldString.replaceAll(crlf, "\n").replaceAll("\n", ending)
|
||||
const newString = input.newString.replaceAll(crlf, "\n").replaceAll("\n", ending)
|
||||
const exact = findOccurrences(source, oldString)
|
||||
// These one-to-one mappings preserve offsets into the original source.
|
||||
const unicode =
|
||||
exact.length > 0 ? [] : findOccurrences(normalizeForMatch(source), normalizeForMatch(oldString))
|
||||
const trailing = exact.length > 0 || unicode.length > 0 ? [] : findLineOccurrences(source, oldString)
|
||||
const matches = exact.length > 0 ? exact : unicode.length > 0 ? unicode : trailing
|
||||
const replacements = matches.length
|
||||
const replaced = (input.replaceAll === true ? matches : matches.slice(0, 1))
|
||||
.toReversed()
|
||||
.reduce(
|
||||
(content, match) => `${content.slice(0, match.start)}${newString}${content.slice(match.end)}`,
|
||||
source,
|
||||
)
|
||||
const preview =
|
||||
replacements > 0 && (replacements === 1 || input.replaceAll === true)
|
||||
? fileDiff(target.resource, source, replaced)
|
||||
: undefined
|
||||
yield* permission.assert({
|
||||
action: "edit",
|
||||
resources: [target.resource],
|
||||
save: ["*"],
|
||||
metadata: preview ? { files: [preview] } : undefined,
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: permissionSource,
|
||||
})
|
||||
if (replacements === 0) {
|
||||
return yield* new ToolFailure({
|
||||
message: `Could not find oldString in ${input.path}. It must match exactly, including whitespace and indentation.`,
|
||||
})
|
||||
}
|
||||
if (replacements > 1 && input.replaceAll !== true) {
|
||||
return yield* new ToolFailure({
|
||||
message: `Found ${replacements} matches for oldString, but expected exactly one. Add more surrounding context to make oldString unique, or set replaceAll to true to replace every occurrence.`,
|
||||
})
|
||||
}
|
||||
const replacementBom = replaced.startsWith("\uFEFF")
|
||||
const result = yield* files.write({
|
||||
target,
|
||||
content: Bom.join(replaced, original.bom || replacementBom),
|
||||
})
|
||||
const bom = original.bom || replacementBom
|
||||
const formatted = (yield* formatter.file(target.absolute))
|
||||
? yield* Bom.syncFile(fs, target.absolute, bom)
|
||||
: (yield* Bom.readFile(fs, target.absolute)).text
|
||||
return {
|
||||
files: [fileDiff(result.resource, source, formatted)],
|
||||
replacements,
|
||||
} satisfies Output
|
||||
}).pipe(
|
||||
Effect.map((output) => ({
|
||||
output,
|
||||
content: `Edited ${output.files[0]?.file} (${output.replacements} replacement${output.replacements === 1 ? "" : "s"})`,
|
||||
metadata: { files: output.files },
|
||||
})),
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure
|
||||
? error
|
||||
: new ToolFailure({ message: `Unable to edit ${input.path}`, error }),
|
||||
),
|
||||
)
|
||||
},
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
|
||||
@@ -50,96 +50,93 @@ export const Plugin = {
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((draft) =>
|
||||
draft.add(
|
||||
({
|
||||
name,
|
||||
options: { codemode: false },
|
||||
description:
|
||||
'Search file paths using a glob pattern (examples: "**/*.ts", "src/**/*.tsx").',
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const searchPath = input.path === "undefined" || input.path === "null" ? undefined : input.path
|
||||
const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
|
||||
const target = yield* mutation.resolve({ path: searchPath ?? ".", kind: "directory" })
|
||||
const external = target.externalDirectory
|
||||
if (external)
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
draft.add({
|
||||
name,
|
||||
options: { codemode: false },
|
||||
description: 'Search file paths using a glob pattern (examples: "**/*.ts", "src/**/*.tsx").',
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const searchPath = input.path === "undefined" || input.path === "null" ? undefined : input.path
|
||||
const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
|
||||
const target = yield* mutation.resolve({ path: searchPath ?? ".", kind: "directory" })
|
||||
const external = target.externalDirectory
|
||||
if (external)
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [input.pattern],
|
||||
save: ["*"],
|
||||
metadata: {
|
||||
root: searchPath ?? ".",
|
||||
path: searchPath,
|
||||
limit: input.limit,
|
||||
},
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const info = yield* fs
|
||||
.stat(target.canonical)
|
||||
.pipe(
|
||||
Effect.catchReason("PlatformError", "NotFound", () =>
|
||||
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${searchPath ?? "."}` })),
|
||||
),
|
||||
)
|
||||
if (info.type !== "Directory")
|
||||
return yield* Effect.fail(
|
||||
new ToolFailure({ message: `Search path is not a directory: ${searchPath ?? "."}` }),
|
||||
)
|
||||
const root = path.resolve(location.directory, searchPath ?? ".")
|
||||
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
|
||||
const entries = yield* ripgrep
|
||||
.glob({
|
||||
cwd: target.canonical,
|
||||
pattern: input.pattern,
|
||||
limit: limit + 1,
|
||||
})
|
||||
.pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: FileSystem.DEFAULT_SEARCH_TIMEOUT_MS,
|
||||
orElse: () =>
|
||||
Effect.fail(
|
||||
new ToolFailure({
|
||||
message: `Search timed out after ${FileSystem.DEFAULT_SEARCH_TIMEOUT_MS / 1_000} seconds. Consider using a more specific path or pattern.`,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
Effect.map((result) =>
|
||||
result.map((entry) =>
|
||||
FileSystem.Entry.make({
|
||||
...entry,
|
||||
path: RelativePath.make(path.relative(location.directory, path.resolve(root, entry.path))),
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [input.pattern],
|
||||
save: ["*"],
|
||||
metadata: {
|
||||
root: searchPath ?? ".",
|
||||
path: searchPath,
|
||||
limit: input.limit,
|
||||
},
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const info = yield* fs
|
||||
.stat(target.absolute)
|
||||
.pipe(
|
||||
Effect.catchReason("PlatformError", "NotFound", () =>
|
||||
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${searchPath ?? "."}` })),
|
||||
),
|
||||
)
|
||||
if (info.type !== "Directory")
|
||||
return yield* Effect.fail(
|
||||
new ToolFailure({ message: `Search path is not a directory: ${searchPath ?? "."}` }),
|
||||
)
|
||||
const root = path.resolve(location.directory, searchPath ?? ".")
|
||||
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
|
||||
const entries = yield* ripgrep
|
||||
.glob({
|
||||
cwd: target.absolute,
|
||||
pattern: input.pattern,
|
||||
limit: limit + 1,
|
||||
})
|
||||
.pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: FileSystem.DEFAULT_SEARCH_TIMEOUT_MS,
|
||||
orElse: () =>
|
||||
Effect.fail(
|
||||
new ToolFailure({
|
||||
message: `Search timed out after ${FileSystem.DEFAULT_SEARCH_TIMEOUT_MS / 1_000} seconds. Consider using a more specific path or pattern.`,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
Effect.map((result) =>
|
||||
result.map((entry) =>
|
||||
FileSystem.Entry.make({
|
||||
...entry,
|
||||
path: RelativePath.make(path.relative(location.directory, path.resolve(root, entry.path))),
|
||||
}),
|
||||
),
|
||||
)
|
||||
return { entries: entries.slice(0, limit), truncated: entries.length > limit }
|
||||
}).pipe(
|
||||
Effect.map((result) => ({
|
||||
output: result.entries,
|
||||
content: toModelContent(
|
||||
result.entries.map((entry) => ({ ...entry, path: path.resolve(location.directory, entry.path) })),
|
||||
result.truncated,
|
||||
),
|
||||
metadata: { count: result.entries.length, truncated: result.truncated },
|
||||
})),
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure
|
||||
? error
|
||||
: new ToolFailure({ message: `Unable to find files matching ${input.pattern}`, error }),
|
||||
)
|
||||
return { entries: entries.slice(0, limit), truncated: entries.length > limit }
|
||||
}).pipe(
|
||||
Effect.map((result) => ({
|
||||
output: result.entries,
|
||||
content: toModelContent(
|
||||
result.entries.map((entry) => ({ ...entry, path: path.resolve(location.directory, entry.path) })),
|
||||
result.truncated,
|
||||
),
|
||||
metadata: { count: result.entries.length, truncated: result.truncated },
|
||||
})),
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure
|
||||
? error
|
||||
: new ToolFailure({ message: `Unable to find files matching ${input.pattern}`, error }),
|
||||
),
|
||||
}),
|
||||
),
|
||||
),
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
|
||||
@@ -58,7 +58,7 @@ type Prepared =
|
||||
})
|
||||
|
||||
interface Target {
|
||||
readonly canonical: string
|
||||
readonly absolute: string
|
||||
readonly resource: string
|
||||
readonly externalDirectory?: {
|
||||
readonly directory: string
|
||||
@@ -76,267 +76,256 @@ export const Plugin = {
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((draft) =>
|
||||
draft.add(
|
||||
({
|
||||
name,
|
||||
options: { codemode: false, permission: "edit" },
|
||||
description: DESCRIPTION,
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) => {
|
||||
const applied: Array<typeof Applied.Type> = []
|
||||
const fail = (operation: string, error: unknown) => {
|
||||
const completed = applied.map((item) => item.resource).join(", ")
|
||||
return new ToolFailure({
|
||||
message: `${operation}: ${errorMessage(error)}${completed ? `. Completed before failure: ${completed}` : ""}`,
|
||||
})
|
||||
}
|
||||
return Effect.gen(function* () {
|
||||
const source = {
|
||||
type: "tool" as const,
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
draft.add({
|
||||
name,
|
||||
options: { codemode: false, permission: "edit" },
|
||||
description: DESCRIPTION,
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) => {
|
||||
const applied: Array<typeof Applied.Type> = []
|
||||
const fail = (operation: string, error: unknown) => {
|
||||
const completed = applied.map((item) => item.resource).join(", ")
|
||||
return new ToolFailure({
|
||||
message: `${operation}: ${errorMessage(error)}${completed ? `. Completed before failure: ${completed}` : ""}`,
|
||||
})
|
||||
}
|
||||
return Effect.gen(function* () {
|
||||
const source = {
|
||||
type: "tool" as const,
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
}
|
||||
if (!input.patchText) return yield* new ToolFailure({ message: "patchText is required" })
|
||||
const hunks = yield* Effect.fromResult(Patch.parse(input.patchText)).pipe(
|
||||
Effect.mapError((error) => new ToolFailure({ message: `patch verification failed: ${error.message}` })),
|
||||
)
|
||||
if (hunks.length === 0) {
|
||||
return yield* new ToolFailure({ message: "patch rejected: empty patch" })
|
||||
}
|
||||
const prepared: Prepared[] = []
|
||||
const targets: Target[] = []
|
||||
const updates = new Map<string, string>()
|
||||
for (const hunk of hunks) {
|
||||
yield* Effect.gen(function* () {
|
||||
const target = resolveTarget(location, hunk.path)
|
||||
targets.push(target)
|
||||
if (target.externalDirectory) {
|
||||
yield* permission.assert({
|
||||
action: "external_directory",
|
||||
resources: [target.externalDirectory.resource],
|
||||
save: [target.externalDirectory.resource],
|
||||
metadata: {
|
||||
filepath: target.absolute,
|
||||
parentDir: target.externalDirectory.directory,
|
||||
},
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
}
|
||||
if (!input.patchText) return yield* new ToolFailure({ message: "patchText is required" })
|
||||
const hunks = yield* Effect.fromResult(Patch.parse(input.patchText)).pipe(
|
||||
Effect.mapError(
|
||||
(error) => new ToolFailure({ message: `patch verification failed: ${error.message}` }),
|
||||
),
|
||||
)
|
||||
if (hunks.length === 0) {
|
||||
return yield* new ToolFailure({ message: "patch rejected: empty patch" })
|
||||
if (hunk.type === "add") {
|
||||
prepared.push({
|
||||
...hunk,
|
||||
target,
|
||||
before: "",
|
||||
after: Bom.split(
|
||||
hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`,
|
||||
).text,
|
||||
})
|
||||
return
|
||||
}
|
||||
const prepared: Prepared[] = []
|
||||
const targets: Target[] = []
|
||||
const updates = new Map<string, string>()
|
||||
for (const hunk of hunks) {
|
||||
yield* Effect.gen(function* () {
|
||||
const target = resolveTarget(location, hunk.path)
|
||||
targets.push(target)
|
||||
if (target.externalDirectory) {
|
||||
yield* permission.assert({
|
||||
action: "external_directory",
|
||||
resources: [target.externalDirectory.resource],
|
||||
save: [target.externalDirectory.resource],
|
||||
metadata: {
|
||||
filepath: target.canonical,
|
||||
parentDir: target.externalDirectory.directory,
|
||||
},
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
}
|
||||
if (hunk.type === "add") {
|
||||
prepared.push({
|
||||
...hunk,
|
||||
target,
|
||||
before: "",
|
||||
after: Bom.split(
|
||||
hunk.contents.endsWith("\n") || hunk.contents === ""
|
||||
? hunk.contents
|
||||
: `${hunk.contents}\n`,
|
||||
).text,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (hunk.type === "delete") {
|
||||
const content = yield* Bom.readFile(fs, target.canonical).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
message: `patch verification failed: Failed to delete ${target.resource}: ${errorMessage(error)}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
prepared.push({ ...hunk, target, before: content.text, after: "" })
|
||||
return
|
||||
}
|
||||
const previous = updates.get(target.canonical)
|
||||
const original =
|
||||
previous ??
|
||||
(yield* Effect.gen(function* () {
|
||||
const stats = yield* fs.stat(target.canonical).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${errorMessage(error)}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (stats.type === "Directory") {
|
||||
return yield* new ToolFailure({
|
||||
message: `patch verification failed: Failed to read file to update ${target.canonical}: path is a directory`,
|
||||
})
|
||||
}
|
||||
const content = yield* Bom.readFile(fs, target.canonical).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${errorMessage(error)}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
return Bom.join(content.text, content.bom)
|
||||
}))
|
||||
const before = Bom.split(original).text
|
||||
const update = yield* Effect.try({
|
||||
try: () => Patch.derive(hunk.path, hunk.chunks, original),
|
||||
catch: (error) =>
|
||||
new ToolFailure({ message: `patch verification failed: ${errorMessage(error)}` }),
|
||||
})
|
||||
const moveTarget = hunk.movePath ? resolveTarget(location, hunk.movePath) : undefined
|
||||
if (moveTarget) targets.push(moveTarget)
|
||||
if (moveTarget?.externalDirectory) {
|
||||
yield* permission.assert({
|
||||
action: "external_directory",
|
||||
resources: [moveTarget.externalDirectory.resource],
|
||||
save: [moveTarget.externalDirectory.resource],
|
||||
metadata: {
|
||||
filepath: moveTarget.canonical,
|
||||
parentDir: moveTarget.externalDirectory.directory,
|
||||
},
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
}
|
||||
prepared.push({
|
||||
...hunk,
|
||||
target,
|
||||
content: Patch.joinBom(update.content, update.bom),
|
||||
before,
|
||||
after: update.content,
|
||||
moveTarget,
|
||||
})
|
||||
if (!moveTarget) updates.set(target.canonical, Patch.joinBom(update.content, update.bom))
|
||||
}).pipe(
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure
|
||||
? error
|
||||
: new ToolFailure({ message: `Unable to prepare patch at ${hunk.path}`, error }),
|
||||
if (hunk.type === "delete") {
|
||||
const content = yield* Bom.readFile(fs, target.absolute).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
message: `patch verification failed: Failed to delete ${target.resource}: ${errorMessage(error)}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
prepared.push({ ...hunk, target, before: content.text, after: "" })
|
||||
return
|
||||
}
|
||||
|
||||
const patchFiles = prepared.map((change) => patchFile(change))
|
||||
yield* permission.assert({
|
||||
action: "edit",
|
||||
resources: [...new Set(targets.map((target) => target.resource))],
|
||||
save: ["*"],
|
||||
metadata: {
|
||||
filepath: targets.map((target) => target.resource).join(", "),
|
||||
diff: patchFiles.map((file) => `${file.patch}\n`).join(""),
|
||||
files: patchFiles,
|
||||
},
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
|
||||
yield* Effect.forEach(
|
||||
prepared,
|
||||
(change) =>
|
||||
Effect.gen(function* () {
|
||||
if (change.type === "add") {
|
||||
yield* fs
|
||||
.writeWithDirs(
|
||||
change.target.canonical,
|
||||
change.contents.endsWith("\n") || change.contents === ""
|
||||
? change.contents
|
||||
: `${change.contents}\n`,
|
||||
)
|
||||
.pipe(
|
||||
Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)),
|
||||
)
|
||||
applied.push({
|
||||
type: change.type,
|
||||
resource: change.target.resource,
|
||||
target: change.target.canonical,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (change.type === "delete") {
|
||||
yield* fs
|
||||
.remove(change.target.canonical)
|
||||
.pipe(
|
||||
Effect.mapError((error) => fail(`Failed to delete ${change.target.resource}`, error)),
|
||||
)
|
||||
applied.push({
|
||||
type: change.type,
|
||||
resource: change.target.resource,
|
||||
target: change.target.canonical,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (change.moveTarget) {
|
||||
const moveTarget = change.moveTarget
|
||||
yield* fs
|
||||
.writeWithDirs(moveTarget.canonical, change.content)
|
||||
.pipe(Effect.mapError((error) => fail(`Failed to write ${moveTarget.resource}`, error)))
|
||||
yield* fs.remove(change.target.canonical).pipe(
|
||||
Effect.mapError((error) =>
|
||||
fail(`Wrote ${moveTarget.resource} but failed to remove ${change.target.resource}`, error),
|
||||
),
|
||||
)
|
||||
applied.push({
|
||||
type: change.type,
|
||||
resource: change.moveTarget.resource,
|
||||
target: change.moveTarget.canonical,
|
||||
})
|
||||
return
|
||||
}
|
||||
yield* fs
|
||||
.writeWithDirs(change.target.canonical, change.content)
|
||||
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
|
||||
applied.push({
|
||||
type: change.type,
|
||||
resource: change.target.resource,
|
||||
target: change.target.canonical,
|
||||
const previous = updates.get(target.absolute)
|
||||
const original =
|
||||
previous ??
|
||||
(yield* Effect.gen(function* () {
|
||||
const stats = yield* fs.stat(target.absolute).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
message: `patch verification failed: Failed to read file to update ${target.absolute}: ${errorMessage(error)}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (stats.type === "Directory") {
|
||||
return yield* new ToolFailure({
|
||||
message: `patch verification failed: Failed to read file to update ${target.absolute}: path is a directory`,
|
||||
})
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
const formatted = new Map<string, string>()
|
||||
yield* Effect.forEach(
|
||||
[...new Set(applied.filter((item) => item.type !== "delete").map((item) => item.target))],
|
||||
(target) =>
|
||||
Effect.gen(function* () {
|
||||
const current = yield* Bom.readFile(fs, target).pipe(
|
||||
Effect.mapError((error) => fail(`Failed to read ${target}`, error)),
|
||||
)
|
||||
formatted.set(
|
||||
target,
|
||||
(yield* formatter.file(target))
|
||||
? yield* Bom.syncFile(fs, target, current.bom).pipe(
|
||||
Effect.mapError((error) => fail(`Failed to sync ${target}`, error)),
|
||||
)
|
||||
: current.text,
|
||||
)
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
const files = yield* Effect.forEach(prepared, (change) => {
|
||||
if (change.type === "delete") return Effect.succeed(patchFile(change))
|
||||
const target = change.type === "update" && change.moveTarget ? change.moveTarget : change.target
|
||||
return Effect.succeed(patchFile(change, formatted.get(target.canonical)))
|
||||
}
|
||||
const content = yield* Bom.readFile(fs, target.absolute).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
message: `patch verification failed: Failed to read file to update ${target.absolute}: ${errorMessage(error)}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
return Bom.join(content.text, content.bom)
|
||||
}))
|
||||
const before = Bom.split(original).text
|
||||
const update = yield* Effect.try({
|
||||
try: () => Patch.derive(hunk.path, hunk.chunks, original),
|
||||
catch: (error) => new ToolFailure({ message: `patch verification failed: ${errorMessage(error)}` }),
|
||||
})
|
||||
return { applied, files }
|
||||
const moveTarget = hunk.movePath ? resolveTarget(location, hunk.movePath) : undefined
|
||||
if (moveTarget) targets.push(moveTarget)
|
||||
if (moveTarget?.externalDirectory) {
|
||||
yield* permission.assert({
|
||||
action: "external_directory",
|
||||
resources: [moveTarget.externalDirectory.resource],
|
||||
save: [moveTarget.externalDirectory.resource],
|
||||
metadata: {
|
||||
filepath: moveTarget.absolute,
|
||||
parentDir: moveTarget.externalDirectory.directory,
|
||||
},
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
}
|
||||
prepared.push({
|
||||
...hunk,
|
||||
target,
|
||||
content: Patch.joinBom(update.content, update.bom),
|
||||
before,
|
||||
after: update.content,
|
||||
moveTarget,
|
||||
})
|
||||
if (!moveTarget) updates.set(target.absolute, Patch.joinBom(update.content, update.bom))
|
||||
}).pipe(
|
||||
Effect.map((output) => ({
|
||||
output,
|
||||
content: toModelOutput(output),
|
||||
metadata: { files: output.files },
|
||||
})),
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure
|
||||
? error
|
||||
: new ToolFailure({ message: "Unable to apply patch", error }),
|
||||
: new ToolFailure({ message: `Unable to prepare patch at ${hunk.path}`, error }),
|
||||
),
|
||||
)
|
||||
},
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
const patchFiles = prepared.map((change) => patchFile(change))
|
||||
yield* permission.assert({
|
||||
action: "edit",
|
||||
resources: [...new Set(targets.map((target) => target.resource))],
|
||||
save: ["*"],
|
||||
metadata: {
|
||||
filepath: targets.map((target) => target.resource).join(", "),
|
||||
diff: patchFiles.map((file) => `${file.patch}\n`).join(""),
|
||||
files: patchFiles,
|
||||
},
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
|
||||
yield* Effect.forEach(
|
||||
prepared,
|
||||
(change) =>
|
||||
Effect.gen(function* () {
|
||||
if (change.type === "add") {
|
||||
yield* fs
|
||||
.writeWithDirs(
|
||||
change.target.absolute,
|
||||
change.contents.endsWith("\n") || change.contents === ""
|
||||
? change.contents
|
||||
: `${change.contents}\n`,
|
||||
)
|
||||
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
|
||||
applied.push({
|
||||
type: change.type,
|
||||
resource: change.target.resource,
|
||||
target: change.target.absolute,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (change.type === "delete") {
|
||||
yield* fs
|
||||
.remove(change.target.absolute)
|
||||
.pipe(Effect.mapError((error) => fail(`Failed to delete ${change.target.resource}`, error)))
|
||||
applied.push({
|
||||
type: change.type,
|
||||
resource: change.target.resource,
|
||||
target: change.target.absolute,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (change.moveTarget) {
|
||||
const moveTarget = change.moveTarget
|
||||
yield* fs
|
||||
.writeWithDirs(moveTarget.absolute, change.content)
|
||||
.pipe(Effect.mapError((error) => fail(`Failed to write ${moveTarget.resource}`, error)))
|
||||
yield* fs
|
||||
.remove(change.target.absolute)
|
||||
.pipe(
|
||||
Effect.mapError((error) =>
|
||||
fail(`Wrote ${moveTarget.resource} but failed to remove ${change.target.resource}`, error),
|
||||
),
|
||||
)
|
||||
applied.push({
|
||||
type: change.type,
|
||||
resource: change.moveTarget.resource,
|
||||
target: change.moveTarget.absolute,
|
||||
})
|
||||
return
|
||||
}
|
||||
yield* fs
|
||||
.writeWithDirs(change.target.absolute, change.content)
|
||||
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
|
||||
applied.push({
|
||||
type: change.type,
|
||||
resource: change.target.resource,
|
||||
target: change.target.absolute,
|
||||
})
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
const formatted = new Map<string, string>()
|
||||
yield* Effect.forEach(
|
||||
[...new Set(applied.filter((item) => item.type !== "delete").map((item) => item.target))],
|
||||
(target) =>
|
||||
Effect.gen(function* () {
|
||||
const current = yield* Bom.readFile(fs, target).pipe(
|
||||
Effect.mapError((error) => fail(`Failed to read ${target}`, error)),
|
||||
)
|
||||
formatted.set(
|
||||
target,
|
||||
(yield* formatter.file(target))
|
||||
? yield* Bom.syncFile(fs, target, current.bom).pipe(
|
||||
Effect.mapError((error) => fail(`Failed to sync ${target}`, error)),
|
||||
)
|
||||
: current.text,
|
||||
)
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
const files = yield* Effect.forEach(prepared, (change) => {
|
||||
if (change.type === "delete") return Effect.succeed(patchFile(change))
|
||||
const target = change.type === "update" && change.moveTarget ? change.moveTarget : change.target
|
||||
return Effect.succeed(patchFile(change, formatted.get(target.absolute)))
|
||||
})
|
||||
return { applied, files }
|
||||
}).pipe(
|
||||
Effect.map((output) => ({
|
||||
output,
|
||||
content: toModelOutput(output),
|
||||
metadata: { files: output.files },
|
||||
})),
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure ? error : new ToolFailure({ message: "Unable to apply patch", error }),
|
||||
),
|
||||
)
|
||||
},
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -365,9 +354,7 @@ function errorMessage(error: unknown) {
|
||||
|
||||
function patchFile(change: Prepared, after = change.after): typeof FileDiff.Info.Type {
|
||||
const target = (change.type === "update" ? change.moveTarget : undefined)?.resource ?? change.target.resource
|
||||
const patch = trimDiff(
|
||||
createTwoFilesPatch(change.target.canonical, change.target.canonical, change.before, after),
|
||||
)
|
||||
const patch = trimDiff(createTwoFilesPatch(change.target.absolute, change.target.absolute, change.before, after))
|
||||
const counts =
|
||||
change.type === "delete"
|
||||
? { additions: 0, deletions: change.before.split("\n").length }
|
||||
@@ -416,22 +403,22 @@ function trimDiff(diff: string) {
|
||||
}
|
||||
|
||||
function resolveTarget(location: Location.Interface, value: string): Target {
|
||||
const canonical =
|
||||
const absolute =
|
||||
process.platform === "win32"
|
||||
? FSUtil.normalizePath(path.resolve(location.directory, value))
|
||||
: path.resolve(location.directory, value)
|
||||
const projectRoot = path.parse(location.project.directory).root
|
||||
const external =
|
||||
!FSUtil.contains(location.directory, canonical) &&
|
||||
(location.project.directory === projectRoot || !FSUtil.contains(location.project.directory, canonical))
|
||||
const directory = path.dirname(canonical)
|
||||
!FSUtil.contains(location.directory, absolute) &&
|
||||
(location.project.directory === projectRoot || !FSUtil.contains(location.project.directory, absolute))
|
||||
const directory = path.dirname(absolute)
|
||||
const resource =
|
||||
process.platform === "win32"
|
||||
? FSUtil.normalizePathPattern(path.join(directory, "*"))
|
||||
: path.join(directory, "*").replaceAll("\\", "/")
|
||||
return {
|
||||
canonical,
|
||||
resource: path.relative(location.project.directory, canonical).replaceAll("\\", "/") || ".",
|
||||
absolute,
|
||||
resource: path.relative(location.project.directory, absolute).replaceAll("\\", "/") || ".",
|
||||
externalDirectory: external ? { directory, resource } : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,11 +25,7 @@ const LocationInput = Schema.Struct({
|
||||
}),
|
||||
})
|
||||
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 = {
|
||||
id: "opencode.tool.read",
|
||||
@@ -43,105 +39,103 @@ export const Plugin = {
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((draft) =>
|
||||
draft.add(
|
||||
({
|
||||
name,
|
||||
options: { codemode: false },
|
||||
description:
|
||||
"Read the contents of a file or directory. Supports text files, images, and PDFs. Images and PDFs are presented directly to the model. Each text line is prefixed by its 1-based line number as <line>: <content>. The prefix is for reference and is not part of the file content. Directory entries are returned one per line. Use offset and limit to read large files or directories in sections. Prefer one larger read over many small slices, and use grep to find specific content in large files.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) => {
|
||||
return Effect.gen(function* () {
|
||||
const source = {
|
||||
type: "tool" as const,
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
}
|
||||
const target = yield* mutation.resolve({ path: input.path, kind: "directory" })
|
||||
const external = target.externalDirectory
|
||||
if (external)
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const resource = target.resource
|
||||
const absolute = AbsolutePath.make(target.canonical)
|
||||
draft.add({
|
||||
name,
|
||||
options: { codemode: false },
|
||||
description:
|
||||
"Read the contents of a file or directory. Supports text files, images, and PDFs. Images and PDFs are presented directly to the model. Each text line is prefixed by its 1-based line number as <line>: <content>. The prefix is for reference and is not part of the file content. Directory entries are returned one per line. Use offset and limit to read large files or directories in sections. Prefer one larger read over many small slices, and use grep to find specific content in large files.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) => {
|
||||
return Effect.gen(function* () {
|
||||
const source = {
|
||||
type: "tool" as const,
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
}
|
||||
const target = yield* mutation.resolve({ path: input.path })
|
||||
const external = target.externalDirectory
|
||||
if (external)
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [resource],
|
||||
save: ["*"],
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const type = yield* reader.inspect(absolute).pipe(
|
||||
Effect.catchReason("PlatformError", "NotFound", () => missing(input.path, target.canonical)),
|
||||
const resource = target.resource
|
||||
const absolute = AbsolutePath.make(target.absolute)
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [resource],
|
||||
save: ["*"],
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const type = yield* reader
|
||||
.inspect(absolute)
|
||||
.pipe(Effect.catchReason("PlatformError", "NotFound", () => missing(input.path, target.absolute)))
|
||||
const content =
|
||||
type === "directory"
|
||||
? yield* reader.list(absolute, { offset: input.offset, limit: input.limit })
|
||||
: yield* reader.read(absolute, resource, {
|
||||
offset: input.offset,
|
||||
limit: input.limit,
|
||||
})
|
||||
// After a successful read, discover nearby AGENTS.md walking up to the Location
|
||||
// root exclusive and inject them as durable synthetic instructions. For a
|
||||
// directory listing the walk starts at the directory itself (so its own AGENTS.md
|
||||
// is discovered); for a file it starts at the file's dirname. External reads are
|
||||
// skipped, and discovery failures never fail the read.
|
||||
yield* Effect.gen(function* () {
|
||||
if (target.externalDirectory !== undefined) return
|
||||
const resolved = yield* fs.resolve(target.absolute)
|
||||
const root = yield* fs.resolve(location.directory)
|
||||
// up() searches its stop directory, so the Location-root AGENTS.md (already
|
||||
// supplied by core initial instructions) is dropped by the dirname filter.
|
||||
const discovered = yield* fs.up({
|
||||
targets: [FILENAME],
|
||||
start: type === "directory" ? resolved : dirname(resolved),
|
||||
stop: root,
|
||||
})
|
||||
const candidates = (yield* Effect.forEach(discovered, fs.resolve)).filter(
|
||||
(file) => dirname(file) !== root,
|
||||
)
|
||||
const content =
|
||||
type === "directory"
|
||||
? yield* reader.list(absolute, { offset: input.offset, limit: input.limit })
|
||||
: yield* reader.read(absolute, resource, {
|
||||
offset: input.offset,
|
||||
limit: input.limit,
|
||||
})
|
||||
// After a successful read, discover nearby AGENTS.md walking up to the Location
|
||||
// root exclusive and inject them as durable synthetic instructions. For a
|
||||
// directory listing the walk starts at the directory itself (so its own AGENTS.md
|
||||
// is discovered); for a file it starts at the file's dirname. External reads are
|
||||
// skipped, and discovery failures never fail the read.
|
||||
yield* Effect.gen(function* () {
|
||||
if (target.externalDirectory !== undefined) return
|
||||
const resolved = yield* fs.resolve(target.canonical)
|
||||
const root = yield* fs.resolve(location.directory)
|
||||
// up() searches its stop directory, so the Location-root AGENTS.md (already
|
||||
// supplied by core initial instructions) is dropped by the dirname filter.
|
||||
const discovered = yield* fs.up({
|
||||
targets: [FILENAME],
|
||||
start: type === "directory" ? resolved : dirname(resolved),
|
||||
stop: root,
|
||||
})
|
||||
const candidates = (yield* Effect.forEach(discovered, fs.resolve)).filter(
|
||||
(file) => dirname(file) !== root,
|
||||
)
|
||||
if (candidates.length === 0) return
|
||||
yield* sessionInstructions.load({ sessionID: context.sessionID, paths: candidates })
|
||||
}).pipe(
|
||||
Effect.catch(() => Effect.void),
|
||||
Effect.catchDefect(() => Effect.void),
|
||||
)
|
||||
if (content.type === "file" && content.encoding === "base64" && !SUPPORTED_MEDIA_MIMES.has(content.mime))
|
||||
return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource }))
|
||||
return content
|
||||
if (candidates.length === 0) return
|
||||
yield* sessionInstructions.load({ sessionID: context.sessionID, paths: candidates })
|
||||
}).pipe(
|
||||
Effect.map((output) => ({
|
||||
output,
|
||||
content: toModelContent(input.path, input.offset, output),
|
||||
})),
|
||||
Effect.mapError((error) => {
|
||||
if (error instanceof ToolFailure) return error
|
||||
const message =
|
||||
error instanceof ReadToolFileSystem.BinaryFileError ||
|
||||
error instanceof ReadToolFileSystem.MediaIngestLimitError ||
|
||||
error instanceof ReadToolFileSystem.MalformedUtf8Error ||
|
||||
error instanceof ReadToolFileSystem.OffsetOutOfRangeError ||
|
||||
error instanceof ReadToolFileSystem.PathKindError
|
||||
? error.message
|
||||
: `Unable to read ${input.path}`
|
||||
return new ToolFailure({ message, error })
|
||||
}),
|
||||
Effect.catch(() => Effect.void),
|
||||
Effect.catchDefect(() => Effect.void),
|
||||
)
|
||||
},
|
||||
}),
|
||||
),
|
||||
if (content.type === "file" && content.encoding === "base64" && !SUPPORTED_MEDIA_MIMES.has(content.mime))
|
||||
return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource }))
|
||||
return content
|
||||
}).pipe(
|
||||
Effect.map((output) => ({
|
||||
output,
|
||||
content: toModelContent(input.path, input.offset, output),
|
||||
metadata: { truncated: output.type === "file" ? false : output.truncated },
|
||||
})),
|
||||
Effect.mapError((error) => {
|
||||
if (error instanceof ToolFailure) return error
|
||||
const message =
|
||||
error instanceof ReadToolFileSystem.BinaryFileError ||
|
||||
error instanceof ReadToolFileSystem.MediaIngestLimitError ||
|
||||
error instanceof ReadToolFileSystem.OffsetOutOfRangeError ||
|
||||
error instanceof ReadToolFileSystem.PathKindError
|
||||
? error.message
|
||||
: `Unable to read ${input.path}`
|
||||
return new ToolFailure({ message, error })
|
||||
}),
|
||||
)
|
||||
},
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
const missing = Effect.fn("ReadTool.missing")(function* (input: string, canonical: string) {
|
||||
const missing = Effect.fn("ReadTool.missing")(function* (input: string, absolute: string) {
|
||||
const base = basename(input).toLowerCase()
|
||||
const suggestions = yield* fs.readDirectory(dirname(canonical)).pipe(
|
||||
const suggestions = yield* fs.readDirectory(dirname(absolute)).pipe(
|
||||
Effect.map((entries) =>
|
||||
entries
|
||||
.filter((entry) => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { Content } from "@opencode-ai/schema/tool"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Deferred, Effect, Schema, Scope } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Config } from "../../config"
|
||||
import { LocationMutation } from "../../location-mutation"
|
||||
import { Permission } from "../../permission"
|
||||
import { PluginRuntime } from "../../plugin/runtime"
|
||||
@@ -13,10 +14,10 @@ import { NonNegativeInt } from "../../schema"
|
||||
import { SessionSchema } from "../../session/schema"
|
||||
import { Shell } from "../../shell"
|
||||
import { ShellParse } from "../../shell/parse"
|
||||
import { ToolOutput } from "../../tool-output"
|
||||
|
||||
export const name = "shell"
|
||||
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_INSTRUCTION =
|
||||
@@ -86,6 +87,7 @@ export const Plugin = {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const shell = yield* Shell.Service
|
||||
const permission = yield* Permission.Service
|
||||
const config = yield* Config.Service
|
||||
|
||||
const notifyWhenDone = Effect.fn("ShellTool.notifyWhenDone")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
@@ -122,174 +124,182 @@ export const Plugin = {
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((draft) =>
|
||||
draft.add(
|
||||
({
|
||||
name,
|
||||
options: { codemode: false },
|
||||
description: description(),
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const source = {
|
||||
type: "tool" as const,
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
}
|
||||
const timeout = input.background === true ? (input.timeout ?? 0) : (input.timeout ?? DEFAULT_TIMEOUT_MS)
|
||||
let finalTimeout = timeout
|
||||
const info = yield* shell.create(
|
||||
{
|
||||
command: input.command,
|
||||
cwd: input.workdir,
|
||||
timeout,
|
||||
metadata: { sessionID: context.sessionID },
|
||||
},
|
||||
(invocation) =>
|
||||
Effect.gen(function* () {
|
||||
const target = yield* mutation.resolve({ path: invocation.cwd, kind: "directory" })
|
||||
const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.canonical)
|
||||
const directories = yield* Effect.forEach(parsed.directories, (directory) =>
|
||||
mutation.resolve({ path: path.resolve(target.canonical, directory), kind: "directory" }),
|
||||
draft.add({
|
||||
name,
|
||||
options: { codemode: false },
|
||||
description: description(),
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const source = {
|
||||
type: "tool" as const,
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
}
|
||||
const timeout = input.background === true ? (input.timeout ?? 0) : (input.timeout ?? DEFAULT_TIMEOUT_MS)
|
||||
let finalTimeout = timeout
|
||||
const info = yield* shell.create(
|
||||
{
|
||||
command: input.command,
|
||||
cwd: input.workdir,
|
||||
timeout,
|
||||
metadata: { sessionID: context.sessionID },
|
||||
},
|
||||
(invocation) =>
|
||||
Effect.gen(function* () {
|
||||
const target = yield* mutation.resolve({ path: invocation.cwd, kind: "directory" })
|
||||
const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.absolute)
|
||||
const directories = yield* Effect.forEach(parsed.directories, (directory) =>
|
||||
mutation.resolve({ path: path.resolve(target.absolute, directory), kind: "directory" }),
|
||||
)
|
||||
invocation.cwd = target.absolute
|
||||
finalTimeout = invocation.timeout
|
||||
const external = [target, ...directories]
|
||||
.map((item) => item.externalDirectory)
|
||||
.filter((item) => item !== undefined)
|
||||
.filter(
|
||||
(item, index, items) => items.findIndex((other) => other.resource === item.resource) === index,
|
||||
)
|
||||
invocation.cwd = target.canonical
|
||||
finalTimeout = invocation.timeout
|
||||
const external = [target, ...directories]
|
||||
.map((item) => item.externalDirectory)
|
||||
.filter((item) => item !== undefined)
|
||||
.filter((item, index, items) => items.findIndex((other) => other.resource === item.resource) === index)
|
||||
if (external.length > 0)
|
||||
yield* permission.assert({
|
||||
action: "external_directory",
|
||||
resources: external.map((item) => item.resource),
|
||||
save: external.map((item) => item.save),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
if (parsed.commands.length > 0)
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: parsed.commands.map((command) => command.resource),
|
||||
save: parsed.commands.map((command) => command.save),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const workdir = yield* fsUtil.stat(target.canonical).pipe(
|
||||
if (external.length > 0)
|
||||
yield* permission.assert({
|
||||
action: "external_directory",
|
||||
resources: external.map((item) => item.resource),
|
||||
save: external.map((item) => item.save),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
if (parsed.commands.length > 0)
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: parsed.commands.map((command) => command.resource),
|
||||
save: parsed.commands.map((command) => command.save),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const workdir = yield* fsUtil
|
||||
.stat(target.absolute)
|
||||
.pipe(
|
||||
Effect.catchReason("PlatformError", "NotFound", () =>
|
||||
Effect.fail(new Error(`Working directory does not exist: ${target.canonical}`)),
|
||||
Effect.fail(new Error(`Working directory does not exist: ${target.absolute}`)),
|
||||
),
|
||||
)
|
||||
if (workdir.type !== "Directory")
|
||||
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`))
|
||||
}),
|
||||
)
|
||||
yield* context.progress({ shellID: info.id })
|
||||
if (workdir.type !== "Directory")
|
||||
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.absolute}`))
|
||||
}),
|
||||
)
|
||||
yield* context.progress({ shellID: info.id })
|
||||
|
||||
const captureShell = Effect.fn("ShellTool.captureShell")(function* () {
|
||||
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, {
|
||||
cursor: Math.max(0, latest.size - MAX_CAPTURE_BYTES),
|
||||
limit: MAX_CAPTURE_BYTES,
|
||||
})
|
||||
const notice = truncated ? `\n\n[output truncated; full output saved to: ${info.file}]` : ""
|
||||
return {
|
||||
output: `${page.output || "(no output)"}${notice}`,
|
||||
truncated,
|
||||
}
|
||||
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 page = yield* shell.output(info.id, {
|
||||
cursor: Math.max(0, latest.size - maxBytes),
|
||||
limit: maxBytes,
|
||||
})
|
||||
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}]` : ""
|
||||
return {
|
||||
output: `${output || "(no output)"}${notice}`,
|
||||
truncated,
|
||||
}
|
||||
})
|
||||
|
||||
const settleShell = Effect.fn("ShellTool.settleShell")(function* () {
|
||||
const final = yield* shell.wait(info.id)
|
||||
const capture = yield* captureShell()
|
||||
|
||||
// `exit` is optionalKey in the Output schema; a present-but-undefined key
|
||||
// fails output encoding, so omit it when the process has no exit code.
|
||||
if (final.status === "timeout") {
|
||||
return {
|
||||
...(final.exit !== undefined ? { exit: final.exit } : {}),
|
||||
output: `${capture.output}\n\nCommand exceeded timeout of ${finalTimeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
|
||||
truncated: capture.truncated,
|
||||
timeout: true,
|
||||
status: "completed" as const,
|
||||
}
|
||||
}
|
||||
const settleShell = Effect.fn("ShellTool.settleShell")(function* () {
|
||||
const final = yield* shell.wait(info.id)
|
||||
const capture = yield* captureShell()
|
||||
|
||||
// `exit` is optionalKey in the Output schema; a present-but-undefined key
|
||||
// fails output encoding, so omit it when the process has no exit code.
|
||||
if (final.status === "timeout") {
|
||||
return {
|
||||
...(final.exit !== undefined ? { exit: final.exit } : {}),
|
||||
output: capture.output,
|
||||
output: `${capture.output}\n\nCommand exceeded timeout of ${finalTimeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
|
||||
truncated: capture.truncated,
|
||||
timeout: true,
|
||||
status: "completed" as const,
|
||||
}
|
||||
})
|
||||
|
||||
const settled = yield* Deferred.make<Output>()
|
||||
const run = settleShell().pipe(
|
||||
Effect.tap((output) => Deferred.succeed(settled, output)),
|
||||
Effect.map((output) => output.output),
|
||||
Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)),
|
||||
)
|
||||
const job = yield* runtime.job.start({
|
||||
id: context.id,
|
||||
type: name,
|
||||
title: info.command,
|
||||
metadata: { sessionID: context.sessionID, shellID: info.id },
|
||||
run,
|
||||
})
|
||||
|
||||
if (input.background === true) {
|
||||
yield* runtime.job.background(job.id)
|
||||
yield* notifyWhenDone(context.sessionID, context.id, info.command)
|
||||
return {
|
||||
output: BACKGROUND_STARTED,
|
||||
shellID: info.id,
|
||||
truncated: false,
|
||||
status: "running" as const,
|
||||
}
|
||||
}
|
||||
|
||||
const result = yield* runtime.job.block({ id: job.id, sessionID: context.sessionID }).pipe(
|
||||
Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)),
|
||||
)
|
||||
if (result?.type === "backgrounded") {
|
||||
yield* shell.timeout(info.id, 0)
|
||||
yield* notifyWhenDone(context.sessionID, context.id, info.command)
|
||||
return {
|
||||
output: BACKGROUND_STARTED,
|
||||
shellID: info.id,
|
||||
truncated: false,
|
||||
status: "running" as const,
|
||||
}
|
||||
return {
|
||||
...(final.exit !== undefined ? { exit: final.exit } : {}),
|
||||
output: capture.output,
|
||||
truncated: capture.truncated,
|
||||
status: "completed" as const,
|
||||
}
|
||||
if (result?.info.status === "error")
|
||||
return yield* Effect.fail(new Error(result.info.error ?? "Command failed"))
|
||||
if (result?.info.status === "cancelled") return yield* Effect.fail(new Error("Command cancelled"))
|
||||
})
|
||||
|
||||
return yield* Deferred.await(settled)
|
||||
}).pipe(
|
||||
Effect.map((output) => {
|
||||
const content: Array<Content> = [{ type: "text", text: output.output }]
|
||||
const model = modelOutput(output)
|
||||
if (model) content.push({ type: "text", text: model })
|
||||
return {
|
||||
output,
|
||||
content,
|
||||
metadata: {
|
||||
truncated: output.truncated,
|
||||
...("exit" in output && output.exit !== undefined ? { exit: output.exit } : {}),
|
||||
...("shellID" in output && output.shellID !== undefined ? { shellID: output.shellID } : {}),
|
||||
...("timeout" in output && output.timeout !== undefined ? { timeout: output.timeout } : {}),
|
||||
},
|
||||
}
|
||||
}),
|
||||
Effect.mapError(
|
||||
(error) => new ToolFailure({ message: `Unable to execute command: ${input.command}`, error }),
|
||||
),
|
||||
const settled = yield* Deferred.make<Output>()
|
||||
const run = settleShell().pipe(
|
||||
Effect.tap((output) => Deferred.succeed(settled, output)),
|
||||
Effect.map((output) => output.output),
|
||||
Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)),
|
||||
)
|
||||
const job = yield* runtime.job.start({
|
||||
id: context.id,
|
||||
type: name,
|
||||
title: info.command,
|
||||
metadata: { sessionID: context.sessionID, shellID: info.id },
|
||||
run,
|
||||
})
|
||||
|
||||
if (input.background === true) {
|
||||
yield* runtime.job.background(job.id)
|
||||
yield* notifyWhenDone(context.sessionID, context.id, info.command)
|
||||
return {
|
||||
output: BACKGROUND_STARTED,
|
||||
shellID: info.id,
|
||||
truncated: false,
|
||||
status: "running" as const,
|
||||
}
|
||||
}
|
||||
|
||||
const result = yield* runtime.job
|
||||
.block({ id: job.id, sessionID: context.sessionID })
|
||||
.pipe(Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)))
|
||||
if (result?.type === "backgrounded") {
|
||||
yield* shell.timeout(info.id, 0)
|
||||
yield* notifyWhenDone(context.sessionID, context.id, info.command)
|
||||
return {
|
||||
output: BACKGROUND_STARTED,
|
||||
shellID: info.id,
|
||||
truncated: false,
|
||||
status: "running" as const,
|
||||
}
|
||||
}
|
||||
if (result?.info.status === "error")
|
||||
return yield* Effect.fail(new Error(result.info.error ?? "Command failed"))
|
||||
if (result?.info.status === "cancelled") return yield* Effect.fail(new Error("Command cancelled"))
|
||||
|
||||
return yield* Deferred.await(settled)
|
||||
}).pipe(
|
||||
Effect.map((output) => {
|
||||
const content: Array<Content> = [{ type: "text", text: output.output }]
|
||||
const model = modelOutput(output)
|
||||
if (model) content.push({ type: "text", text: model })
|
||||
return {
|
||||
output,
|
||||
content,
|
||||
metadata: {
|
||||
truncated: output.truncated,
|
||||
...("exit" in output && output.exit !== undefined ? { exit: output.exit } : {}),
|
||||
...("shellID" in output && output.shellID !== undefined ? { shellID: output.shellID } : {}),
|
||||
...("timeout" in output && output.timeout !== undefined ? { timeout: output.timeout } : {}),
|
||||
},
|
||||
}
|
||||
}),
|
||||
Effect.mapError(
|
||||
(error) => new ToolFailure({ message: `Unable to execute command: ${input.command}`, error }),
|
||||
),
|
||||
}),
|
||||
),
|
||||
),
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ export * as WebSearchTool from "./websearch"
|
||||
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect, Schema, Semaphore } from "effect"
|
||||
import { Form } from "../../form"
|
||||
import { KV } from "../../kv"
|
||||
import { Permission } from "../../permission"
|
||||
@@ -10,6 +10,7 @@ import { WebSearch } from "../../websearch"
|
||||
|
||||
export const name = "websearch"
|
||||
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.
|
||||
|
||||
@@ -29,6 +30,7 @@ export const Plugin = {
|
||||
const permission = yield* Permission.Service
|
||||
const forms = yield* Form.Service
|
||||
const kv = yield* KV.Service
|
||||
const websearch = yield* WebSearch.Service
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((draft) =>
|
||||
@@ -49,70 +51,90 @@ export const Plugin = {
|
||||
agent: context.agent,
|
||||
source: { type: "tool", messageID: context.messageID, id: context.id },
|
||||
})
|
||||
const result = yield* ctx.websearch.query(input).pipe(
|
||||
Effect.catch((error) => {
|
||||
if (!Schema.is(WebSearch.ProviderRequiredError)(error)) return Effect.fail(error)
|
||||
return Effect.gen(function* () {
|
||||
const providers = (yield* ctx.websearch.providers()).data
|
||||
const defaultProvider = providers[0]
|
||||
if (!defaultProvider) return yield* new WebSearch.ProviderRequiredError()
|
||||
const response = yield* forms.ask({
|
||||
sessionID: context.sessionID,
|
||||
title: "Web Search",
|
||||
metadata: { kind: "websearch.provider" },
|
||||
fields: [
|
||||
{
|
||||
key: "choice",
|
||||
description: "Allow OpenCode to search the web for up-to-date information?",
|
||||
type: "string",
|
||||
required: true,
|
||||
custom: false,
|
||||
options: [
|
||||
{
|
||||
value: "allow",
|
||||
label: `Allow web search via ${defaultProvider.name}`,
|
||||
},
|
||||
{
|
||||
value: "choose",
|
||||
label: "Choose another provider",
|
||||
},
|
||||
{ value: "disable", label: "Disable web search" },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
if (response.status === "cancelled") return yield* Effect.fail(new Error("Web search cancelled"))
|
||||
if (response.answer.choice === "disable") {
|
||||
yield* kv.set("websearch:provider", false)
|
||||
return yield* new WebSearch.DisabledError()
|
||||
}
|
||||
const selection =
|
||||
response.answer.choice === "choose"
|
||||
? yield* forms.ask({
|
||||
const search = (): Effect.Effect<Effect.Success<ReturnType<typeof ctx.websearch.query>>, unknown> =>
|
||||
ctx.websearch.query(input).pipe(
|
||||
Effect.catch((error) => {
|
||||
if (!Schema.is(WebSearch.ProviderRequiredError)(error)) return Effect.fail(error)
|
||||
return providerSelectionLock
|
||||
.withPermit(
|
||||
Effect.gen(function* () {
|
||||
if (yield* websearch.default()) return yield* Effect.void
|
||||
const providers = (yield* ctx.websearch.providers()).data
|
||||
const defaultProvider = providers[0]
|
||||
if (!defaultProvider) return yield* new WebSearch.ProviderRequiredError()
|
||||
const response = yield* forms.ask({
|
||||
sessionID: context.sessionID,
|
||||
title: "Choose a web search provider",
|
||||
title: "Web Search",
|
||||
metadata: { kind: "websearch.provider" },
|
||||
fields: [
|
||||
{
|
||||
key: "provider",
|
||||
description: "Choose a provider for web search.",
|
||||
key: "choice",
|
||||
description: "Allow OpenCode to search the web for up-to-date information?",
|
||||
type: "string",
|
||||
required: true,
|
||||
custom: false,
|
||||
options: providers.map((provider) => ({ value: provider.id, label: provider.name })),
|
||||
options: [
|
||||
{
|
||||
value: "allow",
|
||||
label: `Allow web search via ${defaultProvider.name}`,
|
||||
},
|
||||
{
|
||||
value: "choose",
|
||||
label: "Choose another provider",
|
||||
},
|
||||
{ value: "disable", label: "Disable web search" },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
: undefined
|
||||
if (selection?.status === "cancelled") return yield* Effect.fail(new Error("Web search cancelled"))
|
||||
const providerID = selection?.answer.provider ?? defaultProvider.id
|
||||
if (typeof providerID !== "string" || !providers.some((provider) => provider.id === providerID))
|
||||
return yield* new WebSearch.ProviderRequiredError()
|
||||
yield* kv.set("websearch:provider", providerID)
|
||||
return yield* ctx.websearch.query(input)
|
||||
})
|
||||
}),
|
||||
)
|
||||
if (response.status === "cancelled")
|
||||
return yield* Effect.fail(new Error("Web search cancelled"))
|
||||
if (response.answer.choice === "disable") {
|
||||
yield* kv.set("websearch:provider", false)
|
||||
return yield* new WebSearch.DisabledError()
|
||||
}
|
||||
const selection =
|
||||
response.answer.choice === "choose"
|
||||
? yield* forms.ask({
|
||||
sessionID: context.sessionID,
|
||||
title: "Choose a web search provider",
|
||||
metadata: { kind: "websearch.provider" },
|
||||
fields: [
|
||||
{
|
||||
key: "provider",
|
||||
description: "Choose a provider for web search.",
|
||||
type: "string",
|
||||
required: true,
|
||||
custom: false,
|
||||
options: providers.map((provider) => ({
|
||||
value: provider.id,
|
||||
label: provider.name,
|
||||
})),
|
||||
},
|
||||
],
|
||||
})
|
||||
: undefined
|
||||
if (selection?.status === "cancelled")
|
||||
return yield* Effect.fail(new Error("Web search cancelled"))
|
||||
const providerID = selection?.answer.provider ?? defaultProvider.id
|
||||
if (
|
||||
typeof providerID !== "string" ||
|
||||
!providers.some((provider) => provider.id === providerID)
|
||||
)
|
||||
return yield* new WebSearch.ProviderRequiredError()
|
||||
return yield* kv.set("websearch:provider", providerID)
|
||||
}),
|
||||
)
|
||||
.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 = {
|
||||
provider: result.data.providerID,
|
||||
results: result.data.results,
|
||||
|
||||
@@ -54,59 +54,52 @@ export const Plugin = {
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((draft) =>
|
||||
draft.add(
|
||||
({
|
||||
name,
|
||||
options: { codemode: false, permission: "edit" },
|
||||
description:
|
||||
"Writes a file to the local filesystem, overwriting if one exists.\n\nMissing parent directories are created automatically.\n\nUse this tool to create new files or overwrite existing files. For partial changes, use the edit tool instead.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const source = {
|
||||
type: "tool" as const,
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
}
|
||||
const target = yield* mutation.resolve({ path: input.path, kind: "file" })
|
||||
const external = target.externalDirectory
|
||||
if (external)
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const current = yield* Bom.readFile(fs, target.canonical).pipe(
|
||||
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)),
|
||||
)
|
||||
const next = Bom.split(input.content)
|
||||
const preview = fileDiff(
|
||||
target.resource,
|
||||
current?.text ?? "",
|
||||
next.text,
|
||||
current ? "modified" : "added",
|
||||
)
|
||||
yield* permission.assert({
|
||||
action: "edit",
|
||||
resources: [target.resource],
|
||||
save: ["*"],
|
||||
metadata: { files: [preview] },
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const result = yield* files.writeTextPreservingBom({ target, content: input.content })
|
||||
const bom = (yield* Bom.readFile(fs, target.canonical)).bom
|
||||
if (yield* formatter.file(target.canonical)) yield* Bom.syncFile(fs, target.canonical, bom)
|
||||
return result
|
||||
}).pipe(
|
||||
Effect.map((output) => ({ output, content: toModelOutput(output) })),
|
||||
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
|
||||
),
|
||||
}),
|
||||
),
|
||||
draft.add({
|
||||
name,
|
||||
options: { codemode: false, permission: "edit" },
|
||||
description:
|
||||
"Writes a file to the local filesystem, overwriting if one exists.\n\nMissing parent directories are created automatically.\n\nUse this tool to create new files or overwrite existing files. For partial changes, use the edit tool instead.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const source = {
|
||||
type: "tool" as const,
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
}
|
||||
const target = yield* mutation.resolve({ path: input.path, kind: "file" })
|
||||
const external = target.externalDirectory
|
||||
if (external)
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const current = yield* Bom.readFile(fs, target.absolute).pipe(
|
||||
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)),
|
||||
)
|
||||
const next = Bom.split(input.content)
|
||||
const preview = fileDiff(target.resource, current?.text ?? "", next.text, current ? "modified" : "added")
|
||||
yield* permission.assert({
|
||||
action: "edit",
|
||||
resources: [target.resource],
|
||||
save: ["*"],
|
||||
metadata: { files: [preview] },
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const result = yield* files.writeTextPreservingBom({ target, content: input.content })
|
||||
const bom = (yield* Bom.readFile(fs, target.absolute)).bom
|
||||
if (yield* formatter.file(target.absolute)) yield* Bom.syncFile(fs, target.absolute, bom)
|
||||
return result
|
||||
}).pipe(
|
||||
Effect.map((output) => ({ output, content: toModelOutput(output) })),
|
||||
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
|
||||
),
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
|
||||
@@ -34,14 +34,6 @@ 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>()(
|
||||
"ReadTool.OffsetOutOfRangeError",
|
||||
{ offset: Schema.Number },
|
||||
@@ -61,13 +53,7 @@ export class PathKindError extends Schema.TaggedErrorClass<PathKindError>()("Rea
|
||||
}
|
||||
|
||||
export type InspectError = FSUtil.Error | PathKindError
|
||||
export type ReadError =
|
||||
| FSUtil.Error
|
||||
| BinaryFileError
|
||||
| MediaIngestLimitError
|
||||
| MalformedUtf8Error
|
||||
| OffsetOutOfRangeError
|
||||
| PathKindError
|
||||
export type ReadError = FSUtil.Error | BinaryFileError | MediaIngestLimitError | OffsetOutOfRangeError | PathKindError
|
||||
|
||||
export const PageInput = Schema.Struct({
|
||||
offset: Schema.optionalKey(NonNegativeInt),
|
||||
@@ -90,9 +76,15 @@ export class TextPage extends Schema.Class<TextPage>("ReadTool.TextPage")({
|
||||
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")({
|
||||
type: Schema.Literal("list-page"),
|
||||
entries: Schema.Array(FileSystem.Entry),
|
||||
entries: Schema.Array(ListEntry),
|
||||
truncated: Schema.Boolean,
|
||||
next: Schema.optionalKey(PositiveInt),
|
||||
}) {}
|
||||
@@ -109,36 +101,6 @@ export interface Interface {
|
||||
|
||||
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 mediaMime = (bytes: Uint8Array) => {
|
||||
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png"
|
||||
@@ -148,8 +110,7 @@ const mediaMime = (bytes: Uint8Array) => {
|
||||
return "image/webp"
|
||||
if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d])) return "application/pdf"
|
||||
}
|
||||
const binary = (resource: string, bytes: Uint8Array) => {
|
||||
if (extensions.has(path.extname(resource).toLowerCase())) return true
|
||||
const binary = (bytes: Uint8Array) => {
|
||||
if (bytes.length === 0) return false
|
||||
let nonPrintable = 0
|
||||
for (const byte of bytes) {
|
||||
@@ -158,16 +119,9 @@ const binary = (resource: string, bytes: Uint8Array) => {
|
||||
}
|
||||
return nonPrintable / bytes.length > 0.3
|
||||
}
|
||||
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 decodeUtf8 = (decoder: TextDecoder, bytes?: Uint8Array) => decoder.decode(bytes, { stream: bytes !== undefined })
|
||||
const decodeChunk = (resource: string, decoder: TextDecoder, bytes: Uint8Array) =>
|
||||
bytes.includes(0) ? Effect.fail(new BinaryFileError({ resource })) : decodeUtf8(resource, decoder, bytes)
|
||||
bytes.includes(0) ? Effect.fail(new BinaryFileError({ resource })) : Effect.succeed(decodeUtf8(decoder, bytes))
|
||||
|
||||
export const inspect = Effect.fn("ReadTool.inspect")(function* (fs: FSUtil.Interface, input: string) {
|
||||
const info = yield* fs.stat(input)
|
||||
@@ -218,19 +172,17 @@ export const read = Effect.fn("ReadTool.read")(function* (
|
||||
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
|
||||
if (!paged) {
|
||||
if (binary(resource, first)) return yield* Effect.fail(new BinaryFileError({ resource }))
|
||||
const decoder = new TextDecoder("utf-8", { fatal: true })
|
||||
const text = [yield* decodeUtf8(resource, decoder, first)]
|
||||
if (binary(first)) return yield* Effect.fail(new BinaryFileError({ resource }))
|
||||
const decoder = new TextDecoder()
|
||||
const text = [decodeUtf8(decoder, first)]
|
||||
while (true) {
|
||||
const chunk = yield* file.readAlloc(64 * 1024)
|
||||
if (Option.isNone(chunk)) break
|
||||
text.push(yield* decodeChunk(resource, decoder, chunk.value))
|
||||
}
|
||||
text.push(yield* decodeUtf8(resource, decoder))
|
||||
text.push(decodeUtf8(decoder))
|
||||
return {
|
||||
type: "file" as const,
|
||||
uri: pathToFileURL(real).href,
|
||||
@@ -243,7 +195,7 @@ export const read = Effect.fn("ReadTool.read")(function* (
|
||||
const offset = page.offset || 1
|
||||
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
|
||||
const lines: string[] = []
|
||||
const decoder = new TextDecoder("utf-8", { fatal: true })
|
||||
const decoder = new TextDecoder()
|
||||
let pending = ""
|
||||
let discard = false
|
||||
let line = 1
|
||||
@@ -301,8 +253,8 @@ export const read = Effect.fn("ReadTool.read")(function* (
|
||||
const newline = chunk.indexOf(10, start)
|
||||
const end = newline === -1 ? chunk.length : newline + 1
|
||||
const segment = chunk.subarray(start, end)
|
||||
if (binary(resource, segment)) return yield* Effect.fail(new BinaryFileError({ resource }))
|
||||
if (!consume(yield* decodeUtf8(resource, decoder, segment))) return false
|
||||
if (binary(segment)) return yield* Effect.fail(new BinaryFileError({ resource }))
|
||||
if (!consume(decodeUtf8(decoder, segment))) return false
|
||||
start = end
|
||||
}
|
||||
return true
|
||||
@@ -314,7 +266,7 @@ export const read = Effect.fn("ReadTool.read")(function* (
|
||||
done = !(yield* consumeChunk(chunk.value))
|
||||
}
|
||||
if (!done) {
|
||||
const tail = yield* decodeUtf8(resource, decoder)
|
||||
const tail = decodeUtf8(decoder)
|
||||
if (!discard) pending += tail
|
||||
if (pending) append(pending.endsWith("\r") ? pending.slice(0, -1) : pending)
|
||||
}
|
||||
@@ -336,26 +288,26 @@ export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface,
|
||||
const items = yield* fs.readDirectoryEntries(real)
|
||||
const offset = page.offset || 1
|
||||
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
|
||||
const entries = yield* Effect.forEach(
|
||||
items,
|
||||
(item) =>
|
||||
Effect.gen(function* () {
|
||||
const absolute = path.join(real, item.name)
|
||||
const target = yield* fs.realPath(absolute).pipe(Effect.catch(() => Effect.void))
|
||||
if (!target || !FSUtil.contains(real, target)) return
|
||||
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 },
|
||||
)
|
||||
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 visible = items
|
||||
.flatMap((item) =>
|
||||
item.type === "other"
|
||||
? []
|
||||
: [
|
||||
ListEntry.make({
|
||||
path: RelativePath.make(item.name + (item.type === "directory" ? path.sep : "")),
|
||||
type: item.type,
|
||||
}),
|
||||
],
|
||||
)
|
||||
.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 selected = visible.slice(offset - 1, offset - 1 + limit)
|
||||
const truncated = offset - 1 + selected.length < visible.length
|
||||
return new ListPage({
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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 { ConfigAgentV1 } from "./agent"
|
||||
import { ConfigCommandV1 } from "./command"
|
||||
@@ -10,80 +13,52 @@ import { ConfigProviderOptionsV1 } from "./provider-options"
|
||||
import { Provider } from "../../provider"
|
||||
import { Model } from "../../model"
|
||||
|
||||
const keys = new Set([
|
||||
"logLevel",
|
||||
"server",
|
||||
"command",
|
||||
"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)
|
||||
)
|
||||
}
|
||||
|
||||
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
||||
const decodeInfo = Schema.decodeUnknownSync(Schema.fromJsonString(Info), decodeOptions)
|
||||
const encodeInfo = Schema.encodeSync(Info)
|
||||
const decodeAgent = Schema.decodeUnknownSync(Schema.fromJsonString(ConfigAgent.Info), decodeOptions)
|
||||
const encodeAgent = Schema.encodeSync(ConfigAgent.Info)
|
||||
export function migrate(info: typeof ConfigV1.Info.Type) {
|
||||
return {
|
||||
$schema: info.$schema,
|
||||
shell: info.shell,
|
||||
model: modelSelection(info.model),
|
||||
default_agent: info.default_agent,
|
||||
autoupdate: info.autoupdate,
|
||||
share: info.share ?? (info.autoshare ? "auto" : undefined),
|
||||
enterprise: info.enterprise,
|
||||
username: info.username,
|
||||
permissions: permissions(info.permission, info.tools),
|
||||
agents: agents(info),
|
||||
snapshots: info.snapshot,
|
||||
watcher: info.watcher,
|
||||
formatter: info.formatter,
|
||||
lsp: info.lsp,
|
||||
media: info.attachment,
|
||||
tool_output: info.tool_output,
|
||||
mcp: mcp(info),
|
||||
compaction: info.compaction && {
|
||||
auto: info.compaction.auto,
|
||||
prune: info.compaction.prune,
|
||||
keep: {
|
||||
tokens: info.compaction.preserve_recent_tokens,
|
||||
},
|
||||
buffer: info.compaction.reserved,
|
||||
},
|
||||
skills: info.skills && [...(info.skills.paths ?? []), ...(info.skills.urls ?? [])],
|
||||
commands: commands(info.command),
|
||||
instructions: info.instructions,
|
||||
references: info.references ?? info.reference,
|
||||
experimental: experimental(info),
|
||||
plugins: info.plugin?.map((plugin) =>
|
||||
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
|
||||
return encodeInfo(
|
||||
decodeInfo(
|
||||
JSON.stringify({
|
||||
$schema: info.$schema,
|
||||
shell: info.shell,
|
||||
model: modelSelection(info.model),
|
||||
default_agent: info.default_agent,
|
||||
autoupdate: info.autoupdate,
|
||||
share: info.share ?? (info.autoshare ? "auto" : undefined),
|
||||
enterprise: info.enterprise,
|
||||
username: info.username,
|
||||
permissions: permissions(info.permission, info.tools),
|
||||
agents: agents(info),
|
||||
snapshots: info.snapshot,
|
||||
watcher: info.watcher,
|
||||
formatter: info.formatter,
|
||||
lsp: info.lsp,
|
||||
media: info.attachment,
|
||||
tool_output: info.tool_output,
|
||||
mcp: mcp(info),
|
||||
compaction: info.compaction && {
|
||||
auto: info.compaction.auto,
|
||||
prune: info.compaction.prune,
|
||||
keep: {
|
||||
tokens: info.compaction.preserve_recent_tokens,
|
||||
},
|
||||
buffer: info.compaction.reserved,
|
||||
},
|
||||
skills: info.skills && [...(info.skills.paths ?? []), ...(info.skills.urls ?? [])],
|
||||
commands: commands(info.command),
|
||||
instructions: info.instructions,
|
||||
references: info.references ?? info.reference,
|
||||
experimental: experimental(info),
|
||||
plugins: info.plugin?.map((plugin) =>
|
||||
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) {
|
||||
@@ -132,7 +107,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.
|
||||
function normalizeAction(action: string) {
|
||||
export function normalizeAction(action: string) {
|
||||
if (action === "write" || action === "patch") return "edit"
|
||||
if (action === "task") return "subagent"
|
||||
if (action === "bash") return "shell"
|
||||
@@ -144,8 +119,16 @@ function agents(info: typeof ConfigV1.Info.Type) {
|
||||
...Object.entries(info.agent ?? {}),
|
||||
...Object.entries(info.mode ?? {}).map(([name, agent]) => [name, { ...agent, mode: "primary" as const }] as const),
|
||||
]
|
||||
if (!entries.length) return undefined
|
||||
return Object.fromEntries(entries.flatMap(([name, agent]) => (agent ? [[name, migrateAgent(agent)]] : [])))
|
||||
const result = Object.fromEntries(entries.flatMap(([name, agent]) => (agent ? [[name, migrateAgent(agent)]] : [])))
|
||||
const small = modelSelection(info.small_model)
|
||||
if (!small) return entries.length ? result : undefined
|
||||
return {
|
||||
...result,
|
||||
title: {
|
||||
model: small,
|
||||
...result.title,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function migrateAgent(info: ConfigAgentV1.Info) {
|
||||
@@ -154,21 +137,25 @@ export function migrateAgent(info: ConfigAgentV1.Info) {
|
||||
...(info.temperature === undefined ? {} : { temperature: info.temperature }),
|
||||
...(info.top_p === undefined ? {} : { top_p: info.top_p }),
|
||||
}
|
||||
return {
|
||||
model: modelSelection(info.model, info.variant),
|
||||
request: Object.keys(body).length ? { body } : undefined,
|
||||
system: info.prompt,
|
||||
description: info.description,
|
||||
mode: info.mode,
|
||||
hidden: info.hidden,
|
||||
color: info.color === undefined ? undefined : info.color.startsWith("#") ? info.color : "#aaaaaa",
|
||||
steps: info.steps,
|
||||
disabled: info.disable,
|
||||
permissions: permissions(info.permission),
|
||||
}
|
||||
return encodeAgent(
|
||||
decodeAgent(
|
||||
JSON.stringify({
|
||||
model: modelSelection(info.model, info.variant),
|
||||
request: Object.keys(body).length ? { body } : undefined,
|
||||
system: info.prompt,
|
||||
description: info.description,
|
||||
mode: info.mode,
|
||||
hidden: info.hidden,
|
||||
color: info.color === undefined ? undefined : info.color.startsWith("#") ? info.color : "#aaaaaa",
|
||||
steps: info.steps,
|
||||
disabled: info.disable,
|
||||
permissions: permissions(info.permission),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function commands(info?: Readonly<Record<string, ConfigCommandV1.Info>>) {
|
||||
export function commands(info?: Readonly<Record<string, ConfigCommandV1.Info>>) {
|
||||
if (!info) return undefined
|
||||
return Object.fromEntries(
|
||||
Object.entries(info).map(([id, command]) => [
|
||||
@@ -205,7 +192,7 @@ function mcp(info: typeof ConfigV1.Info.Type) {
|
||||
return { timeout: timeout === undefined ? undefined : { catalog: timeout, execution: timeout }, servers }
|
||||
}
|
||||
|
||||
function migrateMcp(info: ConfigMCPV1.Info) {
|
||||
export function migrateMcp(info: ConfigMCPV1.Info) {
|
||||
const disabled = info.enabled === undefined ? undefined : !info.enabled
|
||||
if (info.type === "local")
|
||||
return {
|
||||
@@ -244,7 +231,7 @@ function providers(info?: Readonly<Record<string, ConfigProviderV1.Info>>) {
|
||||
)
|
||||
}
|
||||
|
||||
function migrateProvider(sourceID: string, info: ConfigProviderV1.Info) {
|
||||
export function migrateProvider(sourceID: string, info: ConfigProviderV1.Info) {
|
||||
if (sourceID === "azure-cognitive-services") return migrateAzureCognitiveServicesProvider(info)
|
||||
if (sourceID === "google-vertex-anthropic") return migrateGoogleVertexAnthropicProvider(info)
|
||||
return migrateStandardProvider(info)
|
||||
@@ -256,7 +243,7 @@ function migrateStandardProvider(info: ConfigProviderV1.Info) {
|
||||
name: info.name,
|
||||
env: info.env,
|
||||
package: info.npm ? Provider.aisdk(info.npm) : undefined,
|
||||
settings: info.api ? { ...options.settings, baseURL: info.api } : options.settings,
|
||||
settings: info.api ? { ...options.settings, baseURL: info.api } : info.options ? options.settings : undefined,
|
||||
headers: info.options && options.headers,
|
||||
body: info.options && options.body,
|
||||
models:
|
||||
@@ -300,8 +287,8 @@ function migrateGoogleVertexAnthropicProvider(info: ConfigProviderV1.Info) {
|
||||
}
|
||||
}
|
||||
|
||||
// Rename these only in files detected as V1 by a field that exists only in the old config format.
|
||||
function providerID(input: string) {
|
||||
// Rename these only while migrating unambiguous V1 fields.
|
||||
export function providerID(input: string) {
|
||||
if (input === "azure-cognitive-services") return "azure"
|
||||
if (input === "google-vertex-anthropic") return "google-vertex"
|
||||
return input
|
||||
|
||||
@@ -126,6 +126,7 @@ describe("Agent", () => {
|
||||
|
||||
yield* agent.transform((editor) => editor.update(id, () => {}))
|
||||
const info = yield* agent.get(id)
|
||||
expect(info?.mode).toBe("primary")
|
||||
expect(info?.permissions.slice(0, Agent.Info.default(id).permissions.length)).toEqual(
|
||||
Agent.Info.default(id).permissions,
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { AgentPlugin } from "@opencode-ai/core/plugin/agent"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
|
||||
import { advance, drain } from "../lib/clock"
|
||||
@@ -50,6 +51,11 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
||||
it.effect("matches Windows paths against home-relative permissions", () =>
|
||||
Effect.gen(function* () {
|
||||
const permissions = yield* loadHomePermissions("C:\\Users\\test")
|
||||
expect(permissions).toContainEqual({
|
||||
action: "external_directory",
|
||||
resource: "C:\\Users\\test\\p\\**",
|
||||
effect: "allow",
|
||||
})
|
||||
expect(
|
||||
Permission.evaluate("external_directory", "C:\\Users\\test\\p\\opencode\\src\\*", permissions).effect,
|
||||
).toBe("allow")
|
||||
@@ -59,6 +65,96 @@ 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", () =>
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* Agent.Service
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Fiber, Layer, PubSub, Schema, Stream } from "effect"
|
||||
import { Effect, Fiber, Layer, Logger, PubSub, Schema, Stream } from "effect"
|
||||
import { FastCheck } from "effect/testing"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { AgentsDirectory, Directory, Document, Event, Info } from "@opencode-ai/schema/config"
|
||||
@@ -307,7 +307,7 @@ describe("Config", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("loads authenticated wellknown config at highest priority", () =>
|
||||
it.live("loads authenticated wellknown config before user configuration", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) =>
|
||||
@@ -370,7 +370,13 @@ describe("Config", () => {
|
||||
return yield* Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const bus = yield* Bus.Service
|
||||
expect(Config.latest(yield* config.entries(), "shell")).toBe("secret")
|
||||
const initial = yield* config.entries()
|
||||
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
|
||||
.subscribe(Event.Updated)
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
@@ -378,7 +384,13 @@ describe("Config", () => {
|
||||
key = "next"
|
||||
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID })
|
||||
expect(yield* Fiber.join(updated)).toHaveLength(1)
|
||||
expect(Config.latest(yield* config.entries(), "shell")).toBe("next")
|
||||
const refreshed = yield* config.entries()
|
||||
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(
|
||||
Effect.provide(testLayer(project, global, project, undefined, undefined, credentialNode, wellknownNode)),
|
||||
)
|
||||
@@ -387,27 +399,96 @@ describe("Config", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("detects v1 configuration from any v1-only top-level key", () =>
|
||||
Effect.sync(() => {
|
||||
expect(ConfigMigrateV1.isV1({ snapshot: false })).toBe(true)
|
||||
expect(ConfigMigrateV1.isV1({ snapshot: false, agents: {} })).toBe(true)
|
||||
expect(ConfigMigrateV1.isV1({ reference: {} })).toBe(true)
|
||||
expect(ConfigMigrateV1.isV1({ shell: "/bin/zsh", model: "anthropic/claude" })).toBe(false)
|
||||
expect(ConfigMigrateV1.isV1({ references: {} })).toBe(false)
|
||||
}),
|
||||
)
|
||||
it.live("logs redacted source-aware diagnostics for every config source", () => {
|
||||
const output: Array<Record<string, unknown>> = []
|
||||
const logger = Logger.map(Logger.formatStructured, (entry) => {
|
||||
if (!Array.isArray(entry.message) || entry.message[0] !== "configuration normalization diagnostic") return
|
||||
const details = entry.message[1]
|
||||
if (typeof details === "object" && details !== null) output.push(details as Record<string, unknown>)
|
||||
})
|
||||
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: [],
|
||||
})
|
||||
|
||||
it.effect("detects a bare v1-shaped mcp block while leaving v2 mcp config alone", () =>
|
||||
Effect.sync(() => {
|
||||
// V1 lists servers directly under `mcp`, so a file with only `$schema` + `mcp` still migrates.
|
||||
expect(ConfigMigrateV1.isV1({ mcp: { context7: { type: "local", command: ["npx"] } } })).toBe(true)
|
||||
expect(ConfigMigrateV1.isV1({ $schema: "x", mcp: { executor: { type: "remote", url: "https://x" } } })).toBe(true)
|
||||
// Current config nests under `mcp.servers`, so it must not be misdetected and re-migrated.
|
||||
expect(ConfigMigrateV1.isV1({ mcp: { servers: { context7: { type: "local", command: ["npx"] } } } })).toBe(false)
|
||||
expect(ConfigMigrateV1.isV1({ mcp: {} })).toBe(false)
|
||||
expect(ConfigMigrateV1.isV1({ mcp: { timeout: { execution: 1000 } } })).toBe(false)
|
||||
}),
|
||||
)
|
||||
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(
|
||||
[
|
||||
`${path.join(global, "opencode.json")}:$:invalid`,
|
||||
`${path.join(project, "opencode.json")}:$:invalid`,
|
||||
`${malformed}:$:invalid`,
|
||||
"https://invalid.example.com:$:invalid",
|
||||
"OPENCODE_CONFIG_CONTENT:$:invalid",
|
||||
].toSorted(),
|
||||
)
|
||||
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", () =>
|
||||
Effect.sync(() => {
|
||||
@@ -431,6 +512,20 @@ 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", () =>
|
||||
Effect.sync(() => {
|
||||
expect(
|
||||
@@ -516,12 +611,12 @@ describe("Config", () => {
|
||||
})
|
||||
expect(migrated.providers?.["azure-cognitive-services"]).toBeUndefined()
|
||||
expect(migrated.providers?.["google-vertex"]).toMatchObject({
|
||||
package: undefined,
|
||||
settings: { project: "test-project", location: "us-central1" },
|
||||
models: {
|
||||
"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()
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
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,7 +237,11 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
models: {
|
||||
chat: {
|
||||
name: "First",
|
||||
compatibility: { reasoningField: "vendor_reasoning" },
|
||||
compatibility: {
|
||||
reasoningField: "vendor_reasoning",
|
||||
maxTokensField: "max_completion_tokens",
|
||||
requireFinishReason: false,
|
||||
},
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
disabled: true,
|
||||
limit: { context: 100, output: 50 },
|
||||
@@ -318,7 +322,11 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
expect(model.id).toBe(modelID)
|
||||
expect(model.modelID).toBe(Model.ID.make("api-chat"))
|
||||
expect(model.name).toBe("Last")
|
||||
expect(model.compatibility).toEqual({ reasoningField: "vendor_reasoning" })
|
||||
expect(model.compatibility).toEqual({
|
||||
reasoningField: "vendor_reasoning",
|
||||
maxTokensField: "max_completion_tokens",
|
||||
requireFinishReason: false,
|
||||
})
|
||||
expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
|
||||
expect(model.enabled).toBe(false)
|
||||
expect(model.limit).toEqual({ context: 100, output: 75 })
|
||||
|
||||
@@ -43,7 +43,7 @@ describe("FileMutation", () => {
|
||||
|
||||
expect(yield* (yield* FileMutation.Service).write({ target, content: "after" })).toEqual({
|
||||
operation: "write",
|
||||
target: target.canonical,
|
||||
target: target.absolute,
|
||||
resource: "hello.txt",
|
||||
existed: true,
|
||||
})
|
||||
@@ -62,11 +62,11 @@ describe("FileMutation", () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
operation: "write",
|
||||
target: target.canonical,
|
||||
target: target.absolute,
|
||||
resource: "src/nested/hello.txt",
|
||||
existed: false,
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(result.target, "utf8"))).toBe("hello")
|
||||
expect(yield* Effect.promise(() => fs.readFile(target.absolute, "utf8"))).toBe("hello")
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
@@ -84,7 +84,7 @@ describe("FileMutation", () => {
|
||||
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(created.canonical, "utf8"))).toBe("\uFEFFcreated")
|
||||
expect(yield* Effect.promise(() => fs.readFile(created.absolute, "utf8"))).toBe("\uFEFFcreated")
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
@@ -99,7 +99,7 @@ describe("FileMutation", () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
operation: "write",
|
||||
target: target.canonical,
|
||||
target: target.absolute,
|
||||
resource: target.resource,
|
||||
existed: false,
|
||||
})
|
||||
@@ -109,7 +109,7 @@ describe("FileMutation", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("serializes concurrent writes to the same canonical target", () =>
|
||||
it.live("serializes concurrent writes to the same absolute target", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(directory, "shared.txt")
|
||||
@@ -152,7 +152,7 @@ describe("FileMutation", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("allows distinct canonical targets to proceed independently", () =>
|
||||
it.live("allows distinct absolute targets to proceed independently", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
|
||||
@@ -37,7 +37,7 @@ describe("LocationMutation", () => {
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "hello.txt" })
|
||||
|
||||
expect(target).toMatchObject({
|
||||
canonical: yield* Effect.promise(() => fs.realpath(targetPath)),
|
||||
absolute: targetPath,
|
||||
resource: "hello.txt",
|
||||
})
|
||||
expect(target.externalDirectory).toBeUndefined()
|
||||
@@ -50,10 +50,8 @@ describe("LocationMutation", () => {
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("src", "new.txt") })
|
||||
const root = yield* Effect.promise(() => fs.realpath(directory))
|
||||
|
||||
expect(target).toMatchObject({
|
||||
canonical: path.join(root, "src", "new.txt"),
|
||||
absolute: path.join(directory, "src", "new.txt"),
|
||||
resource: "src/new.txt",
|
||||
})
|
||||
}).pipe(provide(directory)),
|
||||
@@ -64,9 +62,9 @@ describe("LocationMutation", () => {
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "../outside.txt" })
|
||||
const root = yield* Effect.promise(() => fs.realpath(path.dirname(directory)))
|
||||
const root = path.dirname(directory)
|
||||
expect(target).toMatchObject({
|
||||
canonical: path.join(root, "outside.txt"),
|
||||
absolute: path.join(root, "outside.txt"),
|
||||
resource: path.join(root, "outside.txt").replaceAll("\\", "/"),
|
||||
})
|
||||
expect(target.externalDirectory).toMatchObject({
|
||||
@@ -77,7 +75,7 @@ describe("LocationMutation", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("authorizes a prospective target below an external symlink by its in-location path", () =>
|
||||
it.live("resolves a prospective target below an external symlink lexically", () =>
|
||||
withTmp((directory) => {
|
||||
const outside = `${directory}-outside`
|
||||
return Effect.gen(function* () {
|
||||
@@ -88,7 +86,7 @@ describe("LocationMutation", () => {
|
||||
})
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("escape", "new.txt") })
|
||||
expect(target).toMatchObject({
|
||||
canonical: path.join(yield* Effect.promise(() => fs.realpath(outside)), "new.txt"),
|
||||
absolute: path.join(directory, "escape", "new.txt"),
|
||||
resource: "escape/new.txt",
|
||||
})
|
||||
expect(target.externalDirectory).toBeUndefined()
|
||||
@@ -107,7 +105,7 @@ describe("LocationMutation", () => {
|
||||
})
|
||||
|
||||
expect(yield* (yield* LocationMutation.Service).resolve({ path: "linked/new.txt" })).toMatchObject({
|
||||
canonical: path.join(yield* Effect.promise(() => fs.realpath(directory)), "actual", "new.txt"),
|
||||
absolute: path.join(directory, "linked", "new.txt"),
|
||||
resource: "linked/new.txt",
|
||||
})
|
||||
}).pipe(provide(directory)),
|
||||
@@ -120,7 +118,7 @@ describe("LocationMutation", () => {
|
||||
const targetPath = path.join(directory, "new.txt")
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||
expect(target).toMatchObject({
|
||||
canonical: path.join(yield* Effect.promise(() => fs.realpath(directory)), "new.txt"),
|
||||
absolute: targetPath,
|
||||
resource: "new.txt",
|
||||
})
|
||||
expect(target.externalDirectory).toBeUndefined()
|
||||
@@ -134,9 +132,9 @@ describe("LocationMutation", () => {
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(outside, "new.txt")
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||
const root = yield* Effect.promise(() => fs.realpath(outside))
|
||||
const root = outside
|
||||
expect(target).toMatchObject({
|
||||
canonical: path.join(root, "new.txt"),
|
||||
absolute: path.join(root, "new.txt"),
|
||||
resource: path.join(root, "new.txt").replaceAll("\\", "/"),
|
||||
})
|
||||
expect(target.externalDirectory).toMatchObject({
|
||||
@@ -155,24 +153,23 @@ describe("LocationMutation", () => {
|
||||
const targetPath = path.join(outside, "existing.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "existing"))
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||
const root = yield* Effect.promise(() => fs.realpath(outside))
|
||||
expect(target).toMatchObject({ canonical: path.join(root, "existing.txt") })
|
||||
expect(target.externalDirectory?.directory).toBe(root)
|
||||
expect(target).toMatchObject({ absolute: targetPath })
|
||||
expect(target.externalDirectory?.directory).toBe(outside)
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("anchors prospective external descendants at their stable existing directory", () =>
|
||||
it.live("authorizes prospective external descendants at their lexical parent", () =>
|
||||
withTmp((directory) =>
|
||||
withTmp((outside) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(outside, "new", "nested", "file.txt")
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||
const root = yield* Effect.promise(() => fs.realpath(outside))
|
||||
const parent = path.dirname(targetPath)
|
||||
expect(target.externalDirectory).toMatchObject({
|
||||
directory: root,
|
||||
resource: path.join(root, "*").replaceAll("\\", "/"),
|
||||
directory: parent,
|
||||
resource: path.join(parent, "*").replaceAll("\\", "/"),
|
||||
})
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
|
||||
@@ -151,6 +151,9 @@ describe("ModelResolver", () => {
|
||||
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")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -191,7 +194,11 @@ describe("ModelResolver", () => {
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/openai-compatible"), {
|
||||
compatibility: { reasoningField: "vendor_reasoning" },
|
||||
compatibility: {
|
||||
reasoningField: "vendor_reasoning",
|
||||
maxTokensField: "max_completion_tokens",
|
||||
requireFinishReason: false,
|
||||
},
|
||||
settings: {
|
||||
apiKey: "settings-secret",
|
||||
baseURL: "https://compatible.example/v1",
|
||||
@@ -201,7 +208,8 @@ describe("ModelResolver", () => {
|
||||
body: {},
|
||||
}),
|
||||
)
|
||||
const request = LLM.request({ model: resolved, prompt: "Hello" })
|
||||
const request = LLM.request({ model: resolved, prompt: "Hello", generation: { maxTokens: 10 } })
|
||||
const prepared = yield* compileRequest(request)
|
||||
const headers = yield* resolved.route.auth.apply({
|
||||
request,
|
||||
method: "POST",
|
||||
@@ -213,6 +221,10 @@ describe("ModelResolver", () => {
|
||||
expect(headers.authorization).toBe("Bearer settings-secret")
|
||||
expect(resolved.route.id).toBe("openai-compatible-chat")
|
||||
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.defaults.http?.body).toEqual({})
|
||||
}),
|
||||
|
||||
@@ -126,7 +126,7 @@ describe("OpenAIPlugin", () => {
|
||||
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.cost).toEqual([])
|
||||
expect(eligible.limit).toEqual({ context: 272_000, input: 272_000, output: 128_000 })
|
||||
expect(eligible.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
|
||||
expect(eligible.enabled).toBe(true)
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5-pro"))).enabled).toBe(
|
||||
false,
|
||||
@@ -135,14 +135,14 @@ describe("OpenAIPlugin", () => {
|
||||
false,
|
||||
)
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.4"))).limit).toEqual({
|
||||
context: 272_000,
|
||||
context: 400_000,
|
||||
input: 272_000,
|
||||
output: 64_000,
|
||||
})
|
||||
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")))
|
||||
expect(gpt56.enabled).toBe(true)
|
||||
expect(gpt56.limit).toEqual({ context: 272_000, input: 272_000, output: 128_000 })
|
||||
expect(gpt56.limit).toEqual({ context: 400_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)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -23,6 +23,7 @@ import { SessionPending } from "@opencode-ai/core/session/pending"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { SessionTransfer } from "@opencode-ai/core/session/transfer"
|
||||
import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
@@ -37,7 +38,14 @@ const projects = Layer.succeed(
|
||||
)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
LayerNode.group([
|
||||
Database.node,
|
||||
Bus.node,
|
||||
SessionProjector.node,
|
||||
SessionStore.node,
|
||||
Session.node,
|
||||
SessionTransfer.node,
|
||||
]),
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[Project.node, projects],
|
||||
@@ -740,3 +748,77 @@ 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([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1086,4 +1086,78 @@ describe("Session.pending", () => {
|
||||
expect(yield* session.pending(sessionID)).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("cancels pending input and allows its ID to be admitted again", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* Session.Service
|
||||
const inputID = SessionMessage.ID.make("msg_cancelled_queue")
|
||||
yield* session.prompt({
|
||||
id: inputID,
|
||||
sessionID,
|
||||
text: "Queue this",
|
||||
delivery: "queue",
|
||||
resume: false,
|
||||
})
|
||||
|
||||
yield* session.cancelPending({ sessionID, inputID })
|
||||
|
||||
expect(yield* session.pending(sessionID)).toEqual([])
|
||||
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1)
|
||||
expect(
|
||||
yield* session.cancelPending({ sessionID, inputID }).pipe(Effect.flip),
|
||||
).toMatchObject({ _tag: "Session.PendingInputConflictError", sessionID, inputID })
|
||||
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1)
|
||||
|
||||
const retried = yield* session.prompt({
|
||||
id: inputID,
|
||||
sessionID,
|
||||
text: "Queue this",
|
||||
delivery: "queue",
|
||||
resume: false,
|
||||
})
|
||||
expect(retried).toMatchObject({ id: inputID, delivery: "queue" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("moves pending input between steer and queue delivery", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* Session.Service
|
||||
const queued = yield* session.synthetic({
|
||||
sessionID,
|
||||
text: "Steer this",
|
||||
delivery: "queue",
|
||||
resume: false,
|
||||
})
|
||||
const alreadySteered = yield* session.prompt({ sessionID, text: "Already steer", resume: false })
|
||||
wakeCalls.length = 0
|
||||
|
||||
yield* session.steerPending({ sessionID, inputID: queued.id })
|
||||
|
||||
expect(yield* session.pending(sessionID)).toMatchObject([
|
||||
{ id: queued.id, delivery: "steer" },
|
||||
{ id: alreadySteered.id, delivery: "steer" },
|
||||
])
|
||||
expect(wakeCalls).toEqual([sessionID])
|
||||
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputSteered.type, 1))).toBe(1)
|
||||
|
||||
wakeCalls.length = 0
|
||||
yield* session.queuePending({ sessionID, inputID: queued.id })
|
||||
expect(yield* session.pending(sessionID)).toMatchObject([
|
||||
{ id: queued.id, delivery: "queue" },
|
||||
{ id: alreadySteered.id, delivery: "steer" },
|
||||
])
|
||||
expect(wakeCalls).toEqual([])
|
||||
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputQueued.type, 1))).toBe(1)
|
||||
|
||||
expect(
|
||||
yield* session.steerPending({ sessionID, inputID: alreadySteered.id }).pipe(Effect.flip),
|
||||
).toMatchObject({ _tag: "Session.PendingInputConflictError", sessionID, inputID: alreadySteered.id })
|
||||
yield* session.cancelPending({ sessionID, inputID: alreadySteered.id })
|
||||
expect(wakeCalls).toEqual([])
|
||||
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputSteered.type, 1))).toBe(1)
|
||||
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
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,4 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect, FileSystem } from "effect"
|
||||
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
|
||||
@@ -50,22 +51,53 @@ describe("ReadToolFileSystem", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reports binary and malformed UTF-8 content as typed errors", () =>
|
||||
it.effect("reads malformed UTF-8 lossily and still rejects null-byte binary content", () =>
|
||||
Effect.gen(function* () {
|
||||
const { fs, files, directory } = yield* fixture
|
||||
const binary = path.join(directory, "archive.dat")
|
||||
const malformed = path.join(directory, "malformed.txt")
|
||||
yield* files.writeFile(binary, Uint8Array.of(0, 1, 2, 3))
|
||||
const malformedContent = new Uint8Array(64 * 1024 + 1).fill(97)
|
||||
malformedContent[64 * 1024] = 0x80
|
||||
yield* files.writeFile(malformed, malformedContent)
|
||||
yield* files.writeFile(malformed, Uint8Array.of(0x68, 0x69, 0x80))
|
||||
|
||||
const binaryError = yield* ReadToolFileSystem.read(fs, binary, "archive.dat").pipe(Effect.flip)
|
||||
const malformedError = yield* ReadToolFileSystem.read(fs, malformed, "malformed.txt").pipe(Effect.flip)
|
||||
const malformedResult = yield* ReadToolFileSystem.read(fs, malformed, "malformed.txt")
|
||||
|
||||
expect(binaryError).toBeInstanceOf(ReadToolFileSystem.BinaryFileError)
|
||||
expect(binaryError.message).toBe("Cannot read binary file: archive.dat")
|
||||
expect(malformedError).toBeInstanceOf(ReadToolFileSystem.MalformedUtf8Error)
|
||||
expect(malformedResult).toMatchObject({ type: "file", content: "hi\uFFFD", encoding: "utf8" })
|
||||
}),
|
||||
)
|
||||
|
||||
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.of({
|
||||
resolve: (input) => {
|
||||
const canonical = path.resolve(process.cwd(), input.path)
|
||||
const external = path.isAbsolute(input.path) && !FSUtil.contains(process.cwd(), canonical)
|
||||
const resource = external ? canonical.replaceAll("\\", "/") : path.relative(process.cwd(), canonical) || "."
|
||||
const directory = path.dirname(canonical)
|
||||
const absolute = path.resolve(process.cwd(), input.path)
|
||||
const external = path.isAbsolute(input.path) && !FSUtil.contains(process.cwd(), absolute)
|
||||
const resource = external ? absolute.replaceAll("\\", "/") : path.relative(process.cwd(), absolute) || "."
|
||||
const directory = path.dirname(absolute)
|
||||
const externalResource = path.join(directory, "*").replaceAll("\\", "/")
|
||||
return Effect.succeed({
|
||||
canonical,
|
||||
absolute,
|
||||
resource,
|
||||
externalDirectory: external
|
||||
? {
|
||||
@@ -311,9 +311,7 @@ describe("ReadTool", () => {
|
||||
})
|
||||
expect(settled.status).toBe("completed")
|
||||
if (settled.status !== "completed") return
|
||||
// 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.metadata).toEqual({ truncated: false })
|
||||
expect(settled.content).toMatchObject([
|
||||
{ type: "text", text: "Image read successfully" },
|
||||
{ type: "file", mime: "image/png", uri: `data:image/png;base64,${png}` },
|
||||
@@ -621,10 +619,6 @@ describe("ReadTool", () => {
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* Tool.Service
|
||||
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.PathKindError({ resource: "socket", expected: "a file" }),
|
||||
@@ -721,17 +715,21 @@ describe("ReadTool", () => {
|
||||
const registry = yield* Tool.Service
|
||||
|
||||
const result = yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-read-directory",
|
||||
name: "read",
|
||||
input: { path: "src", offset: 2, limit: 10 },
|
||||
},
|
||||
})
|
||||
expect(result).toMatchObject({ status: "completed", output: { entries: listResult.entries, truncated: true, next: 4 } })
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-read-directory",
|
||||
name: "read",
|
||||
input: { path: "src", offset: 2, limit: 10 },
|
||||
},
|
||||
})
|
||||
expect(result).toMatchObject({
|
||||
status: "completed",
|
||||
output: { entries: listResult.entries, truncated: true, next: 4 },
|
||||
})
|
||||
if (result.status !== "completed") return
|
||||
expect(result.metadata).toEqual({ truncated: true })
|
||||
expect(result.content).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
@@ -806,6 +804,7 @@ describe("ReadTool", () => {
|
||||
output: { type: "text-page", content: "hello", mime: "text/plain", offset: 2, truncated: true, next: 3 },
|
||||
})
|
||||
if (result.status !== "completed") return
|
||||
expect(result.metadata).toEqual({ truncated: true })
|
||||
expect(result.content).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
|
||||
@@ -30,6 +30,7 @@ import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||
import { Shell } from "@opencode-ai/core/shell"
|
||||
import { Shell as ShellSchema } from "@opencode-ai/schema/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 { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
@@ -171,6 +172,9 @@ const overflowCommand = (bytes: number) =>
|
||||
isWindows
|
||||
? `[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`
|
||||
const lineOverflowCommand = isWindows
|
||||
? "[Console]::Out.Write('one' + [Environment]::NewLine + 'two' + [Environment]::NewLine + 'three')"
|
||||
: "printf 'one\\ntwo\\nthree'"
|
||||
const progressOverflowCommand = (bytes: number, release: string) =>
|
||||
isWindows
|
||||
? `[Console]::Out.Write(('x' * ${bytes})); while (!(Test-Path -LiteralPath '${release}')) { Start-Sleep -Milliseconds 50 }`
|
||||
@@ -477,7 +481,7 @@ describe("ShellTool", () => {
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
const bytes = ShellTool.MAX_CAPTURE_BYTES + 1024
|
||||
const bytes = ToolOutput.MAX_BYTES + 1024
|
||||
return withSession(tmp.path, (registry) =>
|
||||
executeTool(registry, call({ command: overflowCommand(bytes) }, "call-overflow")),
|
||||
).pipe(
|
||||
@@ -501,6 +505,33 @@ describe("ShellTool", () => {
|
||||
{ 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(
|
||||
"reports the shell ID for a running command",
|
||||
() =>
|
||||
@@ -515,7 +546,7 @@ describe("ShellTool", () => {
|
||||
const observed = yield* Deferred.make<string>()
|
||||
yield* executeTool(registry, {
|
||||
...call(
|
||||
{ command: progressOverflowCommand(ShellTool.MAX_CAPTURE_BYTES + 1024, release) },
|
||||
{ command: progressOverflowCommand(ToolOutput.MAX_BYTES + 1024, release) },
|
||||
"call-progress",
|
||||
),
|
||||
progress: (update) =>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Deferred, Effect, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
@@ -39,6 +39,8 @@ const providers = [
|
||||
let providerRequired = false
|
||||
let formResponse: Form.TerminalState = { status: "cancelled" }
|
||||
const formResponses: Form.TerminalState[] = []
|
||||
let queryBarrier: Deferred.Deferred<void> | undefined
|
||||
let synchronizedQueries = 0
|
||||
let result = new WebSearch.Response({
|
||||
providerID: WebSearch.ID.make("exa"),
|
||||
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
|
||||
@@ -52,6 +54,8 @@ beforeEach(() => {
|
||||
providerRequired = false
|
||||
formResponse = { status: "cancelled" }
|
||||
formResponses.length = 0
|
||||
queryBarrier = undefined
|
||||
synchronizedQueries = 0
|
||||
result = new WebSearch.Response({
|
||||
providerID: WebSearch.ID.make("exa"),
|
||||
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
|
||||
@@ -75,11 +79,21 @@ const websearch = Layer.succeed(
|
||||
transform: () => Effect.die("unused"),
|
||||
reload: () => Effect.die("unused"),
|
||||
providers: () => Effect.succeed(providers),
|
||||
default: () => Effect.succeed(undefined),
|
||||
default: () =>
|
||||
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) =>
|
||||
Effect.gen(function* () {
|
||||
queries.push(input)
|
||||
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 (typeof stored === "string")
|
||||
return new WebSearch.Response({ providerID: WebSearch.ID.make(stored), results: result.results })
|
||||
@@ -316,6 +330,35 @@ 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", () =>
|
||||
Effect.gen(function* () {
|
||||
providerRequired = true
|
||||
|
||||
@@ -90,13 +90,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
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],
|
||||
[Location.node, activeLocation],
|
||||
@@ -230,7 +224,10 @@ describe("WriteTool", () => {
|
||||
const deduplicated = path.join(tmp.path, "deduplicated.txt")
|
||||
formatFile = (target) =>
|
||||
Effect.promise(async () => {
|
||||
await fs.writeFile(target, `\uFEFF\uFEFF\uFEFF${(await fs.readFile(target, "utf8")).replace(/^\uFEFF+/, "")}`)
|
||||
await fs.writeFile(
|
||||
target,
|
||||
`\uFEFF\uFEFF\uFEFF${(await fs.readFile(target, "utf8")).replace(/^\uFEFF+/, "")}`,
|
||||
)
|
||||
return true
|
||||
})
|
||||
return Effect.promise(() =>
|
||||
@@ -323,24 +320,22 @@ describe("WriteTool", () => {
|
||||
).pipe(
|
||||
Effect.andThen((settled) =>
|
||||
Effect.gen(function* () {
|
||||
const canonicalTarget = path.join(yield* Effect.promise(() => fs.realpath(outside.path)), "external.txt")
|
||||
const absoluteTarget = target
|
||||
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
|
||||
expect(assertions[0]).toMatchObject({
|
||||
resources: [
|
||||
path.join(yield* Effect.promise(() => fs.realpath(outside.path)), "*").replaceAll("\\", "/"),
|
||||
],
|
||||
resources: [path.join(outside.path, "*").replaceAll("\\", "/")],
|
||||
})
|
||||
expect(assertions[1]).toMatchObject({ resources: [canonicalTarget.replaceAll("\\", "/")], save: ["*"] })
|
||||
expect(assertions[1]).toMatchObject({ resources: [absoluteTarget.replaceAll("\\", "/")], save: ["*"] })
|
||||
expect(settled).toMatchObject({
|
||||
status: "completed",
|
||||
output: {
|
||||
target: canonicalTarget,
|
||||
resource: canonicalTarget.replaceAll("\\", "/"),
|
||||
target: absoluteTarget,
|
||||
resource: absoluteTarget.replaceAll("\\", "/"),
|
||||
existed: false,
|
||||
},
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("external")
|
||||
expect(writes).toEqual([canonicalTarget])
|
||||
expect(writes).toEqual([absoluteTarget])
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -368,12 +363,10 @@ describe("WriteTool", () => {
|
||||
),
|
||||
Effect.andThen(
|
||||
Effect.gen(function* () {
|
||||
const canonicalRepo = yield* Effect.promise(() => fs.realpath(repo))
|
||||
const canonicalNested = yield* Effect.promise(() => fs.realpath(nested))
|
||||
expect(assertions[0]).toMatchObject({
|
||||
action: "external_directory",
|
||||
resources: [path.join(canonicalNested, "*").replaceAll("\\", "/")],
|
||||
save: [path.join(canonicalRepo, "*").replaceAll("\\", "/")],
|
||||
resources: [path.join(nested, "*").replaceAll("\\", "/")],
|
||||
save: [path.join(repo, "*").replaceAll("\\", "/")],
|
||||
})
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { ServerConnection, useServer, useSettings, useTabs } from "@opencode-ai/app"
|
||||
import { ServerConnection, useServer, useTabs } from "@opencode-ai/app"
|
||||
import { onMount } from "solid-js"
|
||||
|
||||
export function DesktopFirstLaunchOnboarding(props: { initialUrl: string; onLoaded: () => void }) {
|
||||
const server = useServer()
|
||||
const settings = useSettings()
|
||||
const tabs = useTabs()
|
||||
|
||||
onMount(() => {
|
||||
@@ -16,7 +15,6 @@ export function DesktopFirstLaunchOnboarding(props: { initialUrl: string; onLoad
|
||||
[server.ready.promise, tabs.ready.promise, tabs.recentReady.promise].map((p) => p ?? Promise.resolve()),
|
||||
)
|
||||
const existingInstall = await window.api.isOldLayoutEligible()
|
||||
settings.general.setOldLayoutEligible(existingInstall)
|
||||
if (!server.isLocal()) return
|
||||
|
||||
const pending = await window.api.isFirstLaunchOnboardingPending()
|
||||
|
||||
+677
-1526
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { SessionTransfer } from "@opencode-ai/schema/session-transfer"
|
||||
import { SessionPending } from "@opencode-ai/schema/session-pending"
|
||||
import { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
@@ -163,6 +164,36 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("session.import", "/api/session/import", {
|
||||
payload: Schema.Struct({
|
||||
...SessionTransfer.Data.fields,
|
||||
location: Location.Ref.pipe(Schema.optional),
|
||||
}),
|
||||
success: Schema.Struct({ data: Session.Info }),
|
||||
error: ConflictError,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.import",
|
||||
summary: "Import session",
|
||||
description: "Import a projected session transcript at the requested location.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("session.export", "/api/session/:sessionID/export", {
|
||||
params: { sessionID: Session.ID },
|
||||
query: Schema.Struct({ sanitize: BooleanFromString.pipe(Schema.optional) }),
|
||||
success: Schema.Struct({ data: SessionTransfer.Data }),
|
||||
error: [SessionNotFoundError, UnknownError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.export",
|
||||
summary: "Export session",
|
||||
description: "Export a complete projected session transcript.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("session.active", "/api/session/active", {
|
||||
success: Schema.Struct({ data: Schema.Record(Session.ID, SessionActive) }),
|
||||
@@ -491,6 +522,45 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.delete("session.pending.cancel", "/api/session/:sessionID/pending/:inputID", {
|
||||
params: { sessionID: Session.ID, inputID: SessionMessage.ID },
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: [ConflictError, SessionNotFoundError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.pending.cancel",
|
||||
summary: "Cancel pending input",
|
||||
description: "Cancel an input that has not yet been promoted into session history.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("session.pending.steer", "/api/session/:sessionID/pending/:inputID/steer", {
|
||||
params: { sessionID: Session.ID, inputID: SessionMessage.ID },
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: [ConflictError, SessionNotFoundError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.pending.steer",
|
||||
summary: "Steer queued input",
|
||||
description: "Change a queued input to steer delivery and wake session execution.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("session.pending.queue", "/api/session/:sessionID/pending/:inputID/queue", {
|
||||
params: { sessionID: Session.ID, inputID: SessionMessage.ID },
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: [ConflictError, SessionNotFoundError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.pending.queue",
|
||||
summary: "Queue pending steer",
|
||||
description: "Change a pending steer to queued delivery.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("session.instructions.entry.list", "/api/session/:sessionID/instructions/entries", {
|
||||
params: { sessionID: Session.ID },
|
||||
|
||||
@@ -41,7 +41,7 @@ export const Info = Schema.Struct({
|
||||
id,
|
||||
name: Name.make(id),
|
||||
request: { settings: {}, headers: {}, body: {} },
|
||||
mode: "all",
|
||||
mode: "primary",
|
||||
hidden: false,
|
||||
permissions: [
|
||||
{ action: "*", resource: "*", effect: "allow" },
|
||||
|
||||
@@ -3,7 +3,7 @@ export * as Config from "./config.js"
|
||||
import { Schema } from "effect"
|
||||
import { ephemeral, inventory } from "./event.js"
|
||||
import { Permission } from "./permission.js"
|
||||
import { AbsolutePath } from "./schema.js"
|
||||
import { AbsolutePath, optional } from "./schema.js"
|
||||
import { ConfigAgent } from "./config/agent.js"
|
||||
import { ConfigMedia } from "./config/media.js"
|
||||
import { ConfigCompaction } from "./config/compaction.js"
|
||||
@@ -22,94 +22,94 @@ import { ConfigWatcher } from "./config/watcher.js"
|
||||
import { ConfigWarming } from "./config/warming.js"
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.Info")({
|
||||
$schema: Schema.optional(Schema.String).annotate({
|
||||
$schema: optional(Schema.String).annotate({
|
||||
description: "JSON schema reference for configuration validation",
|
||||
}),
|
||||
shell: Schema.String.pipe(Schema.optional).annotate({
|
||||
shell: Schema.String.pipe(optional).annotate({
|
||||
description: "Default shell to use for terminal and shell tool execution",
|
||||
}),
|
||||
model: ConfigModel.Selection.pipe(Schema.optional).annotate({
|
||||
model: ConfigModel.Selection.pipe(optional).annotate({
|
||||
description: "Default model to use when no session or agent model is selected",
|
||||
}),
|
||||
default_agent: Schema.String.pipe(Schema.optional).annotate({
|
||||
default_agent: Schema.String.pipe(optional).annotate({
|
||||
description: "Default primary agent to use when no session agent is selected",
|
||||
}),
|
||||
autoupdate: Schema.Union([Schema.Boolean, Schema.Literal("notify")])
|
||||
.pipe(Schema.optional)
|
||||
.pipe(optional)
|
||||
.annotate({
|
||||
description: "Automatically update or notify when a new version is available",
|
||||
}),
|
||||
share: Schema.Literals(["manual", "auto", "disabled"]).pipe(Schema.optional).annotate({
|
||||
share: Schema.Literals(["manual", "auto", "disabled"]).pipe(optional).annotate({
|
||||
description: "Control whether sessions may be shared manually, automatically, or not at all",
|
||||
}),
|
||||
enterprise: Schema.Struct({
|
||||
url: Schema.String.pipe(Schema.optional),
|
||||
url: Schema.String.pipe(optional),
|
||||
})
|
||||
.pipe(Schema.optional)
|
||||
.pipe(optional)
|
||||
.annotate({
|
||||
description: "Enterprise sharing service configuration",
|
||||
}),
|
||||
username: Schema.String.pipe(Schema.optional).annotate({
|
||||
username: Schema.String.pipe(optional).annotate({
|
||||
description: "Username displayed in conversations and used for telemetry identity",
|
||||
}),
|
||||
permissions: Permission.Ruleset.pipe(Schema.optional).annotate({
|
||||
permissions: Permission.Ruleset.pipe(optional).annotate({
|
||||
description: "Ordered tool permission rules applied to agent tool use",
|
||||
}),
|
||||
agents: Schema.Record(Schema.String, ConfigAgent.Info).pipe(Schema.optional).annotate({
|
||||
agents: Schema.Record(Schema.String, ConfigAgent.Info).pipe(optional).annotate({
|
||||
description: "Named built-in agent overrides and custom agent definitions",
|
||||
}),
|
||||
snapshots: Schema.Boolean.pipe(Schema.optional).annotate({
|
||||
snapshots: Schema.Boolean.pipe(optional).annotate({
|
||||
description: "Enable snapshots used for undo and revert behavior",
|
||||
}),
|
||||
watcher: ConfigWatcher.Info.pipe(Schema.optional).annotate({
|
||||
watcher: ConfigWatcher.Info.pipe(optional).annotate({
|
||||
description: "Filesystem watcher configuration",
|
||||
}),
|
||||
formatter: ConfigFormatter.Info.pipe(Schema.optional).annotate({
|
||||
formatter: ConfigFormatter.Info.pipe(optional).annotate({
|
||||
description: "Enable built-in formatters or configure formatter overrides",
|
||||
}),
|
||||
lsp: ConfigLSP.Info.pipe(Schema.optional).annotate({
|
||||
lsp: ConfigLSP.Info.pipe(optional).annotate({
|
||||
description: "Enable built-in language servers or configure server overrides",
|
||||
}),
|
||||
media: ConfigMedia.Info.pipe(Schema.optional).annotate({
|
||||
media: ConfigMedia.Info.pipe(optional).annotate({
|
||||
description: "Media processing configuration",
|
||||
}),
|
||||
tool_output: ConfigToolOutput.Info.pipe(Schema.optional).annotate({
|
||||
tool_output: ConfigToolOutput.Info.pipe(optional).annotate({
|
||||
description: "Tool output truncation thresholds",
|
||||
}),
|
||||
mcp: ConfigMCP.Info.pipe(Schema.optional).annotate({
|
||||
mcp: ConfigMCP.Info.pipe(optional).annotate({
|
||||
description: "MCP server configuration",
|
||||
}),
|
||||
compaction: ConfigCompaction.Info.pipe(Schema.optional).annotate({
|
||||
compaction: ConfigCompaction.Info.pipe(optional).annotate({
|
||||
description: "Conversation compaction behavior",
|
||||
}),
|
||||
skills: Schema.String.pipe(Schema.Array, Schema.optional).annotate({
|
||||
skills: Schema.String.pipe(Schema.Array, optional).annotate({
|
||||
description: "Additional paths or URLs to discover skills from",
|
||||
}),
|
||||
commands: Schema.Record(Schema.String, ConfigCommand.Info).pipe(Schema.optional).annotate({
|
||||
commands: Schema.Record(Schema.String, ConfigCommand.Info).pipe(optional).annotate({
|
||||
description: "Named slash command definitions",
|
||||
}),
|
||||
instructions: Schema.String.pipe(Schema.Array, Schema.optional).annotate({
|
||||
instructions: Schema.String.pipe(Schema.Array, optional).annotate({
|
||||
description: "Additional paths or URLs supplying ambient instructions",
|
||||
}),
|
||||
references: ConfigReference.Info.pipe(Schema.optional).annotate({
|
||||
references: ConfigReference.Info.pipe(optional).annotate({
|
||||
description: "Named local directories or Git repositories available as external context",
|
||||
}),
|
||||
websearch: ConfigWebSearch.Info.pipe(Schema.optional).annotate({
|
||||
websearch: ConfigWebSearch.Info.pipe(optional).annotate({
|
||||
description: "Web search provider selection",
|
||||
}),
|
||||
plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({
|
||||
plugins: ConfigPlugin.Plugins.pipe(optional).annotate({
|
||||
description: "Ordered plugin enablement directives and external package declarations",
|
||||
}),
|
||||
warming: ConfigWarming.Warming.pipe(Schema.optional).annotate({
|
||||
warming: ConfigWarming.Warming.pipe(optional).annotate({
|
||||
description: "Keep recently active sessions warm with transient model requests (default: false)",
|
||||
}),
|
||||
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional),
|
||||
experimental: ConfigExperimental.Info.pipe(Schema.optional),
|
||||
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(optional),
|
||||
experimental: ConfigExperimental.Info.pipe(optional),
|
||||
}) {}
|
||||
|
||||
export class Document extends Schema.Class<Document>("Config.Document")({
|
||||
type: Schema.Literal("document"),
|
||||
path: Schema.String.pipe(Schema.optional),
|
||||
path: Schema.String.pipe(optional),
|
||||
info: Info,
|
||||
}) {}
|
||||
|
||||
|
||||
@@ -2,21 +2,21 @@ export * as ConfigAgent from "./agent.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { Permission } from "../permission.js"
|
||||
import { PositiveInt } from "../schema.js"
|
||||
import { optional, PositiveInt } from "../schema.js"
|
||||
import { ConfigModel } from "./model.js"
|
||||
import { ConfigProvider } from "./provider.js"
|
||||
|
||||
export const Color = Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/))
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.Agent")({
|
||||
model: ConfigModel.Selection.pipe(Schema.optional),
|
||||
request: ConfigProvider.Request.pipe(Schema.optional),
|
||||
system: Schema.String.pipe(Schema.optional),
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
mode: Schema.Literals(["subagent", "primary", "all"]).pipe(Schema.optional),
|
||||
hidden: Schema.Boolean.pipe(Schema.optional),
|
||||
color: Color.pipe(Schema.optional),
|
||||
steps: PositiveInt.pipe(Schema.optional),
|
||||
disabled: Schema.Boolean.pipe(Schema.optional),
|
||||
permissions: Permission.Ruleset.pipe(Schema.optional),
|
||||
model: ConfigModel.Selection.pipe(optional),
|
||||
request: ConfigProvider.Request.pipe(optional),
|
||||
system: Schema.String.pipe(optional),
|
||||
description: Schema.String.pipe(optional),
|
||||
mode: Schema.Literals(["subagent", "primary", "all"]).pipe(optional),
|
||||
hidden: Schema.Boolean.pipe(optional),
|
||||
color: Color.pipe(optional),
|
||||
steps: PositiveInt.pipe(optional),
|
||||
disabled: Schema.Boolean.pipe(optional),
|
||||
permissions: Permission.Ruleset.pipe(optional),
|
||||
}) {}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
export * as ConfigCommand from "./command.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { optional } from "../schema.js"
|
||||
import { ConfigModel } from "./model.js"
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.Command")({
|
||||
template: Schema.String,
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
agent: Schema.String.pipe(Schema.optional),
|
||||
model: ConfigModel.Selection.pipe(Schema.optional),
|
||||
subtask: Schema.Boolean.pipe(Schema.optional),
|
||||
description: Schema.String.pipe(optional),
|
||||
agent: Schema.String.pipe(optional),
|
||||
model: ConfigModel.Selection.pipe(optional),
|
||||
subtask: Schema.Boolean.pipe(optional),
|
||||
}) {}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user