mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-09 02:49:57 -04:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60124ac57d | |||
| 3bb0d7fda0 | |||
| 8977881e09 | |||
| e6c9b6bef7 | |||
| 2092350cfa | |||
| 5fb0d7c99c | |||
| a1b4843a33 | |||
| cc7827fe08 | |||
| 76e4d88d21 | |||
| 439ed66c7b | |||
| 047d434aa2 |
+8
-10
@@ -1,19 +1,18 @@
|
|||||||
import { expect, test } from "@playwright/test"
|
import { expect, test } from "@playwright/test"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
|
||||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
|
|
||||||
const draftID = "draft_legacy_new_session"
|
const draftID = "draft_removed_layout_preference"
|
||||||
const directory = "C:/OpenCode/LegacyNewSession"
|
const directory = "C:/OpenCode/RemovedLayoutPreference"
|
||||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||||
|
|
||||||
test("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, {
|
await mockOpenCodeServer(page, {
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
id: "proj_legacy_new_session",
|
id: "proj_removed_layout_preference",
|
||||||
worktree: directory,
|
worktree: directory,
|
||||||
vcs: "git",
|
vcs: "git",
|
||||||
name: "legacy-new-session",
|
name: "removed-layout-preference",
|
||||||
time: { created: 1700000000000, updated: 1700000000000 },
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
sandboxes: [],
|
sandboxes: [],
|
||||||
},
|
},
|
||||||
@@ -24,7 +23,6 @@ test("redirects a draft to the legacy new-session route", async ({ page }) => {
|
|||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ directory, draftID, server }) => {
|
({ directory, draftID, server }) => {
|
||||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } }))
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } }))
|
||||||
localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" }))
|
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"opencode.window.browser.dat:tabs",
|
"opencode.window.browser.dat:tabs",
|
||||||
JSON.stringify([{ type: "draft", draftID, server, directory }]),
|
JSON.stringify([{ type: "draft", draftID, server, directory }]),
|
||||||
@@ -35,7 +33,7 @@ test("redirects a draft to the legacy new-session route", async ({ page }) => {
|
|||||||
|
|
||||||
await page.goto(`/new-session?draftId=${draftID}`)
|
await page.goto(`/new-session?draftId=${draftID}`)
|
||||||
|
|
||||||
await expect(page).toHaveURL(`/${base64Encode(directory)}/session`)
|
await expect(page).toHaveURL(`/new-session?draftId=${draftID}`)
|
||||||
await expect(page.locator("header[data-tauri-drag-region]")).toBeVisible()
|
await expect(page.locator("body")).toHaveAttribute("data-new-layout", "")
|
||||||
await expect(page.locator('[data-component="prompt-input"]')).toBeVisible()
|
await expect(page.getByRole("textbox", { name: "Prompt" })).toBeVisible()
|
||||||
})
|
})
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
<meta property="twitter:image" content="/social-share.png" />
|
<meta property="twitter:image" content="/social-share.png" />
|
||||||
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
|
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body 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>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
<div id="root" class="flex flex-col h-dvh bg-v2-background-bg-deep p-px"></div>
|
<div id="root" class="flex flex-col h-dvh bg-v2-background-bg-deep p-px"></div>
|
||||||
<script src="/src/entry.tsx" type="module"></script>
|
<script src="/src/entry.tsx" type="module"></script>
|
||||||
|
|||||||
+31
-146
@@ -14,14 +14,11 @@ import {
|
|||||||
Navigate,
|
Navigate,
|
||||||
Route,
|
Route,
|
||||||
Router,
|
Router,
|
||||||
useLocation,
|
|
||||||
useNavigate,
|
|
||||||
useParams,
|
useParams,
|
||||||
useSearchParams,
|
useSearchParams,
|
||||||
} from "@solidjs/router"
|
} from "@solidjs/router"
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
|
||||||
import {
|
import {
|
||||||
type Component,
|
type Component,
|
||||||
createEffect,
|
createEffect,
|
||||||
@@ -43,7 +40,7 @@ import { CommandProvider, useCommand, type CommandOption } from "@/context/comma
|
|||||||
import { CommentsProvider } from "@/context/comments"
|
import { CommentsProvider } from "@/context/comments"
|
||||||
import { FileProvider } from "@/context/file"
|
import { FileProvider } from "@/context/file"
|
||||||
import { ServerSDKProvider } from "@/context/server-sdk"
|
import { ServerSDKProvider } from "@/context/server-sdk"
|
||||||
import { ServerSyncProvider, useServerSync } from "@/context/server-sync"
|
import { ServerSyncProvider } from "@/context/server-sync"
|
||||||
import { GlobalProvider, useGlobal } from "@/context/global"
|
import { GlobalProvider, useGlobal } from "@/context/global"
|
||||||
import { HighlightsProvider } from "@/context/highlights"
|
import { HighlightsProvider } from "@/context/highlights"
|
||||||
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
||||||
@@ -54,58 +51,36 @@ import { PermissionProvider } from "@/context/permission"
|
|||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { PromptProvider } from "@/context/prompt"
|
import { PromptProvider } from "@/context/prompt"
|
||||||
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
||||||
import { SettingsProvider, useSettings } from "@/context/settings"
|
import { SettingsProvider } from "@/context/settings"
|
||||||
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
|
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 { WslServersProvider } from "@/wsl/context"
|
||||||
import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout"
|
import { DirectoryDataProvider } from "@/pages/directory-layout"
|
||||||
import LegacyLayout from "@/pages/layout"
|
import Layout from "@/pages/layout"
|
||||||
import NewLayout from "@/pages/layout-new"
|
|
||||||
import { ErrorPage } from "./pages/error"
|
import { ErrorPage } from "./pages/error"
|
||||||
import { useCheckServerHealth } from "./utils/server-health"
|
import { useCheckServerHealth } from "./utils/server-health"
|
||||||
import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
||||||
import { createSessionLineage } from "@/pages/session/session-lineage"
|
import { decode64 } from "@/utils/base64"
|
||||||
|
|
||||||
import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
|
import { TargetSessionRouteContent } from "@/pages/session"
|
||||||
import { NewHome } from "@/pages/home"
|
import { Home } from "@/pages/home"
|
||||||
import { LegacyHome } from "@/pages/home/legacy-home"
|
|
||||||
|
|
||||||
const NewSession = lazy(() => import("@/pages/new-session"))
|
const NewSession = lazy(() => import("@/pages/new-session"))
|
||||||
|
|
||||||
const SessionRoute = () => {
|
const DirectoryDraftRedirect = () => {
|
||||||
const settings = useSettings()
|
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
||||||
const sdk = useSDK()
|
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
|
|
||||||
if (params.id && settings.general.newLayoutDesigns()) {
|
|
||||||
const sessionID = params.id
|
|
||||||
return (
|
|
||||||
<Show when={tabs.ready()}>
|
|
||||||
{(_) => {
|
|
||||||
const persisted = tabs.store.filter((item) => item.type === "session")
|
|
||||||
return <Navigate href={sessionHref(legacySessionServer(persisted, sessionID, server.key), sessionID)} />
|
|
||||||
}}
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// When the new layout is enabled, the legacy new-session route (/:dir/session with no id)
|
|
||||||
// is replaced by a draft at /new-session?draftId=…
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!settings.general.newLayoutDesigns()) return
|
if (search.draftId || !tabs.ready()) return
|
||||||
if (params.id || search.draftId) return
|
const directory = decode64(params.dir)
|
||||||
if (!tabs.ready() || !sdk().directory) return
|
if (!directory) return
|
||||||
tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt)
|
tabs.newDraft({ server: server.key, directory }, search.prompt)
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return null
|
||||||
<SessionRouteErrorBoundary sessionID={params.id}>
|
|
||||||
<SessionPage />
|
|
||||||
</SessionRouteErrorBoundary>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function TargetServerRoute(props: ParentProps) {
|
function TargetServerRoute(props: ParentProps) {
|
||||||
@@ -117,9 +92,7 @@ function TargetServerRoute(props: ParentProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// Owns the server-identity remount. Session changes must NOT remount this
|
// Owns the server-identity remount. Session changes must not remount this subtree.
|
||||||
// subtree (SessionRouteErrorBoundary resets and createSessionLineage
|
|
||||||
// re-resolves reactively instead); both rely on this key for server changes.
|
|
||||||
<Show when={requireServerKey(params.serverKey)} keyed>
|
<Show when={requireServerKey(params.serverKey)} keyed>
|
||||||
<ServerSDKProvider server={conn}>
|
<ServerSDKProvider server={conn}>
|
||||||
<ServerSyncProvider server={conn}>{props.children}</ServerSyncProvider>
|
<ServerSyncProvider server={conn}>{props.children}</ServerSyncProvider>
|
||||||
@@ -134,35 +107,6 @@ const TargetSessionRoute = () => (
|
|||||||
</TargetServerRoute>
|
</TargetServerRoute>
|
||||||
)
|
)
|
||||||
|
|
||||||
function LegacyTargetSessionRoute() {
|
|
||||||
const params = useParams<{ serverKey: string; id: string }>()
|
|
||||||
return (
|
|
||||||
<TargetServerRoute>
|
|
||||||
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)}>
|
|
||||||
<LegacyTargetSessionRedirect />
|
|
||||||
</SessionRouteErrorBoundary>
|
|
||||||
</TargetServerRoute>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function LegacyTargetSessionRedirect() {
|
|
||||||
const params = useParams<{ id: string }>()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const sync = useServerSync()
|
|
||||||
const current = createSessionLineage(
|
|
||||||
() => params.id,
|
|
||||||
() => sync().session.lineage,
|
|
||||||
)
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const directory = current()?.session.location.directory
|
|
||||||
if (!directory) return
|
|
||||||
navigate(legacySessionHref(directory, params.id), { replace: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
|
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
|
||||||
// server via ServerKey, then provide the server-scoped shell for that server.
|
// server via ServerKey, then provide the server-scoped shell for that server.
|
||||||
function SelectedServerProviders(props: ParentProps) {
|
function SelectedServerProviders(props: ParentProps) {
|
||||||
@@ -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() {
|
function DraftRoute() {
|
||||||
const [search] = useSearchParams<{ draftId?: string }>()
|
const [search] = useSearchParams<{ draftId?: string }>()
|
||||||
const settings = useSettings()
|
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
return (
|
return (
|
||||||
<Show when={tabs.ready()}>
|
<Show when={tabs.ready()}>
|
||||||
@@ -194,14 +129,7 @@ function DraftRoute() {
|
|||||||
keyed
|
keyed
|
||||||
fallback={<Navigate href="/" />}
|
fallback={<Navigate href="/" />}
|
||||||
>
|
>
|
||||||
{(draft) => (
|
{(draft) => <ResolvedDraftRoute draft={draft} />}
|
||||||
<Show
|
|
||||||
when={settings.general.newLayoutDesigns()}
|
|
||||||
fallback={<Navigate href={`/${base64Encode(draft.directory)}/session`} />}
|
|
||||||
>
|
|
||||||
<ResolvedDraftRoute draft={draft} />
|
|
||||||
</Show>
|
|
||||||
)}
|
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
@@ -237,10 +165,6 @@ function UiI18nBridge(props: ParentProps) {
|
|||||||
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
|
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
|
||||||
}
|
}
|
||||||
|
|
||||||
function LayoutCompatibility(props: ParentProps) {
|
|
||||||
return <>{props.children}</>
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
__OPENCODE__?: {
|
__OPENCODE__?: {
|
||||||
@@ -267,17 +191,11 @@ function QueryProvider(props: ParentProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function BodyDesignClass() {
|
function BodyDesignClass() {
|
||||||
const settings = useSettings()
|
|
||||||
|
|
||||||
createRenderEffect(() => {
|
createRenderEffect(() => {
|
||||||
if (typeof document === "undefined") return
|
if (typeof document === "undefined") return
|
||||||
|
document.body.toggleAttribute("data-new-layout", true)
|
||||||
const enabled = settings.general.newLayoutDesigns()
|
document.body.classList.remove("text-12-regular")
|
||||||
document.body.toggleAttribute("data-new-layout", enabled)
|
document.body.classList.add("font-(family-name:--font-family-text)", "text-[13px]", "font-[440]")
|
||||||
document.body.classList.toggle("text-12-regular", !enabled)
|
|
||||||
document.body.classList.toggle("font-(family-name:--font-family-text)", enabled)
|
|
||||||
document.body.classList.toggle("text-[13px]", enabled)
|
|
||||||
document.body.classList.toggle("font-[440]", enabled)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return null
|
return null
|
||||||
@@ -320,7 +238,6 @@ function DesktopCommands() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server-scoped providers shared by the legacy shell and the top-level new shell.
|
|
||||||
type ServerScopedShellProps = ParentProps<{
|
type ServerScopedShellProps = ParentProps<{
|
||||||
directory?: () => string | undefined
|
directory?: () => string | undefined
|
||||||
serverScoped?: JSX.Element
|
serverScoped?: JSX.Element
|
||||||
@@ -335,19 +252,11 @@ function ServerScopedProviders(props: ServerScopedShellProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function LegacyServerScopedShell(props: ServerScopedShellProps) {
|
function AppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
|
||||||
return (
|
|
||||||
<ServerScopedProviders directory={props.directory} serverScoped={props.serverScoped}>
|
|
||||||
<LegacyLayout>{props.children}</LegacyLayout>
|
|
||||||
</ServerScopedProviders>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function NewAppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
|
|
||||||
return (
|
return (
|
||||||
<SelectedServerProviders>
|
<SelectedServerProviders>
|
||||||
<ServerScopedProviders serverScoped={props.serverScoped}>
|
<ServerScopedProviders serverScoped={props.serverScoped}>
|
||||||
<NewLayout>{props.children}</NewLayout>
|
<Layout>{props.children}</Layout>
|
||||||
</ServerScopedProviders>
|
</ServerScopedProviders>
|
||||||
</SelectedServerProviders>
|
</SelectedServerProviders>
|
||||||
)
|
)
|
||||||
@@ -536,7 +445,7 @@ export function AppInterface(props: {
|
|||||||
startup?: Promise<void>
|
startup?: Promise<void>
|
||||||
serverScoped?: JSX.Element
|
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
|
// route changes. Draft and session routes override only their server-bound data
|
||||||
// providers beneath it.
|
// providers beneath it.
|
||||||
const ServerShell = (shellProps: ParentProps) => (
|
const ServerShell = (shellProps: ParentProps) => (
|
||||||
@@ -557,7 +466,6 @@ export function AppInterface(props: {
|
|||||||
<GlobalProvider>
|
<GlobalProvider>
|
||||||
<SettingsProvider>
|
<SettingsProvider>
|
||||||
<ConnectionGate disableHealthCheck={props.disableHealthCheck} startup={props.startup}>
|
<ConnectionGate disableHealthCheck={props.disableHealthCheck} startup={props.startup}>
|
||||||
<Show when={useSettings().general.newLayoutDesigns().toString()} keyed>
|
|
||||||
<Dynamic
|
<Dynamic
|
||||||
component={props.router ?? Router}
|
component={props.router ?? Router}
|
||||||
root={(routerProps) => (
|
root={(routerProps) => (
|
||||||
@@ -565,18 +473,15 @@ export function AppInterface(props: {
|
|||||||
<PermissionProvider>
|
<PermissionProvider>
|
||||||
<NotificationProvider>
|
<NotificationProvider>
|
||||||
<ServerShell>
|
<ServerShell>
|
||||||
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
<AppLayout serverScoped={props.serverScoped}>{routerProps.children}</AppLayout>
|
||||||
<NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
|
|
||||||
</Show>
|
|
||||||
</ServerShell>
|
</ServerShell>
|
||||||
</NotificationProvider>
|
</NotificationProvider>
|
||||||
</PermissionProvider>
|
</PermissionProvider>
|
||||||
</TabsProvider>
|
</TabsProvider>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Routes serverScoped={props.serverScoped} />
|
<Routes />
|
||||||
</Dynamic>
|
</Dynamic>
|
||||||
</Show>
|
|
||||||
</ConnectionGate>
|
</ConnectionGate>
|
||||||
</SettingsProvider>
|
</SettingsProvider>
|
||||||
</GlobalProvider>
|
</GlobalProvider>
|
||||||
@@ -584,40 +489,20 @@ export function AppInterface(props: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Routes(props: { serverScoped?: JSX.Element }) {
|
function Routes() {
|
||||||
const settings = useSettings()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Route
|
<Route path="/" component={Home} />
|
||||||
component={(routeProps) => (
|
<Route path="/:dir" component={DirectoryDraftRedirect} />
|
||||||
<LegacyServerLayout serverScoped={props.serverScoped}>{routeProps.children}</LegacyServerLayout>
|
<Route path="/:dir/session" component={DirectoryDraftRedirect} />
|
||||||
)}
|
<Route path="/:dir/session/:id" component={LegacySessionRedirect} />
|
||||||
>
|
|
||||||
<Show when={!settings.general.newLayoutDesigns()}>
|
|
||||||
{
|
|
||||||
<>
|
|
||||||
<Route path="/" component={LegacyHome} />
|
|
||||||
<Route path="/server/:serverKey/session/:id" component={LegacyTargetSessionRoute} />
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
</Show>
|
|
||||||
<Route path="/:dir" component={DirectoryLayout}>
|
|
||||||
<Route path="/" component={() => <Navigate href="session" />} />
|
|
||||||
<Route path="/session/:id?" component={SessionRoute} />
|
|
||||||
</Route>
|
|
||||||
</Route>
|
|
||||||
<Show when={settings.general.newLayoutDesigns()}>
|
|
||||||
<Route path="/" component={NewHome} />
|
|
||||||
<Route path="/:dir/session/:id" component={NewLayoutLegacySessionRedirect} />
|
|
||||||
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
|
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
|
||||||
</Show>
|
|
||||||
<Route path="/new-session" component={DraftRoute} />
|
<Route path="/new-session" component={DraftRoute} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function NewLayoutLegacySessionRedirect() {
|
function LegacySessionRedirect() {
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
const params = useParams<{ id: string }>()
|
const params = useParams<{ id: string }>()
|
||||||
|
|||||||
Binary file not shown.
|
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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 { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
@@ -27,7 +26,6 @@ import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
|||||||
import { Link } from "../link"
|
import { Link } from "../link"
|
||||||
import { SettingsListV2 } from "./parts/list"
|
import { SettingsListV2 } from "./parts/list"
|
||||||
import { SettingsRowV2 } from "./parts/row"
|
import { SettingsRowV2 } from "./parts/row"
|
||||||
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
|
|
||||||
import "./settings-v2.css"
|
import "./settings-v2.css"
|
||||||
|
|
||||||
let demoSoundState = {
|
let demoSoundState = {
|
||||||
@@ -87,7 +85,6 @@ export const SettingsGeneralV2: Component<{
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const permission = usePermission()
|
const permission = usePermission()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const dialog = useDialog()
|
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const mobile = createMediaQuery("(max-width: 767px)")
|
const mobile = createMediaQuery("(max-width: 767px)")
|
||||||
@@ -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 = () => (
|
const GeneralSection = () => (
|
||||||
<div class="settings-v2-section">
|
<div class="settings-v2-section">
|
||||||
<SettingsListV2>
|
<SettingsListV2>
|
||||||
@@ -689,14 +661,6 @@ export const SettingsGeneralV2: Component<{
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-v2-tab-body">
|
<div class="settings-v2-tab-body">
|
||||||
<Show when={settings.general.layoutTransitionAvailable()}>
|
|
||||||
<InterfaceSection />
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={settings.general.newInterfaceNoticeVisible()}>
|
|
||||||
<InterfaceNoticeSection />
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<GeneralSection />
|
<GeneralSection />
|
||||||
|
|
||||||
<AppearanceSection />
|
<AppearanceSection />
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -153,4 +153,88 @@ describe("v2 session reducer", () => {
|
|||||||
|
|
||||||
expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] })
|
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":
|
case "session.input.admitted":
|
||||||
pending.set(key(sessionID, event.data.inputID), event.data.input)
|
pending.set(key(sessionID, event.data.inputID), event.data.input)
|
||||||
return result([...source])
|
return result([...source])
|
||||||
|
case "session.input.cancelled":
|
||||||
|
pending.delete(key(sessionID, event.data.inputID))
|
||||||
|
return
|
||||||
case "session.input.promoted": {
|
case "session.input.promoted": {
|
||||||
const input = pending.get(key(sessionID, event.data.inputID))
|
const input = pending.get(key(sessionID, event.data.inputID))
|
||||||
pending.delete(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 { 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 { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { persisted } from "@/utils/persist"
|
import { persisted } from "@/utils/persist"
|
||||||
import { usePlatform } from "@/context/platform"
|
|
||||||
|
|
||||||
export interface NotificationSettings {
|
export interface NotificationSettings {
|
||||||
agent: boolean
|
agent: boolean
|
||||||
@@ -34,10 +33,6 @@ export interface Settings {
|
|||||||
editToolPartsExpanded: boolean
|
editToolPartsExpanded: boolean
|
||||||
showCustomAgents: boolean
|
showCustomAgents: boolean
|
||||||
mobileTitlebarPosition: "top" | "bottom"
|
mobileTitlebarPosition: "top" | "bottom"
|
||||||
newLayoutDesigns?: boolean
|
|
||||||
layoutTransitionEligible?: boolean
|
|
||||||
newInterfaceNoticeDismissed?: boolean
|
|
||||||
shouldDisplayTabsToast?: boolean
|
|
||||||
}
|
}
|
||||||
appearance: {
|
appearance: {
|
||||||
fontSize: number
|
fontSize: number
|
||||||
@@ -56,75 +51,6 @@ export interface Settings {
|
|||||||
export const monoDefault = "System Mono"
|
export const monoDefault = "System Mono"
|
||||||
export const sansDefault = "System Sans"
|
export const sansDefault = "System Sans"
|
||||||
export const terminalDefault = "JetBrainsMono Nerd Font Mono"
|
export const terminalDefault = "JetBrainsMono Nerd Font Mono"
|
||||||
const legacyNewLayoutDesignsDefault = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
|
||||||
export const newLayoutDesignsDefault = true
|
|
||||||
// Existing users can switch layouts until local midnight on this date. Set new Date(YYYY, M-1, D) to show.
|
|
||||||
export const oldInterfaceSunset = new Date(2026, 8, 14)
|
|
||||||
const newLayoutDesignsUpgradeCutoff = "1.17.19"
|
|
||||||
|
|
||||||
function compareVersions(a: string, b: string) {
|
|
||||||
const parse = (version: string) => {
|
|
||||||
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/i.exec(version.trim())
|
|
||||||
if (!match) return
|
|
||||||
return match.slice(1).map(Number)
|
|
||||||
}
|
|
||||||
const left = parse(a)
|
|
||||||
const right = parse(b)
|
|
||||||
if (!left || !right) return
|
|
||||||
const index = left.findIndex((part, index) => part !== right[index])
|
|
||||||
return index === -1 ? 0 : left[index]! - right[index]!
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isAppUpgrade(previous: string | undefined, current: string | undefined) {
|
|
||||||
if (!previous || !current) return false
|
|
||||||
const comparison = compareVersions(current, previous)
|
|
||||||
return comparison !== undefined && comparison > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldDisplayTabsToast(
|
|
||||||
previous: string | undefined,
|
|
||||||
current: string | undefined,
|
|
||||||
existingInstall: boolean,
|
|
||||||
) {
|
|
||||||
return isAppUpgrade(previous, current) || (!previous && existingInstall)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasExistingWebState(settings: Promise<string> | string | null, previousVersion: string | undefined) {
|
|
||||||
return settings !== null || previousVersion !== undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldEnableNewLayout(previous: string | undefined, current: string | undefined) {
|
|
||||||
if (!current) return false
|
|
||||||
const currentComparison = compareVersions(current, newLayoutDesignsUpgradeCutoff)
|
|
||||||
if (!previous) return currentComparison !== undefined && currentComparison > 0
|
|
||||||
if (!isAppUpgrade(previous, current)) return false
|
|
||||||
const previousComparison = compareVersions(previous, newLayoutDesignsUpgradeCutoff)
|
|
||||||
return (
|
|
||||||
previousComparison !== undefined &&
|
|
||||||
currentComparison !== undefined &&
|
|
||||||
previousComparison <= 0 &&
|
|
||||||
currentComparison > 0
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function layoutTransitionState(scheduled: boolean, eligible: boolean, retired: boolean, dismissed: boolean) {
|
|
||||||
return {
|
|
||||||
available: scheduled && eligible && !retired,
|
|
||||||
notice: scheduled && eligible && retired && !dismissed,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const maximumSunsetTimeout = 2_147_483_647
|
|
||||||
|
|
||||||
export function nextSunsetCheckDelay(sunset: number, now: number) {
|
|
||||||
return Math.min(Math.max(0, sunset - now), maximumSunsetTimeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveNewLayoutDesigns(retired: boolean, preference: boolean | undefined, fallback = true) {
|
|
||||||
if (retired) return true
|
|
||||||
return preference ?? fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
const monoFallback =
|
const monoFallback =
|
||||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
||||||
const sansFallback = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
|
const sansFallback = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
|
||||||
@@ -223,17 +149,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
name: "Settings",
|
name: "Settings",
|
||||||
gate: false,
|
gate: false,
|
||||||
init: () => {
|
init: () => {
|
||||||
const platform = usePlatform()
|
const [store, setStore, , ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
|
||||||
const [store, setStore, settingsInit, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
|
|
||||||
const [launch, setLaunch, , launchReady] = persisted(
|
|
||||||
"app-version.v1",
|
|
||||||
createStore<{ version?: string }>({ version: undefined }),
|
|
||||||
)
|
|
||||||
const [launchState, setLaunchState] = createStore({
|
|
||||||
classified: false,
|
|
||||||
migrationApplied: false,
|
|
||||||
previous: undefined as string | undefined,
|
|
||||||
})
|
|
||||||
const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
|
const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
|
||||||
const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
|
const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
|
||||||
const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
|
const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
|
||||||
@@ -241,93 +157,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
() => store.general?.showCustomAgents,
|
() => store.general?.showCustomAgents,
|
||||||
defaultSettings.general.showCustomAgents,
|
defaultSettings.general.showCustomAgents,
|
||||||
)
|
)
|
||||||
const sunset = oldInterfaceSunset
|
|
||||||
const [oldInterfaceRetired, setOldInterfaceRetired] = createSignal(sunset ? Date.now() >= sunset.getTime() : false)
|
|
||||||
const layoutTransitionClassified = createMemo(() => typeof store.general?.layoutTransitionEligible === "boolean")
|
|
||||||
const layoutTransitionEligible = withFallback(() => store.general?.layoutTransitionEligible, false)
|
|
||||||
const newInterfaceNoticeDismissed = withFallback(() => store.general?.newInterfaceNoticeDismissed, false)
|
|
||||||
const layoutUpgrade = createMemo(() =>
|
|
||||||
launchState.classified && !launchState.migrationApplied
|
|
||||||
? shouldEnableNewLayout(launchState.previous, platform.version)
|
|
||||||
: false,
|
|
||||||
)
|
|
||||||
const layoutTransition = createMemo(() =>
|
|
||||||
layoutTransitionState(!!sunset, layoutTransitionEligible(), oldInterfaceRetired(), newInterfaceNoticeDismissed()),
|
|
||||||
)
|
|
||||||
const newLayoutDesigns = createMemo(() => {
|
|
||||||
if (layoutUpgrade()) return true
|
|
||||||
if (!ready() && !oldInterfaceRetired()) return legacyNewLayoutDesignsDefault
|
|
||||||
if (!layoutTransitionClassified()) {
|
|
||||||
return resolveNewLayoutDesigns(
|
|
||||||
oldInterfaceRetired(),
|
|
||||||
store.general?.newLayoutDesigns,
|
|
||||||
legacyNewLayoutDesignsDefault,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return resolveNewLayoutDesigns(
|
|
||||||
oldInterfaceRetired(),
|
|
||||||
store.general?.newLayoutDesigns,
|
|
||||||
layoutTransitionEligible() ? legacyNewLayoutDesignsDefault : newLayoutDesignsDefault,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
const visible = (preference: () => boolean) => createMemo(() => !newLayoutDesigns() || preference())
|
|
||||||
|
|
||||||
if (sunset && !oldInterfaceRetired()) {
|
|
||||||
const timeout = { current: undefined as ReturnType<typeof setTimeout> | undefined }
|
|
||||||
const checkSunset = () => {
|
|
||||||
if (Date.now() >= sunset.getTime()) {
|
|
||||||
setOldInterfaceRetired(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
timeout.current = setTimeout(checkSunset, nextSunsetCheckDelay(sunset.getTime(), Date.now()))
|
|
||||||
}
|
|
||||||
checkSunset()
|
|
||||||
onCleanup(() => {
|
|
||||||
if (timeout.current !== undefined) clearTimeout(timeout.current)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!launchReady() || launchState.classified) return
|
|
||||||
setLaunchState({
|
|
||||||
classified: true,
|
|
||||||
previous: launch.version,
|
|
||||||
})
|
|
||||||
if (!platform.version || launch.version === platform.version) return
|
|
||||||
setLaunch("version", platform.version)
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!ready() || !launchState.classified || platform.platform !== "web") return
|
|
||||||
if (layoutTransitionClassified()) return
|
|
||||||
setStore("general", "layoutTransitionEligible", hasExistingWebState(settingsInit, launchState.previous))
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!ready() || !launchState.classified || launchState.migrationApplied) return
|
|
||||||
if (layoutUpgrade() && store.general?.newLayoutDesigns !== true) {
|
|
||||||
setStore("general", "newLayoutDesigns", true)
|
|
||||||
}
|
|
||||||
setLaunchState("migrationApplied", true)
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!ready() || !launchState.classified) return
|
|
||||||
if (typeof store.general?.shouldDisplayTabsToast === "boolean") return
|
|
||||||
if (!launchState.previous && !layoutTransitionClassified()) return
|
|
||||||
setStore(
|
|
||||||
"general",
|
|
||||||
"shouldDisplayTabsToast",
|
|
||||||
shouldDisplayTabsToast(launchState.previous, platform.version, layoutTransitionEligible()),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!ready() || !oldInterfaceRetired()) return
|
|
||||||
if (store.general?.newLayoutDesigns === true) return
|
|
||||||
setStore("general", "newLayoutDesigns", true)
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (typeof document === "undefined") return
|
if (typeof document === "undefined") return
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
@@ -413,34 +242,13 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
setMobileTitlebarPosition(value: "top" | "bottom") {
|
setMobileTitlebarPosition(value: "top" | "bottom") {
|
||||||
setStore("general", "mobileTitlebarPosition", value)
|
setStore("general", "mobileTitlebarPosition", value)
|
||||||
},
|
},
|
||||||
newLayoutDesigns,
|
newLayoutDesigns: () => true,
|
||||||
setNewLayoutDesigns(value: boolean) {
|
|
||||||
const next = oldInterfaceRetired() ? true : value
|
|
||||||
if (newLayoutDesigns() === next) return
|
|
||||||
setStore("general", "newLayoutDesigns", next)
|
|
||||||
if (typeof window !== "undefined") setTimeout(() => window.location.reload())
|
|
||||||
},
|
|
||||||
layoutTransitionClassified,
|
|
||||||
setOldLayoutEligible(eligible: boolean) {
|
|
||||||
const current = store.general?.layoutTransitionEligible
|
|
||||||
if (typeof current === "boolean") return
|
|
||||||
setStore("general", "layoutTransitionEligible", eligible)
|
|
||||||
},
|
|
||||||
layoutTransitionAvailable: createMemo(() => ready() && layoutTransition().available),
|
|
||||||
newInterfaceNoticeVisible: createMemo(() => ready() && layoutTransition().notice),
|
|
||||||
dismissNewInterfaceNotice() {
|
|
||||||
setStore("general", "newInterfaceNoticeDismissed", true)
|
|
||||||
},
|
|
||||||
shouldDisplayTabsToast: withFallback(() => store.general?.shouldDisplayTabsToast, false),
|
|
||||||
dismissTabsToast() {
|
|
||||||
setStore("general", "shouldDisplayTabsToast", false)
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
visibility: {
|
visibility: {
|
||||||
fileTree: visible(showFileTree),
|
fileTree: showFileTree,
|
||||||
search: visible(showSearch),
|
search: showSearch,
|
||||||
status: visible(showStatus),
|
status: showStatus,
|
||||||
customAgents: visible(showCustomAgents),
|
customAgents: showCustomAgents,
|
||||||
},
|
},
|
||||||
appearance: {
|
appearance: {
|
||||||
fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize),
|
fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize),
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { createHomeSessionSearchController } from "./home/home-session-search-co
|
|||||||
import { createHomeSessionsController } from "./home/home-sessions-controller"
|
import { createHomeSessionsController } from "./home/home-sessions-controller"
|
||||||
import { HomeSessions } from "./home/home-sessions"
|
import { HomeSessions } from "./home/home-sessions"
|
||||||
|
|
||||||
export function NewHome() {
|
export function Home() {
|
||||||
const home = createHomeController()
|
const home = createHomeController()
|
||||||
const projects = createHomeProjectsController(home)
|
const projects = createHomeProjectsController(home)
|
||||||
const sessions = createHomeSessionsController(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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+22
-2388
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 { createStore } from "solid-js/store"
|
||||||
import { DockShell } from "@opencode-ai/ui/dock-surface"
|
import { DockShell } from "@opencode-ai/ui/dock-surface"
|
||||||
import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock"
|
import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock"
|
||||||
import { SettingsProvider, useSettings } from "@/context/settings"
|
import { SettingsProvider } from "@/context/settings"
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: "Composer/Revert Dock",
|
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 live composer overlaps the dock's bottom by 18px (\`session-composer-region-controller.ts\` \`lift()\`).
|
||||||
The card below reproduces that overlap so the collapsed/expanded cutoff behavior can be verified in isolation.
|
The card below reproduces that overlap so the collapsed/expanded cutoff behavior can be verified in isolation.
|
||||||
|
|
||||||
### Layout split
|
|
||||||
Use the **Layout** button to toggle \`newLayoutDesigns\` and preview both the v2 dock and the legacy (v1) \`DockTray\` fallback.
|
|
||||||
|
|
||||||
### Notes
|
### Notes
|
||||||
- \`onRestore\` only mutates local story state, so nothing in the real session is affected.
|
- \`onRestore\` only mutates local story state, so nothing in the real session is affected.
|
||||||
- Click the header to expand/collapse. Click "Restore message" to remove a row.`,
|
- Click the header to expand/collapse. Click "Restore message" to remove a row.`,
|
||||||
@@ -53,11 +50,9 @@ const btn = (accent?: boolean) =>
|
|||||||
}) as const
|
}) as const
|
||||||
|
|
||||||
function Stage(props: { count: number }) {
|
function Stage(props: { count: number }) {
|
||||||
const settings = useSettings()
|
|
||||||
const seed = () => messages.slice(0, props.count).map((text, index) => ({ id: `rolled-${index}`, text }))
|
const seed = () => messages.slice(0, props.count).map((text, index) => ({ id: `rolled-${index}`, text }))
|
||||||
const [store, setStore] = createStore({ items: seed() })
|
const [store, setStore] = createStore({ items: seed() })
|
||||||
|
|
||||||
const v2 = () => settings.general.newLayoutDesigns()
|
|
||||||
const reset = () => setStore("items", seed())
|
const reset = () => setStore("items", seed())
|
||||||
const restore = (id: string) =>
|
const restore = (id: string) =>
|
||||||
setStore(
|
setStore(
|
||||||
@@ -71,22 +66,15 @@ function Stage(props: { count: number }) {
|
|||||||
<button style={btn()} onClick={reset}>
|
<button style={btn()} onClick={reset}>
|
||||||
Reset ({props.count})
|
Reset ({props.count})
|
||||||
</button>
|
</button>
|
||||||
<button style={btn(v2())} onClick={() => settings.general.setNewLayoutDesigns(!v2())}>
|
|
||||||
Layout: {v2() ? "v2" : "v1"}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Reproduce the real composer stack: dock + card overlapping the dock's bottom by lift() = 18px */}
|
{/* Reproduce the real composer stack: dock + card overlapping the dock's bottom by lift() = 18px */}
|
||||||
<div style={{ display: "flex", "flex-direction": "column" }}>
|
<div style={{ display: "flex", "flex-direction": "column" }}>
|
||||||
<SessionRevertDock items={store.items} onRestore={restore} />
|
<SessionRevertDock items={store.items} onRestore={restore} />
|
||||||
<DockShell
|
<DockShell
|
||||||
data-dock-border-underlay={v2() ? "v2" : "legacy"}
|
data-dock-border-underlay="v2"
|
||||||
style={{ position: "relative", "z-index": 70, "margin-top": "-18px" }}
|
style={{ position: "relative", "z-index": 70, "margin-top": "-18px" }}
|
||||||
classList={{
|
class="min-h-24 w-full rounded-[12px] bg-v2-background-bg-base px-4 py-3 text-[13px] text-v2-text-text-faint"
|
||||||
"min-h-24 w-full rounded-[12px] px-4 py-3 text-[13px]": true,
|
|
||||||
"bg-v2-background-bg-base text-v2-text-text-faint": v2(),
|
|
||||||
"text-text-weak": !v2(),
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Ask anything...
|
Ask anything...
|
||||||
</DockShell>
|
</DockShell>
|
||||||
|
|||||||
@@ -263,38 +263,52 @@ export type Endpoint5_23Input = { readonly sessionID: Session.ID }
|
|||||||
export type Endpoint5_23Output = ReadonlyArray<SessionPending.Info>
|
export type Endpoint5_23Output = ReadonlyArray<SessionPending.Info>
|
||||||
export type SessionPendingListOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
|
export type SessionPendingListOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
|
||||||
|
|
||||||
export type Endpoint5_24Input = { readonly sessionID: Session.ID }
|
export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
|
||||||
export type Endpoint5_24Output = ReadonlyArray<InstructionEntry.Info>
|
export type Endpoint5_24Output = void
|
||||||
export type SessionInstructionsEntryListOperation<E = never> = (
|
export type SessionPendingCancelOperation<E = never> = (
|
||||||
input: Endpoint5_24Input,
|
input: Endpoint5_24Input,
|
||||||
) => Effect.Effect<Endpoint5_24Output, E>
|
) => Effect.Effect<Endpoint5_24Output, E>
|
||||||
|
|
||||||
export type Endpoint5_25Input = {
|
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 sessionID: Session.ID
|
||||||
readonly key: InstructionEntry.Key
|
readonly key: InstructionEntry.Key
|
||||||
readonly value: Schema.Json
|
readonly value: Schema.Json
|
||||||
}
|
}
|
||||||
export type Endpoint5_25Output = void
|
export type Endpoint5_28Output = void
|
||||||
export type SessionInstructionsEntryPutOperation<E = never> = (
|
export type SessionInstructionsEntryPutOperation<E = never> = (
|
||||||
input: Endpoint5_25Input,
|
input: Endpoint5_28Input,
|
||||||
) => Effect.Effect<Endpoint5_25Output, E>
|
) => Effect.Effect<Endpoint5_28Output, E>
|
||||||
|
|
||||||
export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
|
export type Endpoint5_29Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
|
||||||
export type Endpoint5_26Output = void
|
export type Endpoint5_29Output = void
|
||||||
export type SessionInstructionsEntryRemoveOperation<E = never> = (
|
export type SessionInstructionsEntryRemoveOperation<E = never> = (
|
||||||
input: Endpoint5_26Input,
|
input: Endpoint5_29Input,
|
||||||
) => Effect.Effect<Endpoint5_26Output, E>
|
) => Effect.Effect<Endpoint5_29Output, E>
|
||||||
|
|
||||||
export type Endpoint5_27Input = { readonly sessionID: Session.ID; readonly prompt: string }
|
export type Endpoint5_30Input = { readonly sessionID: Session.ID; readonly prompt: string }
|
||||||
export type Endpoint5_27Output = { readonly text: string }
|
export type Endpoint5_30Output = { readonly text: string }
|
||||||
export type SessionGenerateOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
|
export type SessionGenerateOperation<E = never> = (input: Endpoint5_30Input) => Effect.Effect<Endpoint5_30Output, E>
|
||||||
|
|
||||||
export type Endpoint5_28Input = {
|
export type Endpoint5_31Input = {
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly after?: Event.Seq | undefined
|
readonly after?: Event.Seq | undefined
|
||||||
readonly follow?: boolean | undefined
|
readonly follow?: boolean | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint5_28Output =
|
export type Endpoint5_31Output =
|
||||||
| (
|
| (
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
@@ -404,6 +418,33 @@ export type Endpoint5_28Output =
|
|||||||
readonly input: SessionPending.Message
|
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 id: Event.ID
|
||||||
readonly created: DateTime.Utc
|
readonly created: DateTime.Utc
|
||||||
@@ -862,19 +903,19 @@ export type Endpoint5_28Output =
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
| EventLog.Synced
|
| EventLog.Synced
|
||||||
export type SessionLogOperation<E = never> = (input: Endpoint5_28Input) => Stream.Stream<Endpoint5_28Output, E>
|
export type SessionLogOperation<E = never> = (input: Endpoint5_31Input) => Stream.Stream<Endpoint5_31Output, E>
|
||||||
|
|
||||||
export type Endpoint5_29Input = { readonly sessionID: Session.ID }
|
export type Endpoint5_32Input = { readonly sessionID: Session.ID }
|
||||||
export type Endpoint5_29Output = void
|
export type Endpoint5_32Output = void
|
||||||
export type SessionInterruptOperation<E = never> = (input: Endpoint5_29Input) => Effect.Effect<Endpoint5_29Output, E>
|
export type SessionInterruptOperation<E = never> = (input: Endpoint5_32Input) => Effect.Effect<Endpoint5_32Output, E>
|
||||||
|
|
||||||
export type Endpoint5_30Input = { readonly sessionID: Session.ID }
|
export type Endpoint5_33Input = { readonly sessionID: Session.ID }
|
||||||
export type Endpoint5_30Output = void
|
export type Endpoint5_33Output = void
|
||||||
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_30Input) => Effect.Effect<Endpoint5_30Output, E>
|
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_33Input) => Effect.Effect<Endpoint5_33Output, E>
|
||||||
|
|
||||||
export type Endpoint5_31Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
|
export type Endpoint5_34Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
|
||||||
export type Endpoint5_31Output = SessionMessage.Info
|
export type Endpoint5_34Output = SessionMessage.Info
|
||||||
export type SessionMessageOperation<E = never> = (input: Endpoint5_31Input) => Effect.Effect<Endpoint5_31Output, E>
|
export type SessionMessageOperation<E = never> = (input: Endpoint5_34Input) => Effect.Effect<Endpoint5_34Output, E>
|
||||||
|
|
||||||
export interface SessionApi<E = never> {
|
export interface SessionApi<E = never> {
|
||||||
readonly list: SessionListOperation<E>
|
readonly list: SessionListOperation<E>
|
||||||
@@ -902,7 +943,12 @@ export interface SessionApi<E = never> {
|
|||||||
readonly commit: SessionRevertCommitOperation<E>
|
readonly commit: SessionRevertCommitOperation<E>
|
||||||
}
|
}
|
||||||
readonly context: SessionContextOperation<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 instructions: {
|
||||||
readonly entry: {
|
readonly entry: {
|
||||||
readonly list: SessionInstructionsEntryListOperation<E>
|
readonly list: SessionInstructionsEntryListOperation<E>
|
||||||
|
|||||||
@@ -80,6 +80,12 @@ import type {
|
|||||||
Endpoint5_30Output,
|
Endpoint5_30Output,
|
||||||
Endpoint5_31Input,
|
Endpoint5_31Input,
|
||||||
Endpoint5_31Output,
|
Endpoint5_31Output,
|
||||||
|
Endpoint5_32Input,
|
||||||
|
Endpoint5_32Output,
|
||||||
|
Endpoint5_33Input,
|
||||||
|
Endpoint5_33Output,
|
||||||
|
Endpoint5_34Input,
|
||||||
|
Endpoint5_34Output,
|
||||||
Endpoint6_0Input,
|
Endpoint6_0Input,
|
||||||
Endpoint6_0Output,
|
Endpoint6_0Output,
|
||||||
Endpoint7_0Input,
|
Endpoint7_0Input,
|
||||||
@@ -523,37 +529,58 @@ const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23I
|
|||||||
|
|
||||||
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
|
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
|
||||||
preserveEffect<Endpoint5_24Output>()(
|
preserveEffect<Endpoint5_24Output>()(
|
||||||
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
raw["session.pending.cancel"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
|
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
|
||||||
preserveEffect<Endpoint5_25Output>()(
|
preserveEffect<Endpoint5_25Output>()(
|
||||||
raw["session.instructions.entry.put"]({
|
raw["session.pending.steer"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
|
||||||
params: { sessionID: input["sessionID"], key: input["key"] },
|
Effect.mapError(mapClientError),
|
||||||
payload: { value: input["value"] },
|
),
|
||||||
}).pipe(Effect.mapError(mapClientError)),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
|
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
|
||||||
preserveEffect<Endpoint5_26Output>()(
|
preserveEffect<Endpoint5_26Output>()(
|
||||||
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
|
raw["session.pending.queue"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
|
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
|
||||||
preserveEffect<Endpoint5_27Output>()(
|
preserveEffect<Endpoint5_27Output>()(
|
||||||
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
|
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
|
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
|
||||||
preserveStream<Endpoint5_28Output>()(
|
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_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) =>
|
||||||
|
preserveStream<Endpoint5_31Output>()(
|
||||||
Stream.unwrap(
|
Stream.unwrap(
|
||||||
raw["session.log"]({
|
raw["session.log"]({
|
||||||
params: { sessionID: input["sessionID"] },
|
params: { sessionID: input["sessionID"] },
|
||||||
@@ -565,18 +592,18 @@ const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28I
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
|
const Endpoint5_32 = (raw: RawClient["server.session"]) => (input: Endpoint5_32Input) =>
|
||||||
preserveEffect<Endpoint5_29Output>()(
|
preserveEffect<Endpoint5_32Output>()(
|
||||||
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
|
const Endpoint5_33 = (raw: RawClient["server.session"]) => (input: Endpoint5_33Input) =>
|
||||||
preserveEffect<Endpoint5_30Output>()(
|
preserveEffect<Endpoint5_33Output>()(
|
||||||
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) =>
|
const Endpoint5_34 = (raw: RawClient["server.session"]) => (input: Endpoint5_34Input) =>
|
||||||
preserveEffect<Endpoint5_31Output>()(
|
preserveEffect<Endpoint5_34Output>()(
|
||||||
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
|
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
@@ -605,13 +632,13 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
|
|||||||
wait: Endpoint5_18(raw),
|
wait: Endpoint5_18(raw),
|
||||||
revert: { stage: Endpoint5_19(raw), clear: Endpoint5_20(raw), commit: Endpoint5_21(raw) },
|
revert: { stage: Endpoint5_19(raw), clear: Endpoint5_20(raw), commit: Endpoint5_21(raw) },
|
||||||
context: Endpoint5_22(raw),
|
context: Endpoint5_22(raw),
|
||||||
pending: { list: Endpoint5_23(raw) },
|
pending: { list: Endpoint5_23(raw), cancel: Endpoint5_24(raw), steer: Endpoint5_25(raw), queue: Endpoint5_26(raw) },
|
||||||
instructions: { entry: { list: Endpoint5_24(raw), put: Endpoint5_25(raw), remove: Endpoint5_26(raw) } },
|
instructions: { entry: { list: Endpoint5_27(raw), put: Endpoint5_28(raw), remove: Endpoint5_29(raw) } },
|
||||||
generate: Endpoint5_27(raw),
|
generate: Endpoint5_30(raw),
|
||||||
log: Endpoint5_28(raw),
|
log: Endpoint5_31(raw),
|
||||||
interrupt: Endpoint5_29(raw),
|
interrupt: Endpoint5_32(raw),
|
||||||
background: Endpoint5_30(raw),
|
background: Endpoint5_33(raw),
|
||||||
message: Endpoint5_31(raw),
|
message: Endpoint5_34(raw),
|
||||||
})
|
})
|
||||||
|
|
||||||
const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>
|
const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>
|
||||||
|
|||||||
@@ -58,6 +58,12 @@ import type {
|
|||||||
SessionContextOutput,
|
SessionContextOutput,
|
||||||
SessionPendingListInput,
|
SessionPendingListInput,
|
||||||
SessionPendingListOutput,
|
SessionPendingListOutput,
|
||||||
|
SessionPendingCancelInput,
|
||||||
|
SessionPendingCancelOutput,
|
||||||
|
SessionPendingSteerInput,
|
||||||
|
SessionPendingSteerOutput,
|
||||||
|
SessionPendingQueueInput,
|
||||||
|
SessionPendingQueueOutput,
|
||||||
SessionInstructionsEntryListInput,
|
SessionInstructionsEntryListInput,
|
||||||
SessionInstructionsEntryListOutput,
|
SessionInstructionsEntryListOutput,
|
||||||
SessionInstructionsEntryPutInput,
|
SessionInstructionsEntryPutInput,
|
||||||
@@ -766,6 +772,39 @@ export function make(options: ClientOptions) {
|
|||||||
},
|
},
|
||||||
requestOptions,
|
requestOptions,
|
||||||
).then((value) => value.data),
|
).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: {
|
instructions: {
|
||||||
entry: {
|
entry: {
|
||||||
|
|||||||
@@ -502,6 +502,36 @@ export type SessionInputPromoted = {
|
|||||||
data: { sessionID: string; inputID: string }
|
data: { sessionID: string; inputID: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SessionInputCancelled = {
|
||||||
|
id: string
|
||||||
|
created: number
|
||||||
|
metadata?: { [x: string]: any }
|
||||||
|
type: "session.input.cancelled"
|
||||||
|
durable: { aggregateID: string; seq: number; version: 1 }
|
||||||
|
location?: LocationRef
|
||||||
|
data: { sessionID: string; inputID: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionInputSteered = {
|
||||||
|
id: string
|
||||||
|
created: number
|
||||||
|
metadata?: { [x: string]: any }
|
||||||
|
type: "session.input.steered"
|
||||||
|
durable: { aggregateID: string; seq: number; version: 1 }
|
||||||
|
location?: LocationRef
|
||||||
|
data: { sessionID: string; inputID: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionInputQueued = {
|
||||||
|
id: string
|
||||||
|
created: number
|
||||||
|
metadata?: { [x: string]: any }
|
||||||
|
type: "session.input.queued"
|
||||||
|
durable: { aggregateID: string; seq: number; version: 1 }
|
||||||
|
location?: LocationRef
|
||||||
|
data: { sessionID: string; inputID: string }
|
||||||
|
}
|
||||||
|
|
||||||
export type SessionExecutionStarted = {
|
export type SessionExecutionStarted = {
|
||||||
id: string
|
id: string
|
||||||
created: number
|
created: number
|
||||||
@@ -1970,6 +2000,9 @@ export type SessionEventDurable =
|
|||||||
| SessionForked
|
| SessionForked
|
||||||
| SessionInputPromoted
|
| SessionInputPromoted
|
||||||
| SessionInputAdmitted
|
| SessionInputAdmitted
|
||||||
|
| SessionInputCancelled
|
||||||
|
| SessionInputSteered
|
||||||
|
| SessionInputQueued
|
||||||
| SessionExecutionStarted
|
| SessionExecutionStarted
|
||||||
| SessionExecutionSucceeded
|
| SessionExecutionSucceeded
|
||||||
| SessionExecutionFailed
|
| SessionExecutionFailed
|
||||||
@@ -2024,6 +2057,9 @@ export type V2Event =
|
|||||||
| SessionForked
|
| SessionForked
|
||||||
| SessionInputPromoted
|
| SessionInputPromoted
|
||||||
| SessionInputAdmitted
|
| SessionInputAdmitted
|
||||||
|
| SessionInputCancelled
|
||||||
|
| SessionInputSteered
|
||||||
|
| SessionInputQueued
|
||||||
| SessionExecutionStarted
|
| SessionExecutionStarted
|
||||||
| SessionExecutionSucceeded
|
| SessionExecutionSucceeded
|
||||||
| SessionExecutionFailed
|
| SessionExecutionFailed
|
||||||
@@ -3689,6 +3725,27 @@ export type SessionPendingListInput = { readonly sessionID: { readonly sessionID
|
|||||||
|
|
||||||
export type SessionPendingListOutput = { data: Array<SessionPendingInfo> }["data"]
|
export type SessionPendingListOutput = { data: Array<SessionPendingInfo> }["data"]
|
||||||
|
|
||||||
|
export type SessionPendingCancelInput = {
|
||||||
|
readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
|
||||||
|
readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionPendingCancelOutput = void
|
||||||
|
|
||||||
|
export type SessionPendingSteerInput = {
|
||||||
|
readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
|
||||||
|
readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionPendingSteerOutput = void
|
||||||
|
|
||||||
|
export type SessionPendingQueueInput = {
|
||||||
|
readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
|
||||||
|
readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionPendingQueueOutput = void
|
||||||
|
|
||||||
export type SessionInstructionsEntryListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
export type SessionInstructionsEntryListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||||
|
|
||||||
export type SessionInstructionsEntryListOutput = { data: Array<InstructionEntryInfo> }["data"]
|
export type SessionInstructionsEntryListOutput = { data: Array<InstructionEntryInfo> }["data"]
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ test("effect entrypoint exposes canonical Schema contracts", () => {
|
|||||||
test("generated Effect API names canonical and composed outputs", async () => {
|
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()
|
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).toContain("export type Endpoint19_0Output = OpenCodeEvent")
|
||||||
expect(source).not.toContain("HttpApiClient.ForApi")
|
expect(source).not.toContain("HttpApiClient.ForApi")
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ test("exposes every standard HTTP API group", () => {
|
|||||||
"projectCopy",
|
"projectCopy",
|
||||||
"vcs",
|
"vcs",
|
||||||
"debug",
|
"debug",
|
||||||
|
"migration",
|
||||||
"websearch",
|
"websearch",
|
||||||
"config",
|
"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" }])
|
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 () => {
|
test("event.subscribe exposes the Promise event stream wire projection", async () => {
|
||||||
const client = OpenCode.make({
|
const client = OpenCode.make({
|
||||||
baseUrl: "http://localhost:3000",
|
baseUrl: "http://localhost:3000",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"opencode": "./bin/opencode"
|
"opencode": "./bin/opencode"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
|
"./environment": "./src/environment/index.ts",
|
||||||
"./session/runner": "./src/session/runner/index.ts",
|
"./session/runner": "./src/session/runner/index.ts",
|
||||||
"./instructions": "./src/instructions/index.ts",
|
"./instructions": "./src/instructions/index.ts",
|
||||||
"./*": "./src/*.ts"
|
"./*": "./src/*.ts"
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export type Result =
|
|||||||
| { readonly type: "rejected"; readonly diagnostics: readonly Diagnostic[] }
|
| { readonly type: "rejected"; readonly diagnostics: readonly Diagnostic[] }
|
||||||
|
|
||||||
const options = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
const options = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
||||||
const unsupportedTopLevel = ["logLevel", "server", "small_model", "subagent_depth", "layout"] as const
|
const unsupportedTopLevel = ["logLevel", "server", "subagent_depth", "layout"] as const
|
||||||
const unsupportedExperimental = [
|
const unsupportedExperimental = [
|
||||||
"disable_paste_summary",
|
"disable_paste_summary",
|
||||||
"batch_tool",
|
"batch_tool",
|
||||||
@@ -113,6 +113,23 @@ export function normalize(input: unknown): Result {
|
|||||||
const legacyAgents = mapValues(decodeMap(input.agent, ConfigAgentV1.Info, ["agent"], diagnostics), (value) =>
|
const legacyAgents = mapValues(decodeMap(input.agent, ConfigAgentV1.Info, ["agent"], diagnostics), (value) =>
|
||||||
canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent(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) =>
|
const modeAgents = mapValues(decodeMap(input.mode, ConfigAgentV1.Info, ["mode"], diagnostics), (value) =>
|
||||||
canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent({ ...value, mode: "primary" })),
|
canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent({ ...value, mode: "primary" })),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||||
|
import type { FilesImpl } from "./files"
|
||||||
|
|
||||||
|
export interface Driver {
|
||||||
|
readonly spawner: ChildProcessSpawner["Service"]
|
||||||
|
readonly overrides?: Partial<FilesImpl>
|
||||||
|
}
|
||||||
|
|
||||||
|
export * as EnvironmentDriver from "./driver"
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { Effect, Stream } from "effect"
|
||||||
|
import { ChildProcess } from "effect/unstable/process"
|
||||||
|
import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||||
|
import { collectStream } from "@opencode-ai/util/process"
|
||||||
|
import { Failed, NotFound, WrongKind, type FileInfo, type FileType, type FilesImpl } from "./files"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Files derived from spawning processes: one process per intent, "$1" is
|
||||||
|
* always the target path. Scripts report classification through an exit-code
|
||||||
|
* protocol (44/45/46) so failures never require parsing localized error text;
|
||||||
|
* LC_ALL=C pins the one stderr match that remains. Requires GNU coreutils and
|
||||||
|
* findutils in the target image — BSD and busybox userlands will not work.
|
||||||
|
* Malformed output from these scripts is our own bug and dies as a defect.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MAX_DATA_BYTES = 64 * 1024 * 1024
|
||||||
|
const MAX_ERROR_BYTES = 64 * 1024
|
||||||
|
const NOT_FOUND = 44
|
||||||
|
const WRONG_KIND = 45
|
||||||
|
const FAILED = 46
|
||||||
|
const TAB = "\t"
|
||||||
|
|
||||||
|
const loadMetadata = (flags = "") => `
|
||||||
|
metadata=$(stat ${flags} -c '%F${TAB}%s${TAB}%Y' -- "$1" 2>&1) || {
|
||||||
|
case "$metadata" in
|
||||||
|
*'No such file or directory'*|*'Not a directory'*) exit ${NOT_FOUND} ;;
|
||||||
|
*) printf '%s' "$metadata" >&2; exit ${FAILED} ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const statScript = `
|
||||||
|
${loadMetadata()}
|
||||||
|
printf '%s\n' "$metadata"
|
||||||
|
`
|
||||||
|
|
||||||
|
const readScript = `
|
||||||
|
${loadMetadata("-L")}
|
||||||
|
kind=\${metadata%%${TAB}*}
|
||||||
|
if [ "$kind" != 'regular file' ] && [ "$kind" != 'regular empty file' ]; then
|
||||||
|
printf '%s' "$kind" >&2
|
||||||
|
exit ${WRONG_KIND}
|
||||||
|
fi
|
||||||
|
printf '%s\n' "$metadata"
|
||||||
|
if [ "$2" = range ]; then
|
||||||
|
dd if="$1" iflag=skip_bytes,count_bytes skip="$3" count="$4" status=none
|
||||||
|
else
|
||||||
|
cat -- "$1"
|
||||||
|
fi
|
||||||
|
`
|
||||||
|
|
||||||
|
const listScript = `
|
||||||
|
${loadMetadata()}
|
||||||
|
kind=\${metadata%%${TAB}*}
|
||||||
|
if [ "$kind" != directory ]; then
|
||||||
|
printf '%s' "$kind" >&2
|
||||||
|
exit ${WRONG_KIND}
|
||||||
|
fi
|
||||||
|
find "$1" -mindepth 1 -maxdepth 1 -printf '%y\\0%f\\0'
|
||||||
|
`
|
||||||
|
|
||||||
|
const moveScript = `
|
||||||
|
${loadMetadata()}
|
||||||
|
mv -- "$1" "$2"
|
||||||
|
`
|
||||||
|
|
||||||
|
interface Result {
|
||||||
|
readonly exitCode: number
|
||||||
|
readonly stdout: Uint8Array
|
||||||
|
readonly stderr: Uint8Array
|
||||||
|
}
|
||||||
|
|
||||||
|
export const execDefaults = (spawner: ChildProcessSpawner["Service"]): FilesImpl => {
|
||||||
|
const run = (
|
||||||
|
path: string,
|
||||||
|
script: string,
|
||||||
|
args: ReadonlyArray<string> = [],
|
||||||
|
stdin?: Uint8Array,
|
||||||
|
): Effect.Effect<Result, Failed> =>
|
||||||
|
Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const command = ChildProcess.make("sh", ["-c", script, "sh", path, ...args], {
|
||||||
|
env: { LC_ALL: "C" },
|
||||||
|
extendEnv: true,
|
||||||
|
stdin: stdin === undefined ? undefined : Stream.make(stdin),
|
||||||
|
})
|
||||||
|
const handle = yield* spawner.spawn(command).pipe(Effect.mapError((cause) => new Failed({ path, cause })))
|
||||||
|
const [stdout, stderr, exitCode] = yield* Effect.all(
|
||||||
|
[
|
||||||
|
collectStream(handle.stdout, MAX_DATA_BYTES),
|
||||||
|
collectStream(handle.stderr, MAX_ERROR_BYTES),
|
||||||
|
handle.exitCode,
|
||||||
|
],
|
||||||
|
{ concurrency: "unbounded" },
|
||||||
|
).pipe(Effect.mapError((cause) => new Failed({ path, cause })))
|
||||||
|
if (stdout.truncated || stderr.truncated) {
|
||||||
|
return yield* new Failed({ path, cause: new Error("Process output exceeded its collection limit") })
|
||||||
|
}
|
||||||
|
return { exitCode, stdout: stdout.buffer, stderr: stderr.buffer }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const classify = <A>(
|
||||||
|
path: string,
|
||||||
|
result: Result,
|
||||||
|
success: (stdout: Uint8Array) => A,
|
||||||
|
): Effect.Effect<A, NotFound | WrongKind | Failed> => {
|
||||||
|
if (result.exitCode === 0) return Effect.sync(() => success(result.stdout))
|
||||||
|
if (result.exitCode === NOT_FOUND) return Effect.fail(new NotFound({ path }))
|
||||||
|
if (result.exitCode === WRONG_KIND) {
|
||||||
|
return Effect.fail(new WrongKind({ path, actual: parseType(new TextDecoder().decode(result.stderr)) }))
|
||||||
|
}
|
||||||
|
return Effect.fail(processFailure(path, result))
|
||||||
|
}
|
||||||
|
|
||||||
|
const complete = (path: string, result: Result) =>
|
||||||
|
result.exitCode === 0 ? Effect.void : Effect.fail(processFailure(path, result))
|
||||||
|
|
||||||
|
return {
|
||||||
|
stat: (path) => run(path, statScript).pipe(Effect.flatMap((result) => classifyPlain(path, result, parseInfo))),
|
||||||
|
read: (path, range) =>
|
||||||
|
run(
|
||||||
|
path,
|
||||||
|
readScript,
|
||||||
|
range === undefined ? ["whole"] : ["range", String(range.offset), String(range.length)],
|
||||||
|
).pipe(
|
||||||
|
Effect.flatMap((result) =>
|
||||||
|
classify(path, result, (stdout) => {
|
||||||
|
const newline = stdout.indexOf(10)
|
||||||
|
if (newline < 0) throw new Error("Missing read metadata header")
|
||||||
|
return {
|
||||||
|
info: parseInfo(stdout.slice(0, newline)),
|
||||||
|
bytes: stdout.slice(newline + 1),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
write: (path, bytes) =>
|
||||||
|
run(path, `mkdir -p "$(dirname "$1")" && cat > "$1"`, [], bytes).pipe(
|
||||||
|
Effect.flatMap((result) => complete(path, result)),
|
||||||
|
),
|
||||||
|
list: (path) => run(path, listScript).pipe(Effect.flatMap((result) => classify(path, result, parseList))),
|
||||||
|
remove: (path) => run(path, `rm -rf -- "$1"`).pipe(Effect.flatMap((result) => complete(path, result))),
|
||||||
|
move: (from, to) =>
|
||||||
|
run(from, moveScript, [to]).pipe(Effect.flatMap((result) => classifyPlain(from, result, () => undefined))),
|
||||||
|
mkdir: (path) => run(path, `mkdir -p -- "$1"`).pipe(Effect.flatMap((result) => complete(path, result))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `classify` for scripts whose protocol never reports WrongKind. */
|
||||||
|
const classifyPlain = <A>(
|
||||||
|
path: string,
|
||||||
|
result: Result,
|
||||||
|
success: (stdout: Uint8Array) => A,
|
||||||
|
): Effect.Effect<A, NotFound | Failed> => {
|
||||||
|
if (result.exitCode === 0) return Effect.sync(() => success(result.stdout))
|
||||||
|
if (result.exitCode === NOT_FOUND) return Effect.fail(new NotFound({ path }))
|
||||||
|
return Effect.fail(processFailure(path, result))
|
||||||
|
}
|
||||||
|
|
||||||
|
const processFailure = (path: string, result: Result) =>
|
||||||
|
new Failed({
|
||||||
|
path,
|
||||||
|
cause: new Error(new TextDecoder().decode(result.stderr).trim() || `Process exited with code ${result.exitCode}`),
|
||||||
|
})
|
||||||
|
|
||||||
|
const parseInfo = (bytes: Uint8Array): FileInfo => {
|
||||||
|
const [rawType, rawSize, rawMtime] = new TextDecoder().decode(bytes).trim().split(TAB)
|
||||||
|
const size = Number(rawSize)
|
||||||
|
const mtimeMs = Number(rawMtime) * 1_000
|
||||||
|
if (!rawType || !Number.isFinite(size) || !Number.isFinite(mtimeMs)) throw new Error("Invalid stat output")
|
||||||
|
return { type: parseType(rawType), size, mtimeMs }
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseType = (value: string): FileType => {
|
||||||
|
if (value === "regular file" || value === "regular empty file" || value === "f") return "file"
|
||||||
|
if (value === "directory" || value === "d") return "directory"
|
||||||
|
if (value === "symbolic link" || value === "l") return "symlink"
|
||||||
|
return "other"
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseList = (bytes: Uint8Array) => {
|
||||||
|
const fields = new TextDecoder().decode(bytes).split("\0")
|
||||||
|
fields.pop()
|
||||||
|
if (fields.length % 2 !== 0) throw new Error("Invalid find output")
|
||||||
|
return Array.from({ length: fields.length / 2 }, (_, index) => ({
|
||||||
|
name: fields[index * 2 + 1],
|
||||||
|
type: parseType(fields[index * 2]),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export * as EnvironmentExecDefaults from "./exec-defaults"
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { Effect, Schema } from "effect"
|
||||||
|
|
||||||
|
export const FileType = Schema.Literals(["file", "directory", "symlink", "other"])
|
||||||
|
export type FileType = typeof FileType.Type
|
||||||
|
|
||||||
|
export interface FileInfo {
|
||||||
|
readonly type: FileType
|
||||||
|
readonly size: number
|
||||||
|
readonly mtimeMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DirEntry {
|
||||||
|
readonly name: string
|
||||||
|
readonly type: FileType
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NotFound extends Schema.TaggedErrorClass<NotFound>()("Environment.NotFound", {
|
||||||
|
path: Schema.String,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export class WrongKind extends Schema.TaggedErrorClass<WrongKind>()("Environment.WrongKind", {
|
||||||
|
path: Schema.String,
|
||||||
|
actual: FileType,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export class Failed extends Schema.TaggedErrorClass<Failed>()("Environment.Failed", {
|
||||||
|
path: Schema.String,
|
||||||
|
cause: Schema.Defect(),
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export interface FilesImpl {
|
||||||
|
/**
|
||||||
|
* Reads a file, following a final symlink so `info` describes the target whose bytes are returned.
|
||||||
|
* The process-backed default caps collected output at 64 MiB; larger whole-file reads fail with
|
||||||
|
* `Failed`, so callers must use ranges for larger files.
|
||||||
|
*/
|
||||||
|
readonly read: (
|
||||||
|
path: string,
|
||||||
|
range?: { readonly offset: number; readonly length: number },
|
||||||
|
) => Effect.Effect<{ readonly info: FileInfo; readonly bytes: Uint8Array }, NotFound | WrongKind | Failed>
|
||||||
|
readonly write: (path: string, bytes: Uint8Array) => Effect.Effect<void, Failed>
|
||||||
|
/** Describes the path entry itself, so a final symlink is reported as `symlink` rather than followed. */
|
||||||
|
readonly stat: (path: string) => Effect.Effect<FileInfo, NotFound | Failed>
|
||||||
|
/** Lists a directory entry without following a final symlink; intermediate symlinks are traversed. */
|
||||||
|
readonly list: (path: string) => Effect.Effect<ReadonlyArray<DirEntry>, NotFound | WrongKind | Failed>
|
||||||
|
readonly remove: (path: string) => Effect.Effect<void, Failed>
|
||||||
|
readonly move: (from: string, to: string) => Effect.Effect<void, NotFound | Failed>
|
||||||
|
readonly mkdir: (path: string) => Effect.Effect<void, Failed>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Files extends FilesImpl {}
|
||||||
|
|
||||||
|
export * as EnvironmentFiles from "./files"
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export * as Environment from "./index"
|
||||||
|
|
||||||
|
export { type Driver } from "./driver"
|
||||||
|
export {
|
||||||
|
type DirEntry,
|
||||||
|
Failed,
|
||||||
|
type FileInfo,
|
||||||
|
type Files,
|
||||||
|
type FilesImpl,
|
||||||
|
type FileType,
|
||||||
|
NotFound,
|
||||||
|
WrongKind,
|
||||||
|
} from "./files"
|
||||||
|
export { execDefaults } from "./exec-defaults"
|
||||||
|
export { makeMemoryDriver, type MemoryDriver } from "./memory"
|
||||||
|
|
||||||
|
import type { Driver } from "./driver"
|
||||||
|
import { execDefaults } from "./exec-defaults"
|
||||||
|
import type { Files } from "./files"
|
||||||
|
|
||||||
|
export const makeFiles = (driver: Driver): Files => ({
|
||||||
|
...execDefaults(driver.spawner),
|
||||||
|
...driver.overrides,
|
||||||
|
})
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import path from "node:path"
|
||||||
|
import { Effect, PlatformError } from "effect"
|
||||||
|
import { make } from "effect/unstable/process/ChildProcessSpawner"
|
||||||
|
import type { Driver } from "./driver"
|
||||||
|
import { Failed, NotFound, WrongKind, type FileInfo, type FilesImpl, type FileType } from "./files"
|
||||||
|
|
||||||
|
type Node =
|
||||||
|
| { readonly type: "file"; readonly bytes: Uint8Array; readonly mtimeMs: number }
|
||||||
|
| { readonly type: "directory"; readonly mtimeMs: number }
|
||||||
|
| { readonly type: "symlink"; readonly target: string; readonly mtimeMs: number }
|
||||||
|
|
||||||
|
export interface MemoryDriver extends Driver {
|
||||||
|
readonly symlink: (target: string, path: string) => Effect.Effect<void, Failed>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makeMemoryDriver = (): MemoryDriver => {
|
||||||
|
const nodes = new Map<string, Node>([["/", { type: "directory", mtimeMs: Date.now() }]])
|
||||||
|
const key = (value: string) => path.posix.resolve("/", value)
|
||||||
|
const info = (node: Node): FileInfo => ({
|
||||||
|
type: node.type,
|
||||||
|
size:
|
||||||
|
node.type === "file"
|
||||||
|
? node.bytes.length
|
||||||
|
: node.type === "symlink"
|
||||||
|
? new TextEncoder().encode(node.target).length
|
||||||
|
: 0,
|
||||||
|
mtimeMs: node.mtimeMs,
|
||||||
|
})
|
||||||
|
const resolveKey = (value: string, followFinal: boolean, seen = new Set<string>()): string | undefined => {
|
||||||
|
const normalized = key(value)
|
||||||
|
const parts = normalized.split("/").filter(Boolean)
|
||||||
|
const base = "/"
|
||||||
|
const walk = (current: string, index: number): string | undefined => {
|
||||||
|
if (index === parts.length) return current
|
||||||
|
const part = parts[index]
|
||||||
|
const candidate = path.posix.join(current, part)
|
||||||
|
const node = nodes.get(candidate)
|
||||||
|
if (node?.type !== "symlink" || (!followFinal && index === parts.length - 1)) return walk(candidate, index + 1)
|
||||||
|
if (seen.has(candidate)) return undefined
|
||||||
|
seen.add(candidate)
|
||||||
|
const target = path.posix.resolve(path.posix.dirname(candidate), node.target)
|
||||||
|
return resolveKey(path.posix.join(target, ...parts.slice(index + 1)), followFinal, seen)
|
||||||
|
}
|
||||||
|
return walk(base, 0)
|
||||||
|
}
|
||||||
|
const lookup = (value: string) => nodes.get(resolveKey(value, false) ?? key(value))
|
||||||
|
const requireParent = (value: string) => {
|
||||||
|
const parentPath = path.posix.dirname(key(value))
|
||||||
|
const parent = nodes.get(resolveKey(parentPath, true) ?? parentPath)
|
||||||
|
if (!parent) throw new Error(`Parent directory does not exist: ${path.posix.dirname(value)}`)
|
||||||
|
if (parent.type !== "directory") throw new Error(`Parent is not a directory: ${path.posix.dirname(value)}`)
|
||||||
|
}
|
||||||
|
const mkdirSync = (value: string) => {
|
||||||
|
const target = resolveKey(value, false) ?? key(value)
|
||||||
|
const existing = nodes.get(target)
|
||||||
|
if (existing?.type === "directory") return
|
||||||
|
if (existing) throw new Error(`Path is not a directory: ${value}`)
|
||||||
|
const parent = path.posix.dirname(target)
|
||||||
|
if (parent !== target) mkdirSync(parent)
|
||||||
|
nodes.set(target, { type: "directory", mtimeMs: Date.now() })
|
||||||
|
}
|
||||||
|
const failed = (value: string, cause: unknown) => new Failed({ path: value, cause })
|
||||||
|
const overrides: FilesImpl = {
|
||||||
|
stat: (value) => {
|
||||||
|
const node = lookup(value)
|
||||||
|
return node ? Effect.succeed(info(node)) : Effect.fail(new NotFound({ path: value }))
|
||||||
|
},
|
||||||
|
read: (value, range) => {
|
||||||
|
const original = lookup(value)
|
||||||
|
if (!original) return Effect.fail(new NotFound({ path: value }))
|
||||||
|
if (original.type === "directory") return Effect.fail(new WrongKind({ path: value, actual: "directory" }))
|
||||||
|
const resolved = resolveKey(value, true)
|
||||||
|
const node = resolved === undefined ? undefined : nodes.get(resolved)
|
||||||
|
if (!node) return Effect.fail(new NotFound({ path: value }))
|
||||||
|
if (node.type !== "file") return Effect.fail(new WrongKind({ path: value, actual: node.type }))
|
||||||
|
const bytes = range === undefined ? node.bytes : node.bytes.subarray(range.offset, range.offset + range.length)
|
||||||
|
return Effect.succeed({ info: info(node), bytes: bytes.slice() })
|
||||||
|
},
|
||||||
|
write: (value, bytes) =>
|
||||||
|
Effect.try({
|
||||||
|
try: () => {
|
||||||
|
mkdirSync(path.posix.dirname(key(value)))
|
||||||
|
const existing = lookup(value)
|
||||||
|
if (existing?.type === "directory") throw new Error(`Path is a directory: ${value}`)
|
||||||
|
const target = existing?.type === "symlink" ? resolveKey(value, true) : resolveKey(value, false)
|
||||||
|
if (!target) throw new Error(`Cannot resolve symlink: ${value}`)
|
||||||
|
requireParent(target)
|
||||||
|
nodes.set(target, { type: "file", bytes: bytes.slice(), mtimeMs: Date.now() })
|
||||||
|
},
|
||||||
|
catch: (cause) => failed(value, cause),
|
||||||
|
}),
|
||||||
|
list: (value) => {
|
||||||
|
const target = resolveKey(value, false) ?? key(value)
|
||||||
|
const node = nodes.get(target)
|
||||||
|
if (!node) return Effect.fail(new NotFound({ path: value }))
|
||||||
|
if (node.type !== "directory") return Effect.fail(new WrongKind({ path: value, actual: node.type }))
|
||||||
|
const entries = [...nodes.entries()]
|
||||||
|
.filter(([entry]) => entry !== target && path.posix.dirname(entry) === target)
|
||||||
|
.map(([entry, child]) => ({ name: path.posix.basename(entry), type: child.type satisfies FileType }))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
return Effect.succeed(entries)
|
||||||
|
},
|
||||||
|
remove: (value) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
const target = resolveKey(value, false) ?? key(value)
|
||||||
|
for (const entry of nodes.keys()) {
|
||||||
|
if (entry === target || entry.startsWith(`${target}/`)) nodes.delete(entry)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
move: (from, to) => {
|
||||||
|
const source = resolveKey(from, false) ?? key(from)
|
||||||
|
const node = nodes.get(source)
|
||||||
|
if (!node) return Effect.fail(new NotFound({ path: from }))
|
||||||
|
return Effect.try({
|
||||||
|
try: () => {
|
||||||
|
const requested = resolveKey(to, false) ?? key(to)
|
||||||
|
const destination =
|
||||||
|
nodes.get(requested)?.type === "directory"
|
||||||
|
? path.posix.join(requested, path.posix.basename(source))
|
||||||
|
: requested
|
||||||
|
if (node.type === "directory" && destination.startsWith(`${source}/`)) {
|
||||||
|
throw new Error(`Cannot move a directory into itself: ${from}`)
|
||||||
|
}
|
||||||
|
const existing = nodes.get(destination)
|
||||||
|
if (node.type === "directory" && existing && existing.type !== "directory") {
|
||||||
|
throw new Error(`Cannot overwrite a non-directory with a directory: ${to}`)
|
||||||
|
}
|
||||||
|
requireParent(destination)
|
||||||
|
const moved = [...nodes.entries()].filter(([entry]) => entry === source || entry.startsWith(`${source}/`))
|
||||||
|
for (const [entry] of moved) nodes.delete(entry)
|
||||||
|
for (const [entry, child] of moved) nodes.set(`${destination}${entry.slice(source.length)}`, child)
|
||||||
|
},
|
||||||
|
catch: (cause) => failed(from, cause),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
mkdir: (value) => Effect.try({ try: () => mkdirSync(value), catch: (cause) => failed(value, cause) }),
|
||||||
|
}
|
||||||
|
|
||||||
|
const spawner = make((command) =>
|
||||||
|
Effect.suspend(() => {
|
||||||
|
const description = command._tag === "StandardCommand" ? command.command : "pipeline"
|
||||||
|
return Effect.fail(
|
||||||
|
PlatformError.systemError({
|
||||||
|
_tag: "Unknown",
|
||||||
|
module: "EnvironmentMemory",
|
||||||
|
method: "spawn",
|
||||||
|
pathOrDescriptor: description,
|
||||||
|
cause: failed(description, new Error("The memory driver cannot spawn processes")),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
spawner,
|
||||||
|
overrides,
|
||||||
|
symlink: (target, value) =>
|
||||||
|
Effect.try({
|
||||||
|
try: () => {
|
||||||
|
requireParent(value)
|
||||||
|
nodes.set(resolveKey(value, false) ?? key(value), { type: "symlink", target, mtimeMs: Date.now() })
|
||||||
|
},
|
||||||
|
catch: (cause) => failed(value, cause),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export * as EnvironmentMemory from "./memory"
|
||||||
@@ -7,7 +7,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
|||||||
import { Bom } from "@opencode-ai/util/bom"
|
import { Bom } from "@opencode-ai/util/bom"
|
||||||
|
|
||||||
export interface Target {
|
export interface Target {
|
||||||
readonly canonical: string
|
readonly absolute: string
|
||||||
readonly resource: string
|
readonly resource: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ export interface Interface {
|
|||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/FileMutation") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/FileMutation") {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serialize file changes by 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
|
* write under the same process-local lock so cooperating OpenCode mutations do
|
||||||
* not overwrite changes made from the same stale content.
|
* not overwrite changes made from the same stale content.
|
||||||
*/
|
*/
|
||||||
@@ -49,11 +49,11 @@ const layer = Layer.effect(
|
|||||||
const withTargetLock =
|
const withTargetLock =
|
||||||
(target: Target) =>
|
(target: Target) =>
|
||||||
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||||
locks.withLock(target.canonical)(Effect.uninterruptible(effect))
|
locks.withLock(target.absolute)(Effect.uninterruptible(effect))
|
||||||
|
|
||||||
const writeResult = (target: Target, existed: boolean): WriteResult => ({
|
const writeResult = (target: Target, existed: boolean): WriteResult => ({
|
||||||
operation: "write",
|
operation: "write",
|
||||||
target: target.canonical,
|
target: target.absolute,
|
||||||
resource: target.resource,
|
resource: target.resource,
|
||||||
existed,
|
existed,
|
||||||
})
|
})
|
||||||
@@ -61,8 +61,8 @@ const layer = Layer.effect(
|
|||||||
const write = Effect.fn("FileMutation.write")((input: WriteInput) =>
|
const write = Effect.fn("FileMutation.write")((input: WriteInput) =>
|
||||||
withTargetLock(input.target)(
|
withTargetLock(input.target)(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const existed = yield* fs.exists(input.target.canonical)
|
const existed = yield* fs.exists(input.target.absolute)
|
||||||
yield* fs.writeWithDirs(input.target.canonical, input.content)
|
yield* fs.writeWithDirs(input.target.absolute, input.content)
|
||||||
return writeResult(input.target, existed)
|
return writeResult(input.target, existed)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -73,10 +73,10 @@ const layer = Layer.effect(
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const next = Bom.split(input.content)
|
const next = Bom.split(input.content)
|
||||||
const current = yield* fs
|
const current = yield* fs
|
||||||
.readFile(input.target.canonical)
|
.readFile(input.target.absolute)
|
||||||
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
|
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
|
||||||
yield* fs.writeWithDirs(
|
yield* fs.writeWithDirs(
|
||||||
input.target.canonical,
|
input.target.absolute,
|
||||||
Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom),
|
Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom),
|
||||||
)
|
)
|
||||||
return writeResult(input.target, current !== undefined)
|
return writeResult(input.target, current !== undefined)
|
||||||
|
|||||||
@@ -23,14 +23,9 @@ export const ResolveInput = Schema.Struct({
|
|||||||
})
|
})
|
||||||
export type ResolveInput = typeof ResolveInput.Type
|
export type ResolveInput = typeof ResolveInput.Type
|
||||||
|
|
||||||
export class PathError extends Schema.TaggedErrorClass<PathError>()("LocationMutation.PathError", {
|
|
||||||
path: Schema.String,
|
|
||||||
reason: Schema.Literal("non_directory_ancestor"),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export interface ExternalDirectoryAuthorization {
|
export interface ExternalDirectoryAuthorization {
|
||||||
readonly action: "external_directory"
|
readonly action: "external_directory"
|
||||||
/** Canonical existing directory used as the external approval boundary. */
|
/** Lexical directory used as the external approval boundary. */
|
||||||
readonly directory: string
|
readonly directory: string
|
||||||
/** `external_directory` permission resource. */
|
/** `external_directory` permission resource. */
|
||||||
readonly resource: string
|
readonly resource: string
|
||||||
@@ -44,9 +39,9 @@ export const externalDirectoryPermission = (input: ExternalDirectoryAuthorizatio
|
|||||||
})
|
})
|
||||||
|
|
||||||
export interface Target {
|
export interface Target {
|
||||||
/** Canonical existing path, or missing path below a canonical directory. */
|
/** Absolute lexical path. */
|
||||||
readonly canonical: string
|
readonly absolute: string
|
||||||
/** Permission resource: Location-relative for internal paths, canonical for external paths. */
|
/** Permission resource: Location-relative for internal paths, absolute for external paths. */
|
||||||
readonly resource: string
|
readonly resource: string
|
||||||
readonly externalDirectory?: ExternalDirectoryAuthorization
|
readonly externalDirectory?: ExternalDirectoryAuthorization
|
||||||
}
|
}
|
||||||
@@ -57,25 +52,11 @@ export interface Interface {
|
|||||||
* from the Location. Paths outside it require separate `external_directory`
|
* from the Location. Paths outside it require separate `external_directory`
|
||||||
* approval. This does not approve the mutation.
|
* approval. This does not approve the mutation.
|
||||||
*/
|
*/
|
||||||
readonly resolve: (input: ResolveInput) => Effect.Effect<Target, PathError | FSUtil.Error>
|
readonly resolve: (input: ResolveInput) => Effect.Effect<Target, FSUtil.Error>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LocationMutation") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/LocationMutation") {}
|
||||||
|
|
||||||
interface ResolvedPath {
|
|
||||||
readonly canonical: string
|
|
||||||
readonly type?:
|
|
||||||
| "File"
|
|
||||||
| "Directory"
|
|
||||||
| "SymbolicLink"
|
|
||||||
| "BlockDevice"
|
|
||||||
| "CharacterDevice"
|
|
||||||
| "FIFO"
|
|
||||||
| "Socket"
|
|
||||||
| "Unknown"
|
|
||||||
readonly directory: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const slash = (value: string) => value.replaceAll("\\", "/")
|
const slash = (value: string) => value.replaceAll("\\", "/")
|
||||||
|
|
||||||
const layer = Layer.effect(
|
const layer = Layer.effect(
|
||||||
@@ -84,65 +65,33 @@ const layer = Layer.effect(
|
|||||||
const fs = yield* FSUtil.Service
|
const fs = yield* FSUtil.Service
|
||||||
const location = yield* Location.Service
|
const location = yield* Location.Service
|
||||||
|
|
||||||
function notFound<A>(effect: Effect.Effect<A, FSUtil.Error>) {
|
|
||||||
return effect.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvePath = Effect.fnUntraced(function* (absolute: string) {
|
|
||||||
const existing = yield* notFound(fs.realPath(absolute))
|
|
||||||
if (existing !== undefined) {
|
|
||||||
const info = yield* fs.stat(existing)
|
|
||||||
return {
|
|
||||||
canonical: existing,
|
|
||||||
type: info.type,
|
|
||||||
directory: info.type === "Directory" ? existing : path.dirname(existing),
|
|
||||||
} satisfies ResolvedPath
|
|
||||||
}
|
|
||||||
|
|
||||||
let anchor = path.dirname(absolute)
|
|
||||||
while (true) {
|
|
||||||
const canonical = yield* notFound(fs.realPath(anchor))
|
|
||||||
if (canonical !== undefined) {
|
|
||||||
const info = yield* fs.stat(canonical)
|
|
||||||
if (info.type !== "Directory") {
|
|
||||||
return yield* new PathError({ path: absolute, reason: "non_directory_ancestor" })
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
canonical: path.resolve(canonical, path.relative(anchor, absolute)),
|
|
||||||
directory: canonical,
|
|
||||||
} satisfies ResolvedPath
|
|
||||||
}
|
|
||||||
const parent = path.dirname(anchor)
|
|
||||||
if (parent === anchor) return yield* new PathError({ path: absolute, reason: "non_directory_ancestor" })
|
|
||||||
anchor = parent
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) {
|
const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) {
|
||||||
const absolute = path.resolve(location.directory, input.path)
|
const absolute = path.resolve(location.directory, input.path)
|
||||||
// External access follows the requested path boundary. Symlinks reached through an
|
if (FSUtil.contains(location.directory, absolute)) {
|
||||||
// internal path intentionally retain internal permission semantics after canonicalization.
|
return {
|
||||||
const lexicallyInternal = FSUtil.contains(location.directory, absolute)
|
absolute,
|
||||||
|
resource: slash(path.relative(location.directory, absolute) || "."),
|
||||||
const resolved = yield* resolvePath(absolute)
|
} satisfies Target
|
||||||
const external = !lexicallyInternal
|
}
|
||||||
const resource = external ? slash(resolved.canonical) : slash(path.relative(location.directory, absolute) || ".")
|
const type =
|
||||||
const externalDirectory =
|
input.kind === "directory"
|
||||||
input.kind === "directory" && resolved.type === "Directory" ? resolved.canonical : resolved.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, "*"))
|
const externalResource = slash(path.join(externalDirectory, "*"))
|
||||||
return {
|
return {
|
||||||
canonical: resolved.canonical,
|
absolute,
|
||||||
resource,
|
resource: slash(absolute),
|
||||||
externalDirectory: external
|
externalDirectory: {
|
||||||
? {
|
|
||||||
action: "external_directory",
|
action: "external_directory",
|
||||||
directory: externalDirectory,
|
directory: externalDirectory,
|
||||||
resource: externalResource,
|
resource: externalResource,
|
||||||
save: slash(
|
save: slash(
|
||||||
path.join((yield* Project.root(fs, AbsolutePath.make(externalDirectory))) ?? externalDirectory, "*"),
|
path.join((yield* Project.root(fs, AbsolutePath.make(externalDirectory))) ?? externalDirectory, "*"),
|
||||||
),
|
),
|
||||||
}
|
},
|
||||||
: undefined,
|
|
||||||
} satisfies Target
|
} satisfies Target
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import { SessionGenerateNode } from "./session/generate-node"
|
|||||||
import { McpTool } from "./tool/mcp"
|
import { McpTool } from "./tool/mcp"
|
||||||
import { ReadToolFileSystem } from "./tool/read-filesystem"
|
import { ReadToolFileSystem } from "./tool/read-filesystem"
|
||||||
import { Tool } from "./tool"
|
import { Tool } from "./tool"
|
||||||
|
import { ToolOutput } from "./tool-output"
|
||||||
import { Vcs } from "./vcs"
|
import { Vcs } from "./vcs"
|
||||||
|
|
||||||
export { LocationServiceMap } from "./location-service-map"
|
export { LocationServiceMap } from "./location-service-map"
|
||||||
@@ -78,6 +79,7 @@ const locationServiceNodes = [
|
|||||||
MCP.node,
|
MCP.node,
|
||||||
Permission.node,
|
Permission.node,
|
||||||
Tool.node,
|
Tool.node,
|
||||||
|
ToolOutput.node,
|
||||||
Image.node,
|
Image.node,
|
||||||
SkillInstructions.node,
|
SkillInstructions.node,
|
||||||
ReferenceInstructions.node,
|
ReferenceInstructions.node,
|
||||||
|
|||||||
@@ -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 { WebSearchExa } from "./exa"
|
||||||
|
import { WebSearchFirecrawl } from "./firecrawl"
|
||||||
import { WebSearchParallel } from "./parallel"
|
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", {
|
export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.BusyError", {
|
||||||
sessionID: SessionSchema.ID,
|
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", {
|
export class SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundError>()("Session.SkillNotFoundError", {
|
||||||
skill: Skill.ID,
|
skill: Skill.ID,
|
||||||
}) {}
|
}) {}
|
||||||
@@ -181,6 +189,9 @@ export interface Interface {
|
|||||||
* unhandled compaction barriers.
|
* unhandled compaction barriers.
|
||||||
*/
|
*/
|
||||||
readonly pending: (sessionID: SessionSchema.ID) => Effect.Effect<SessionPending.Info[], NotFoundError>
|
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
|
* Durable, ordered session log read. Replays durable session bus after
|
||||||
* the exclusive `after` cursor, emits a `Synced` marker at the captured
|
* the exclusive `after` cursor, emits a `Synced` marker at the captured
|
||||||
@@ -318,6 +329,31 @@ const layer = Layer.effect(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const pendingConflict = Effect.fn("Session.pendingConflict")(function* (input: PendingInputRef) {
|
||||||
|
yield* result.get(input.sessionID)
|
||||||
|
return yield* new PendingInputConflictError(input)
|
||||||
|
})
|
||||||
|
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
|
||||||
|
? pendingConflict(input)
|
||||||
|
: Effect.die(defect),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (wake) yield* execution.wake(input.sessionID)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
const result = Service.of({
|
const result = Service.of({
|
||||||
create: Effect.fn("Session.create")(function* (input) {
|
create: Effect.fn("Session.create")(function* (input) {
|
||||||
const sessionID = input.id ?? SessionSchema.ID.create()
|
const sessionID = input.id ?? SessionSchema.ID.create()
|
||||||
@@ -507,6 +543,9 @@ const layer = Layer.effect(
|
|||||||
yield* result.get(sessionID)
|
yield* result.get(sessionID)
|
||||||
return yield* SessionPending.list(db, 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) =>
|
log: (input) =>
|
||||||
Stream.unwrap(
|
Stream.unwrap(
|
||||||
result
|
result
|
||||||
|
|||||||
@@ -90,6 +90,9 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
|||||||
"session.forked": () => Effect.void,
|
"session.forked": () => Effect.void,
|
||||||
"session.input.promoted": () => Effect.void,
|
"session.input.promoted": () => Effect.void,
|
||||||
"session.input.admitted": () => 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.started": () => Effect.void,
|
||||||
"session.execution.succeeded": () => clearCurrentRetry,
|
"session.execution.succeeded": () => clearCurrentRetry,
|
||||||
"session.execution.failed": () => clearCurrentRetry,
|
"session.execution.failed": () => clearCurrentRetry,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const decodeSynthetic = Schema.decodeUnknownSync(SyntheticData)
|
|||||||
const encodeSynthetic = Schema.encodeSync(SyntheticData)
|
const encodeSynthetic = Schema.encodeSync(SyntheticData)
|
||||||
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
|
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
|
||||||
const inboxLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
|
const inboxLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
|
||||||
|
type PendingRef = { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID }
|
||||||
|
|
||||||
export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()(
|
export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()(
|
||||||
"SessionPending.LifecycleConflict",
|
"SessionPending.LifecycleConflict",
|
||||||
@@ -294,10 +295,7 @@ export const projectCompactionAdmitted = Effect.fn("SessionPending.projectCompac
|
|||||||
*/
|
*/
|
||||||
export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(function* (
|
export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
input: {
|
input: PendingRef,
|
||||||
readonly id: SessionMessage.ID
|
|
||||||
readonly sessionID: SessionSchema.ID
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
if (yield* compaction(db, input.sessionID)) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
if (yield* compaction(db, input.sessionID)) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||||
const deleted = yield* db
|
const deleted = yield* db
|
||||||
@@ -312,6 +310,55 @@ export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(funct
|
|||||||
return stored
|
return stored
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const projectCancelled = Effect.fn("SessionPending.projectCancelled")(function* (
|
||||||
|
db: DatabaseService,
|
||||||
|
input: PendingRef,
|
||||||
|
) {
|
||||||
|
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: PendingRef & { 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: PendingRef) =>
|
||||||
|
projectDelivery(db, { ...input, from: "queue", to: "steer" }),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const projectQueued = Effect.fn("SessionPending.projectQueued")(
|
||||||
|
(db: DatabaseService, input: PendingRef) =>
|
||||||
|
projectDelivery(db, { ...input, from: "steer", to: "queue" }),
|
||||||
|
)
|
||||||
|
|
||||||
export const settleCompaction = Effect.fn("SessionPending.settleCompaction")(function* (
|
export const settleCompaction = Effect.fn("SessionPending.settleCompaction")(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
input: { readonly sessionID: SessionSchema.ID },
|
input: { readonly sessionID: SessionSchema.ID },
|
||||||
@@ -389,6 +436,39 @@ export const equivalent = (
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const publishMutation = <A, E, R>(input: PendingRef, effect: Effect.Effect<A, E, R>) =>
|
||||||
|
inboxLocks.withLock(input.sessionID)(effect).pipe(Effect.asVoid)
|
||||||
|
|
||||||
|
export const cancel = Effect.fn("SessionPending.cancel")((bus: Bus.Interface, input: PendingRef) =>
|
||||||
|
publishMutation(
|
||||||
|
input,
|
||||||
|
bus.publish(SessionEvent.InputCancelled, {
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
inputID: input.id,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const steer = Effect.fn("SessionPending.steer")((bus: Bus.Interface, input: PendingRef) =>
|
||||||
|
publishMutation(
|
||||||
|
input,
|
||||||
|
bus.publish(SessionEvent.InputSteered, {
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
inputID: input.id,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const queue = Effect.fn("SessionPending.queue")((bus: Bus.Interface, input: PendingRef) =>
|
||||||
|
publishMutation(
|
||||||
|
input,
|
||||||
|
bus.publish(SessionEvent.InputQueued, {
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
inputID: input.id,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
const publish = Effect.fn("SessionPending.publish")(function* (
|
const publish = Effect.fn("SessionPending.publish")(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
bus: Bus.Interface,
|
bus: Bus.Interface,
|
||||||
|
|||||||
@@ -485,6 +485,24 @@ const layer = Layer.effectDiscard(
|
|||||||
.pipe(Effect.orDie)
|
.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) =>
|
yield* bus.project(SessionEvent.Compaction.Admitted, (event) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
if (event.durable === undefined)
|
if (event.durable === undefined)
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { StepFailedError } from "../error"
|
|||||||
import { toSessionError } from "../to-session-error"
|
import { toSessionError } from "../to-session-error"
|
||||||
import { SessionRunnerRetry } from "./retry"
|
import { SessionRunnerRetry } from "./retry"
|
||||||
import { SessionUsage } from "../usage"
|
import { SessionUsage } from "../usage"
|
||||||
|
import { ToolOutput } from "../../tool-output"
|
||||||
|
|
||||||
/** How one model call ended: settled, awaiting a scheduled retry, or restarted by compaction. */
|
/** How one model call ended: settled, awaiting a scheduled retry, or restarted by compaction. */
|
||||||
type CallOutcome = Data.TaggedEnum<{
|
type CallOutcome = Data.TaggedEnum<{
|
||||||
@@ -107,6 +108,7 @@ const layer = Layer.effect(
|
|||||||
const db = (yield* Database.Service).db
|
const db = (yield* Database.Service).db
|
||||||
const compaction = yield* SessionCompaction.Service
|
const compaction = yield* SessionCompaction.Service
|
||||||
const title = yield* SessionTitle.Service
|
const title = yield* SessionTitle.Service
|
||||||
|
const toolOutput = yield* ToolOutput.Service
|
||||||
// Title generation is a side effect of a successful step; it must not delay continuation.
|
// Title generation is a side effect of a successful step; it must not delay continuation.
|
||||||
// The in-flight set coalesces overlapping steps while title presence records success durably.
|
// The in-flight set coalesces overlapping steps while title presence records success durably.
|
||||||
const titlesRunning = new Set<SessionSchema.ID>()
|
const titlesRunning = new Set<SessionSchema.ID>()
|
||||||
@@ -334,6 +336,7 @@ const layer = Layer.effect(
|
|||||||
).pipe(
|
).pipe(
|
||||||
// The fiber owns its call: it publishes its own completion, masked so a
|
// The fiber owns its call: it publishes its own completion, masked so a
|
||||||
// finished execution always reaches its durable settlement.
|
// finished execution always reaches its durable settlement.
|
||||||
|
Effect.flatMap(toolOutput.truncate),
|
||||||
Effect.flatMap((outcome) => publisher.toolExecution(event.id, event.name, outcome)),
|
Effect.flatMap((outcome) => publisher.toolExecution(event.id, event.name, outcome)),
|
||||||
Effect.catchTag("Tool.Error", (error) =>
|
Effect.catchTag("Tool.Error", (error) =>
|
||||||
publisher.failTool(event.id, toSessionError(error)).pipe(Effect.asVoid),
|
publisher.failTool(event.id, toSessionError(error)).pipe(Effect.asVoid),
|
||||||
@@ -562,6 +565,7 @@ export const node = makeLocationNode({
|
|||||||
SessionCompaction.node,
|
SessionCompaction.node,
|
||||||
SessionTitle.node,
|
SessionTitle.node,
|
||||||
Snapshot.node,
|
Snapshot.node,
|
||||||
|
ToolOutput.node,
|
||||||
Database.node,
|
Database.node,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,8 +116,7 @@ export const Plugin = {
|
|||||||
|
|
||||||
yield* ctx.tool
|
yield* ctx.tool
|
||||||
.transform((draft) =>
|
.transform((draft) =>
|
||||||
draft.add(
|
draft.add({
|
||||||
({
|
|
||||||
name,
|
name,
|
||||||
options: { codemode: false, permission: "edit" },
|
options: { codemode: false, permission: "edit" },
|
||||||
description:
|
description:
|
||||||
@@ -153,7 +152,9 @@ export const Plugin = {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const info = yield* fs.stat(target.canonical).pipe(
|
const info = yield* fs
|
||||||
|
.stat(target.absolute)
|
||||||
|
.pipe(
|
||||||
Effect.catchReason("PlatformError", "NotFound", () =>
|
Effect.catchReason("PlatformError", "NotFound", () =>
|
||||||
Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })),
|
Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })),
|
||||||
),
|
),
|
||||||
@@ -161,7 +162,7 @@ export const Plugin = {
|
|||||||
if (info.type === "Directory") {
|
if (info.type === "Directory") {
|
||||||
return yield* new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` })
|
return yield* new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` })
|
||||||
}
|
}
|
||||||
const original = yield* Bom.readFile(fs, target.canonical)
|
const original = yield* Bom.readFile(fs, target.absolute)
|
||||||
const source = original.text
|
const source = original.text
|
||||||
const ending = source.includes(crlf) ? crlf : "\n"
|
const ending = source.includes(crlf) ? crlf : "\n"
|
||||||
const oldString = input.oldString.replaceAll(crlf, "\n").replaceAll("\n", ending)
|
const oldString = input.oldString.replaceAll(crlf, "\n").replaceAll("\n", ending)
|
||||||
@@ -170,17 +171,13 @@ export const Plugin = {
|
|||||||
// These one-to-one mappings preserve offsets into the original source.
|
// These one-to-one mappings preserve offsets into the original source.
|
||||||
const unicode =
|
const unicode =
|
||||||
exact.length > 0 ? [] : findOccurrences(normalizeForMatch(source), normalizeForMatch(oldString))
|
exact.length > 0 ? [] : findOccurrences(normalizeForMatch(source), normalizeForMatch(oldString))
|
||||||
const trailing =
|
const trailing = exact.length > 0 || unicode.length > 0 ? [] : findLineOccurrences(source, oldString)
|
||||||
exact.length > 0 || unicode.length > 0
|
|
||||||
? []
|
|
||||||
: findLineOccurrences(source, oldString)
|
|
||||||
const matches = exact.length > 0 ? exact : unicode.length > 0 ? unicode : trailing
|
const matches = exact.length > 0 ? exact : unicode.length > 0 ? unicode : trailing
|
||||||
const replacements = matches.length
|
const replacements = matches.length
|
||||||
const replaced = (input.replaceAll === true ? matches : matches.slice(0, 1))
|
const replaced = (input.replaceAll === true ? matches : matches.slice(0, 1))
|
||||||
.toReversed()
|
.toReversed()
|
||||||
.reduce(
|
.reduce(
|
||||||
(content, match) =>
|
(content, match) => `${content.slice(0, match.start)}${newString}${content.slice(match.end)}`,
|
||||||
`${content.slice(0, match.start)}${newString}${content.slice(match.end)}`,
|
|
||||||
source,
|
source,
|
||||||
)
|
)
|
||||||
const preview =
|
const preview =
|
||||||
@@ -212,9 +209,9 @@ export const Plugin = {
|
|||||||
content: Bom.join(replaced, original.bom || replacementBom),
|
content: Bom.join(replaced, original.bom || replacementBom),
|
||||||
})
|
})
|
||||||
const bom = original.bom || replacementBom
|
const bom = original.bom || replacementBom
|
||||||
const formatted = (yield* formatter.file(target.canonical))
|
const formatted = (yield* formatter.file(target.absolute))
|
||||||
? yield* Bom.syncFile(fs, target.canonical, bom)
|
? yield* Bom.syncFile(fs, target.absolute, bom)
|
||||||
: (yield* Bom.readFile(fs, target.canonical)).text
|
: (yield* Bom.readFile(fs, target.absolute)).text
|
||||||
return {
|
return {
|
||||||
files: [fileDiff(result.resource, source, formatted)],
|
files: [fileDiff(result.resource, source, formatted)],
|
||||||
replacements,
|
replacements,
|
||||||
@@ -233,7 +230,6 @@ export const Plugin = {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -50,12 +50,10 @@ export const Plugin = {
|
|||||||
|
|
||||||
yield* ctx.tool
|
yield* ctx.tool
|
||||||
.transform((draft) =>
|
.transform((draft) =>
|
||||||
draft.add(
|
draft.add({
|
||||||
({
|
|
||||||
name,
|
name,
|
||||||
options: { codemode: false },
|
options: { codemode: false },
|
||||||
description:
|
description: 'Search file paths using a glob pattern (examples: "**/*.ts", "src/**/*.tsx").',
|
||||||
'Search file paths using a glob pattern (examples: "**/*.ts", "src/**/*.tsx").',
|
|
||||||
input: Input,
|
input: Input,
|
||||||
output: Output,
|
output: Output,
|
||||||
execute: (input, context) =>
|
execute: (input, context) =>
|
||||||
@@ -85,7 +83,7 @@ export const Plugin = {
|
|||||||
source,
|
source,
|
||||||
})
|
})
|
||||||
const info = yield* fs
|
const info = yield* fs
|
||||||
.stat(target.canonical)
|
.stat(target.absolute)
|
||||||
.pipe(
|
.pipe(
|
||||||
Effect.catchReason("PlatformError", "NotFound", () =>
|
Effect.catchReason("PlatformError", "NotFound", () =>
|
||||||
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${searchPath ?? "."}` })),
|
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${searchPath ?? "."}` })),
|
||||||
@@ -99,7 +97,7 @@ export const Plugin = {
|
|||||||
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
|
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
|
||||||
const entries = yield* ripgrep
|
const entries = yield* ripgrep
|
||||||
.glob({
|
.glob({
|
||||||
cwd: target.canonical,
|
cwd: target.absolute,
|
||||||
pattern: input.pattern,
|
pattern: input.pattern,
|
||||||
limit: limit + 1,
|
limit: limit + 1,
|
||||||
})
|
})
|
||||||
@@ -139,7 +137,6 @@ export const Plugin = {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ type Prepared =
|
|||||||
})
|
})
|
||||||
|
|
||||||
interface Target {
|
interface Target {
|
||||||
readonly canonical: string
|
readonly absolute: string
|
||||||
readonly resource: string
|
readonly resource: string
|
||||||
readonly externalDirectory?: {
|
readonly externalDirectory?: {
|
||||||
readonly directory: string
|
readonly directory: string
|
||||||
@@ -76,8 +76,7 @@ export const Plugin = {
|
|||||||
|
|
||||||
yield* ctx.tool
|
yield* ctx.tool
|
||||||
.transform((draft) =>
|
.transform((draft) =>
|
||||||
draft.add(
|
draft.add({
|
||||||
({
|
|
||||||
name,
|
name,
|
||||||
options: { codemode: false, permission: "edit" },
|
options: { codemode: false, permission: "edit" },
|
||||||
description: DESCRIPTION,
|
description: DESCRIPTION,
|
||||||
@@ -99,9 +98,7 @@ export const Plugin = {
|
|||||||
}
|
}
|
||||||
if (!input.patchText) return yield* new ToolFailure({ message: "patchText is required" })
|
if (!input.patchText) return yield* new ToolFailure({ message: "patchText is required" })
|
||||||
const hunks = yield* Effect.fromResult(Patch.parse(input.patchText)).pipe(
|
const hunks = yield* Effect.fromResult(Patch.parse(input.patchText)).pipe(
|
||||||
Effect.mapError(
|
Effect.mapError((error) => new ToolFailure({ message: `patch verification failed: ${error.message}` })),
|
||||||
(error) => new ToolFailure({ message: `patch verification failed: ${error.message}` }),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
if (hunks.length === 0) {
|
if (hunks.length === 0) {
|
||||||
return yield* new ToolFailure({ message: "patch rejected: empty patch" })
|
return yield* new ToolFailure({ message: "patch rejected: empty patch" })
|
||||||
@@ -119,7 +116,7 @@ export const Plugin = {
|
|||||||
resources: [target.externalDirectory.resource],
|
resources: [target.externalDirectory.resource],
|
||||||
save: [target.externalDirectory.resource],
|
save: [target.externalDirectory.resource],
|
||||||
metadata: {
|
metadata: {
|
||||||
filepath: target.canonical,
|
filepath: target.absolute,
|
||||||
parentDir: target.externalDirectory.directory,
|
parentDir: target.externalDirectory.directory,
|
||||||
},
|
},
|
||||||
sessionID: context.sessionID,
|
sessionID: context.sessionID,
|
||||||
@@ -133,15 +130,13 @@ export const Plugin = {
|
|||||||
target,
|
target,
|
||||||
before: "",
|
before: "",
|
||||||
after: Bom.split(
|
after: Bom.split(
|
||||||
hunk.contents.endsWith("\n") || hunk.contents === ""
|
hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`,
|
||||||
? hunk.contents
|
|
||||||
: `${hunk.contents}\n`,
|
|
||||||
).text,
|
).text,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (hunk.type === "delete") {
|
if (hunk.type === "delete") {
|
||||||
const content = yield* Bom.readFile(fs, target.canonical).pipe(
|
const content = yield* Bom.readFile(fs, target.absolute).pipe(
|
||||||
Effect.mapError(
|
Effect.mapError(
|
||||||
(error) =>
|
(error) =>
|
||||||
new ToolFailure({
|
new ToolFailure({
|
||||||
@@ -152,28 +147,28 @@ export const Plugin = {
|
|||||||
prepared.push({ ...hunk, target, before: content.text, after: "" })
|
prepared.push({ ...hunk, target, before: content.text, after: "" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const previous = updates.get(target.canonical)
|
const previous = updates.get(target.absolute)
|
||||||
const original =
|
const original =
|
||||||
previous ??
|
previous ??
|
||||||
(yield* Effect.gen(function* () {
|
(yield* Effect.gen(function* () {
|
||||||
const stats = yield* fs.stat(target.canonical).pipe(
|
const stats = yield* fs.stat(target.absolute).pipe(
|
||||||
Effect.mapError(
|
Effect.mapError(
|
||||||
(error) =>
|
(error) =>
|
||||||
new ToolFailure({
|
new ToolFailure({
|
||||||
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${errorMessage(error)}`,
|
message: `patch verification failed: Failed to read file to update ${target.absolute}: ${errorMessage(error)}`,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (stats.type === "Directory") {
|
if (stats.type === "Directory") {
|
||||||
return yield* new ToolFailure({
|
return yield* new ToolFailure({
|
||||||
message: `patch verification failed: Failed to read file to update ${target.canonical}: path is a directory`,
|
message: `patch verification failed: Failed to read file to update ${target.absolute}: path is a directory`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const content = yield* Bom.readFile(fs, target.canonical).pipe(
|
const content = yield* Bom.readFile(fs, target.absolute).pipe(
|
||||||
Effect.mapError(
|
Effect.mapError(
|
||||||
(error) =>
|
(error) =>
|
||||||
new ToolFailure({
|
new ToolFailure({
|
||||||
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${errorMessage(error)}`,
|
message: `patch verification failed: Failed to read file to update ${target.absolute}: ${errorMessage(error)}`,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -182,8 +177,7 @@ export const Plugin = {
|
|||||||
const before = Bom.split(original).text
|
const before = Bom.split(original).text
|
||||||
const update = yield* Effect.try({
|
const update = yield* Effect.try({
|
||||||
try: () => Patch.derive(hunk.path, hunk.chunks, original),
|
try: () => Patch.derive(hunk.path, hunk.chunks, original),
|
||||||
catch: (error) =>
|
catch: (error) => new ToolFailure({ message: `patch verification failed: ${errorMessage(error)}` }),
|
||||||
new ToolFailure({ message: `patch verification failed: ${errorMessage(error)}` }),
|
|
||||||
})
|
})
|
||||||
const moveTarget = hunk.movePath ? resolveTarget(location, hunk.movePath) : undefined
|
const moveTarget = hunk.movePath ? resolveTarget(location, hunk.movePath) : undefined
|
||||||
if (moveTarget) targets.push(moveTarget)
|
if (moveTarget) targets.push(moveTarget)
|
||||||
@@ -193,7 +187,7 @@ export const Plugin = {
|
|||||||
resources: [moveTarget.externalDirectory.resource],
|
resources: [moveTarget.externalDirectory.resource],
|
||||||
save: [moveTarget.externalDirectory.resource],
|
save: [moveTarget.externalDirectory.resource],
|
||||||
metadata: {
|
metadata: {
|
||||||
filepath: moveTarget.canonical,
|
filepath: moveTarget.absolute,
|
||||||
parentDir: moveTarget.externalDirectory.directory,
|
parentDir: moveTarget.externalDirectory.directory,
|
||||||
},
|
},
|
||||||
sessionID: context.sessionID,
|
sessionID: context.sessionID,
|
||||||
@@ -209,7 +203,7 @@ export const Plugin = {
|
|||||||
after: update.content,
|
after: update.content,
|
||||||
moveTarget,
|
moveTarget,
|
||||||
})
|
})
|
||||||
if (!moveTarget) updates.set(target.canonical, Patch.joinBom(update.content, update.bom))
|
if (!moveTarget) updates.set(target.absolute, Patch.joinBom(update.content, update.bom))
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.mapError((error) =>
|
Effect.mapError((error) =>
|
||||||
error instanceof ToolFailure
|
error instanceof ToolFailure
|
||||||
@@ -241,40 +235,38 @@ export const Plugin = {
|
|||||||
if (change.type === "add") {
|
if (change.type === "add") {
|
||||||
yield* fs
|
yield* fs
|
||||||
.writeWithDirs(
|
.writeWithDirs(
|
||||||
change.target.canonical,
|
change.target.absolute,
|
||||||
change.contents.endsWith("\n") || change.contents === ""
|
change.contents.endsWith("\n") || change.contents === ""
|
||||||
? change.contents
|
? change.contents
|
||||||
: `${change.contents}\n`,
|
: `${change.contents}\n`,
|
||||||
)
|
)
|
||||||
.pipe(
|
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
|
||||||
Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)),
|
|
||||||
)
|
|
||||||
applied.push({
|
applied.push({
|
||||||
type: change.type,
|
type: change.type,
|
||||||
resource: change.target.resource,
|
resource: change.target.resource,
|
||||||
target: change.target.canonical,
|
target: change.target.absolute,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (change.type === "delete") {
|
if (change.type === "delete") {
|
||||||
yield* fs
|
yield* fs
|
||||||
.remove(change.target.canonical)
|
.remove(change.target.absolute)
|
||||||
.pipe(
|
.pipe(Effect.mapError((error) => fail(`Failed to delete ${change.target.resource}`, error)))
|
||||||
Effect.mapError((error) => fail(`Failed to delete ${change.target.resource}`, error)),
|
|
||||||
)
|
|
||||||
applied.push({
|
applied.push({
|
||||||
type: change.type,
|
type: change.type,
|
||||||
resource: change.target.resource,
|
resource: change.target.resource,
|
||||||
target: change.target.canonical,
|
target: change.target.absolute,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (change.moveTarget) {
|
if (change.moveTarget) {
|
||||||
const moveTarget = change.moveTarget
|
const moveTarget = change.moveTarget
|
||||||
yield* fs
|
yield* fs
|
||||||
.writeWithDirs(moveTarget.canonical, change.content)
|
.writeWithDirs(moveTarget.absolute, change.content)
|
||||||
.pipe(Effect.mapError((error) => fail(`Failed to write ${moveTarget.resource}`, error)))
|
.pipe(Effect.mapError((error) => fail(`Failed to write ${moveTarget.resource}`, error)))
|
||||||
yield* fs.remove(change.target.canonical).pipe(
|
yield* fs
|
||||||
|
.remove(change.target.absolute)
|
||||||
|
.pipe(
|
||||||
Effect.mapError((error) =>
|
Effect.mapError((error) =>
|
||||||
fail(`Wrote ${moveTarget.resource} but failed to remove ${change.target.resource}`, error),
|
fail(`Wrote ${moveTarget.resource} but failed to remove ${change.target.resource}`, error),
|
||||||
),
|
),
|
||||||
@@ -282,17 +274,17 @@ export const Plugin = {
|
|||||||
applied.push({
|
applied.push({
|
||||||
type: change.type,
|
type: change.type,
|
||||||
resource: change.moveTarget.resource,
|
resource: change.moveTarget.resource,
|
||||||
target: change.moveTarget.canonical,
|
target: change.moveTarget.absolute,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
yield* fs
|
yield* fs
|
||||||
.writeWithDirs(change.target.canonical, change.content)
|
.writeWithDirs(change.target.absolute, change.content)
|
||||||
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
|
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
|
||||||
applied.push({
|
applied.push({
|
||||||
type: change.type,
|
type: change.type,
|
||||||
resource: change.target.resource,
|
resource: change.target.resource,
|
||||||
target: change.target.canonical,
|
target: change.target.absolute,
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
{ discard: true },
|
{ discard: true },
|
||||||
@@ -319,7 +311,7 @@ export const Plugin = {
|
|||||||
const files = yield* Effect.forEach(prepared, (change) => {
|
const files = yield* Effect.forEach(prepared, (change) => {
|
||||||
if (change.type === "delete") return Effect.succeed(patchFile(change))
|
if (change.type === "delete") return Effect.succeed(patchFile(change))
|
||||||
const target = change.type === "update" && change.moveTarget ? change.moveTarget : change.target
|
const target = change.type === "update" && change.moveTarget ? change.moveTarget : change.target
|
||||||
return Effect.succeed(patchFile(change, formatted.get(target.canonical)))
|
return Effect.succeed(patchFile(change, formatted.get(target.absolute)))
|
||||||
})
|
})
|
||||||
return { applied, files }
|
return { applied, files }
|
||||||
}).pipe(
|
}).pipe(
|
||||||
@@ -329,14 +321,11 @@ export const Plugin = {
|
|||||||
metadata: { files: output.files },
|
metadata: { files: output.files },
|
||||||
})),
|
})),
|
||||||
Effect.mapError((error) =>
|
Effect.mapError((error) =>
|
||||||
error instanceof ToolFailure
|
error instanceof ToolFailure ? error : new ToolFailure({ message: "Unable to apply patch", error }),
|
||||||
? error
|
|
||||||
: new ToolFailure({ message: "Unable to apply patch", error }),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
|
|
||||||
@@ -365,9 +354,7 @@ function errorMessage(error: unknown) {
|
|||||||
|
|
||||||
function patchFile(change: Prepared, after = change.after): typeof FileDiff.Info.Type {
|
function patchFile(change: Prepared, after = change.after): typeof FileDiff.Info.Type {
|
||||||
const target = (change.type === "update" ? change.moveTarget : undefined)?.resource ?? change.target.resource
|
const target = (change.type === "update" ? change.moveTarget : undefined)?.resource ?? change.target.resource
|
||||||
const patch = trimDiff(
|
const patch = trimDiff(createTwoFilesPatch(change.target.absolute, change.target.absolute, change.before, after))
|
||||||
createTwoFilesPatch(change.target.canonical, change.target.canonical, change.before, after),
|
|
||||||
)
|
|
||||||
const counts =
|
const counts =
|
||||||
change.type === "delete"
|
change.type === "delete"
|
||||||
? { additions: 0, deletions: change.before.split("\n").length }
|
? { additions: 0, deletions: change.before.split("\n").length }
|
||||||
@@ -416,22 +403,22 @@ function trimDiff(diff: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveTarget(location: Location.Interface, value: string): Target {
|
function resolveTarget(location: Location.Interface, value: string): Target {
|
||||||
const canonical =
|
const absolute =
|
||||||
process.platform === "win32"
|
process.platform === "win32"
|
||||||
? FSUtil.normalizePath(path.resolve(location.directory, value))
|
? FSUtil.normalizePath(path.resolve(location.directory, value))
|
||||||
: path.resolve(location.directory, value)
|
: path.resolve(location.directory, value)
|
||||||
const projectRoot = path.parse(location.project.directory).root
|
const projectRoot = path.parse(location.project.directory).root
|
||||||
const external =
|
const external =
|
||||||
!FSUtil.contains(location.directory, canonical) &&
|
!FSUtil.contains(location.directory, absolute) &&
|
||||||
(location.project.directory === projectRoot || !FSUtil.contains(location.project.directory, canonical))
|
(location.project.directory === projectRoot || !FSUtil.contains(location.project.directory, absolute))
|
||||||
const directory = path.dirname(canonical)
|
const directory = path.dirname(absolute)
|
||||||
const resource =
|
const resource =
|
||||||
process.platform === "win32"
|
process.platform === "win32"
|
||||||
? FSUtil.normalizePathPattern(path.join(directory, "*"))
|
? FSUtil.normalizePathPattern(path.join(directory, "*"))
|
||||||
: path.join(directory, "*").replaceAll("\\", "/")
|
: path.join(directory, "*").replaceAll("\\", "/")
|
||||||
return {
|
return {
|
||||||
canonical,
|
absolute,
|
||||||
resource: path.relative(location.project.directory, canonical).replaceAll("\\", "/") || ".",
|
resource: path.relative(location.project.directory, absolute).replaceAll("\\", "/") || ".",
|
||||||
externalDirectory: external ? { directory, resource } : undefined,
|
externalDirectory: external ? { directory, resource } : undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,11 +25,7 @@ const LocationInput = Schema.Struct({
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
export const Input = LocationInput
|
export const Input = LocationInput
|
||||||
const Output = Schema.Union([
|
const Output = Schema.Union([ReadToolFileSystem.FileContent, ReadToolFileSystem.TextPage, ReadToolFileSystem.ListPage])
|
||||||
ReadToolFileSystem.FileContent,
|
|
||||||
ReadToolFileSystem.TextPage,
|
|
||||||
ReadToolFileSystem.ListPage,
|
|
||||||
])
|
|
||||||
|
|
||||||
export const Plugin = {
|
export const Plugin = {
|
||||||
id: "opencode.tool.read",
|
id: "opencode.tool.read",
|
||||||
@@ -43,8 +39,7 @@ export const Plugin = {
|
|||||||
|
|
||||||
yield* ctx.tool
|
yield* ctx.tool
|
||||||
.transform((draft) =>
|
.transform((draft) =>
|
||||||
draft.add(
|
draft.add({
|
||||||
({
|
|
||||||
name,
|
name,
|
||||||
options: { codemode: false },
|
options: { codemode: false },
|
||||||
description:
|
description:
|
||||||
@@ -58,7 +53,7 @@ export const Plugin = {
|
|||||||
messageID: context.messageID,
|
messageID: context.messageID,
|
||||||
id: context.id,
|
id: context.id,
|
||||||
}
|
}
|
||||||
const target = yield* mutation.resolve({ path: input.path, kind: "directory" })
|
const target = yield* mutation.resolve({ path: input.path })
|
||||||
const external = target.externalDirectory
|
const external = target.externalDirectory
|
||||||
if (external)
|
if (external)
|
||||||
yield* permission.assert({
|
yield* permission.assert({
|
||||||
@@ -68,7 +63,7 @@ export const Plugin = {
|
|||||||
source,
|
source,
|
||||||
})
|
})
|
||||||
const resource = target.resource
|
const resource = target.resource
|
||||||
const absolute = AbsolutePath.make(target.canonical)
|
const absolute = AbsolutePath.make(target.absolute)
|
||||||
yield* permission.assert({
|
yield* permission.assert({
|
||||||
action: name,
|
action: name,
|
||||||
resources: [resource],
|
resources: [resource],
|
||||||
@@ -77,9 +72,9 @@ export const Plugin = {
|
|||||||
agent: context.agent,
|
agent: context.agent,
|
||||||
source,
|
source,
|
||||||
})
|
})
|
||||||
const type = yield* reader.inspect(absolute).pipe(
|
const type = yield* reader
|
||||||
Effect.catchReason("PlatformError", "NotFound", () => missing(input.path, target.canonical)),
|
.inspect(absolute)
|
||||||
)
|
.pipe(Effect.catchReason("PlatformError", "NotFound", () => missing(input.path, target.absolute)))
|
||||||
const content =
|
const content =
|
||||||
type === "directory"
|
type === "directory"
|
||||||
? yield* reader.list(absolute, { offset: input.offset, limit: input.limit })
|
? yield* reader.list(absolute, { offset: input.offset, limit: input.limit })
|
||||||
@@ -94,7 +89,7 @@ export const Plugin = {
|
|||||||
// skipped, and discovery failures never fail the read.
|
// skipped, and discovery failures never fail the read.
|
||||||
yield* Effect.gen(function* () {
|
yield* Effect.gen(function* () {
|
||||||
if (target.externalDirectory !== undefined) return
|
if (target.externalDirectory !== undefined) return
|
||||||
const resolved = yield* fs.resolve(target.canonical)
|
const resolved = yield* fs.resolve(target.absolute)
|
||||||
const root = yield* fs.resolve(location.directory)
|
const root = yield* fs.resolve(location.directory)
|
||||||
// up() searches its stop directory, so the Location-root AGENTS.md (already
|
// up() searches its stop directory, so the Location-root AGENTS.md (already
|
||||||
// supplied by core initial instructions) is dropped by the dirname filter.
|
// supplied by core initial instructions) is dropped by the dirname filter.
|
||||||
@@ -119,13 +114,13 @@ export const Plugin = {
|
|||||||
Effect.map((output) => ({
|
Effect.map((output) => ({
|
||||||
output,
|
output,
|
||||||
content: toModelContent(input.path, input.offset, output),
|
content: toModelContent(input.path, input.offset, output),
|
||||||
|
metadata: { truncated: output.type === "file" ? false : output.truncated },
|
||||||
})),
|
})),
|
||||||
Effect.mapError((error) => {
|
Effect.mapError((error) => {
|
||||||
if (error instanceof ToolFailure) return error
|
if (error instanceof ToolFailure) return error
|
||||||
const message =
|
const message =
|
||||||
error instanceof ReadToolFileSystem.BinaryFileError ||
|
error instanceof ReadToolFileSystem.BinaryFileError ||
|
||||||
error instanceof ReadToolFileSystem.MediaIngestLimitError ||
|
error instanceof ReadToolFileSystem.MediaIngestLimitError ||
|
||||||
error instanceof ReadToolFileSystem.MalformedUtf8Error ||
|
|
||||||
error instanceof ReadToolFileSystem.OffsetOutOfRangeError ||
|
error instanceof ReadToolFileSystem.OffsetOutOfRangeError ||
|
||||||
error instanceof ReadToolFileSystem.PathKindError
|
error instanceof ReadToolFileSystem.PathKindError
|
||||||
? error.message
|
? error.message
|
||||||
@@ -135,13 +130,12 @@ export const Plugin = {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.pipe(Effect.orDie)
|
.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 base = basename(input).toLowerCase()
|
||||||
const suggestions = yield* fs.readDirectory(dirname(canonical)).pipe(
|
const suggestions = yield* fs.readDirectory(dirname(absolute)).pipe(
|
||||||
Effect.map((entries) =>
|
Effect.map((entries) =>
|
||||||
entries
|
entries
|
||||||
.filter((entry) => {
|
.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 type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||||
import { Deferred, Effect, Schema, Scope } from "effect"
|
import { Deferred, Effect, Schema, Scope } from "effect"
|
||||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
|
import { Config } from "../../config"
|
||||||
import { LocationMutation } from "../../location-mutation"
|
import { LocationMutation } from "../../location-mutation"
|
||||||
import { Permission } from "../../permission"
|
import { Permission } from "../../permission"
|
||||||
import { PluginRuntime } from "../../plugin/runtime"
|
import { PluginRuntime } from "../../plugin/runtime"
|
||||||
@@ -13,10 +14,10 @@ import { NonNegativeInt } from "../../schema"
|
|||||||
import { SessionSchema } from "../../session/schema"
|
import { SessionSchema } from "../../session/schema"
|
||||||
import { Shell } from "../../shell"
|
import { Shell } from "../../shell"
|
||||||
import { ShellParse } from "../../shell/parse"
|
import { ShellParse } from "../../shell/parse"
|
||||||
|
import { ToolOutput } from "../../tool-output"
|
||||||
|
|
||||||
export const name = "shell"
|
export const name = "shell"
|
||||||
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
|
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
|
||||||
export const MAX_CAPTURE_BYTES = 1024 * 1024
|
|
||||||
|
|
||||||
const BACKGROUND_STARTED = "The command was moved to the background."
|
const BACKGROUND_STARTED = "The command was moved to the background."
|
||||||
const BACKGROUND_INSTRUCTION =
|
const BACKGROUND_INSTRUCTION =
|
||||||
@@ -86,6 +87,7 @@ export const Plugin = {
|
|||||||
const mutation = yield* LocationMutation.Service
|
const mutation = yield* LocationMutation.Service
|
||||||
const shell = yield* Shell.Service
|
const shell = yield* Shell.Service
|
||||||
const permission = yield* Permission.Service
|
const permission = yield* Permission.Service
|
||||||
|
const config = yield* Config.Service
|
||||||
|
|
||||||
const notifyWhenDone = Effect.fn("ShellTool.notifyWhenDone")(function* (
|
const notifyWhenDone = Effect.fn("ShellTool.notifyWhenDone")(function* (
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
@@ -122,8 +124,7 @@ export const Plugin = {
|
|||||||
|
|
||||||
yield* ctx.tool
|
yield* ctx.tool
|
||||||
.transform((draft) =>
|
.transform((draft) =>
|
||||||
draft.add(
|
draft.add({
|
||||||
({
|
|
||||||
name,
|
name,
|
||||||
options: { codemode: false },
|
options: { codemode: false },
|
||||||
description: description(),
|
description: description(),
|
||||||
@@ -148,16 +149,18 @@ export const Plugin = {
|
|||||||
(invocation) =>
|
(invocation) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const target = yield* mutation.resolve({ path: invocation.cwd, kind: "directory" })
|
const target = yield* mutation.resolve({ path: invocation.cwd, kind: "directory" })
|
||||||
const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.canonical)
|
const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.absolute)
|
||||||
const directories = yield* Effect.forEach(parsed.directories, (directory) =>
|
const directories = yield* Effect.forEach(parsed.directories, (directory) =>
|
||||||
mutation.resolve({ path: path.resolve(target.canonical, directory), kind: "directory" }),
|
mutation.resolve({ path: path.resolve(target.absolute, directory), kind: "directory" }),
|
||||||
)
|
)
|
||||||
invocation.cwd = target.canonical
|
invocation.cwd = target.absolute
|
||||||
finalTimeout = invocation.timeout
|
finalTimeout = invocation.timeout
|
||||||
const external = [target, ...directories]
|
const external = [target, ...directories]
|
||||||
.map((item) => item.externalDirectory)
|
.map((item) => item.externalDirectory)
|
||||||
.filter((item) => item !== undefined)
|
.filter((item) => item !== undefined)
|
||||||
.filter((item, index, items) => items.findIndex((other) => other.resource === item.resource) === index)
|
.filter(
|
||||||
|
(item, index, items) => items.findIndex((other) => other.resource === item.resource) === index,
|
||||||
|
)
|
||||||
if (external.length > 0)
|
if (external.length > 0)
|
||||||
yield* permission.assert({
|
yield* permission.assert({
|
||||||
action: "external_directory",
|
action: "external_directory",
|
||||||
@@ -176,27 +179,35 @@ export const Plugin = {
|
|||||||
agent: context.agent,
|
agent: context.agent,
|
||||||
source,
|
source,
|
||||||
})
|
})
|
||||||
const workdir = yield* fsUtil.stat(target.canonical).pipe(
|
const workdir = yield* fsUtil
|
||||||
|
.stat(target.absolute)
|
||||||
|
.pipe(
|
||||||
Effect.catchReason("PlatformError", "NotFound", () =>
|
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")
|
if (workdir.type !== "Directory")
|
||||||
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`))
|
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.absolute}`))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
yield* context.progress({ shellID: info.id })
|
yield* context.progress({ shellID: info.id })
|
||||||
|
|
||||||
const captureShell = Effect.fn("ShellTool.captureShell")(function* () {
|
const captureShell = Effect.fn("ShellTool.captureShell")(function* () {
|
||||||
|
const configured = Config.latest(yield* config.entries(), "tool_output")
|
||||||
|
const maxLines = configured?.max_lines ?? ToolOutput.MAX_LINES
|
||||||
|
const maxBytes = configured?.max_bytes ?? ToolOutput.MAX_BYTES
|
||||||
const latest = yield* shell.output(info.id, { cursor: Number.MAX_SAFE_INTEGER })
|
const latest = yield* shell.output(info.id, { cursor: Number.MAX_SAFE_INTEGER })
|
||||||
const truncated = latest.size > MAX_CAPTURE_BYTES
|
|
||||||
const page = yield* shell.output(info.id, {
|
const page = yield* shell.output(info.id, {
|
||||||
cursor: Math.max(0, latest.size - MAX_CAPTURE_BYTES),
|
cursor: Math.max(0, latest.size - maxBytes),
|
||||||
limit: MAX_CAPTURE_BYTES,
|
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}]` : ""
|
const notice = truncated ? `\n\n[output truncated; full output saved to: ${info.file}]` : ""
|
||||||
return {
|
return {
|
||||||
output: `${page.output || "(no output)"}${notice}`,
|
output: `${output || "(no output)"}${notice}`,
|
||||||
truncated,
|
truncated,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -250,9 +261,9 @@ export const Plugin = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = yield* runtime.job.block({ id: job.id, sessionID: context.sessionID }).pipe(
|
const result = yield* runtime.job
|
||||||
Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)),
|
.block({ id: job.id, sessionID: context.sessionID })
|
||||||
)
|
.pipe(Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)))
|
||||||
if (result?.type === "backgrounded") {
|
if (result?.type === "backgrounded") {
|
||||||
yield* shell.timeout(info.id, 0)
|
yield* shell.timeout(info.id, 0)
|
||||||
yield* notifyWhenDone(context.sessionID, context.id, info.command)
|
yield* notifyWhenDone(context.sessionID, context.id, info.command)
|
||||||
@@ -289,7 +300,6 @@ export const Plugin = {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
|
|
||||||
|
|||||||
@@ -54,8 +54,7 @@ export const Plugin = {
|
|||||||
|
|
||||||
yield* ctx.tool
|
yield* ctx.tool
|
||||||
.transform((draft) =>
|
.transform((draft) =>
|
||||||
draft.add(
|
draft.add({
|
||||||
({
|
|
||||||
name,
|
name,
|
||||||
options: { codemode: false, permission: "edit" },
|
options: { codemode: false, permission: "edit" },
|
||||||
description:
|
description:
|
||||||
@@ -78,16 +77,11 @@ export const Plugin = {
|
|||||||
agent: context.agent,
|
agent: context.agent,
|
||||||
source,
|
source,
|
||||||
})
|
})
|
||||||
const current = yield* Bom.readFile(fs, target.canonical).pipe(
|
const current = yield* Bom.readFile(fs, target.absolute).pipe(
|
||||||
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)),
|
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)),
|
||||||
)
|
)
|
||||||
const next = Bom.split(input.content)
|
const next = Bom.split(input.content)
|
||||||
const preview = fileDiff(
|
const preview = fileDiff(target.resource, current?.text ?? "", next.text, current ? "modified" : "added")
|
||||||
target.resource,
|
|
||||||
current?.text ?? "",
|
|
||||||
next.text,
|
|
||||||
current ? "modified" : "added",
|
|
||||||
)
|
|
||||||
yield* permission.assert({
|
yield* permission.assert({
|
||||||
action: "edit",
|
action: "edit",
|
||||||
resources: [target.resource],
|
resources: [target.resource],
|
||||||
@@ -98,15 +92,14 @@ export const Plugin = {
|
|||||||
source,
|
source,
|
||||||
})
|
})
|
||||||
const result = yield* files.writeTextPreservingBom({ target, content: input.content })
|
const result = yield* files.writeTextPreservingBom({ target, content: input.content })
|
||||||
const bom = (yield* Bom.readFile(fs, target.canonical)).bom
|
const bom = (yield* Bom.readFile(fs, target.absolute)).bom
|
||||||
if (yield* formatter.file(target.canonical)) yield* Bom.syncFile(fs, target.canonical, bom)
|
if (yield* formatter.file(target.absolute)) yield* Bom.syncFile(fs, target.absolute, bom)
|
||||||
return result
|
return result
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.map((output) => ({ output, content: toModelOutput(output) })),
|
Effect.map((output) => ({ output, content: toModelOutput(output) })),
|
||||||
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
|
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -34,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>()(
|
export class OffsetOutOfRangeError extends Schema.TaggedErrorClass<OffsetOutOfRangeError>()(
|
||||||
"ReadTool.OffsetOutOfRangeError",
|
"ReadTool.OffsetOutOfRangeError",
|
||||||
{ offset: Schema.Number },
|
{ offset: Schema.Number },
|
||||||
@@ -61,13 +53,7 @@ export class PathKindError extends Schema.TaggedErrorClass<PathKindError>()("Rea
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type InspectError = FSUtil.Error | PathKindError
|
export type InspectError = FSUtil.Error | PathKindError
|
||||||
export type ReadError =
|
export type ReadError = FSUtil.Error | BinaryFileError | MediaIngestLimitError | OffsetOutOfRangeError | PathKindError
|
||||||
| FSUtil.Error
|
|
||||||
| BinaryFileError
|
|
||||||
| MediaIngestLimitError
|
|
||||||
| MalformedUtf8Error
|
|
||||||
| OffsetOutOfRangeError
|
|
||||||
| PathKindError
|
|
||||||
|
|
||||||
export const PageInput = Schema.Struct({
|
export const PageInput = Schema.Struct({
|
||||||
offset: Schema.optionalKey(NonNegativeInt),
|
offset: Schema.optionalKey(NonNegativeInt),
|
||||||
@@ -90,9 +76,15 @@ export class TextPage extends Schema.Class<TextPage>("ReadTool.TextPage")({
|
|||||||
next: Schema.optionalKey(PositiveInt),
|
next: Schema.optionalKey(PositiveInt),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
|
export interface ListEntry extends Schema.Schema.Type<typeof ListEntry> {}
|
||||||
|
export const ListEntry = Schema.Struct({
|
||||||
|
path: RelativePath,
|
||||||
|
type: Schema.Literals(["file", "directory", "symlink"]),
|
||||||
|
}).annotate({ identifier: "ReadTool.ListEntry" })
|
||||||
|
|
||||||
export class ListPage extends Schema.Class<ListPage>("ReadTool.ListPage")({
|
export class ListPage extends Schema.Class<ListPage>("ReadTool.ListPage")({
|
||||||
type: Schema.Literal("list-page"),
|
type: Schema.Literal("list-page"),
|
||||||
entries: Schema.Array(FileSystem.Entry),
|
entries: Schema.Array(ListEntry),
|
||||||
truncated: Schema.Boolean,
|
truncated: Schema.Boolean,
|
||||||
next: Schema.optionalKey(PositiveInt),
|
next: Schema.optionalKey(PositiveInt),
|
||||||
}) {}
|
}) {}
|
||||||
@@ -109,36 +101,6 @@ export interface Interface {
|
|||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ReadToolFileSystem") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/ReadToolFileSystem") {}
|
||||||
|
|
||||||
const extensions = new Set([
|
|
||||||
".zip",
|
|
||||||
".tar",
|
|
||||||
".gz",
|
|
||||||
".exe",
|
|
||||||
".dll",
|
|
||||||
".so",
|
|
||||||
".class",
|
|
||||||
".jar",
|
|
||||||
".war",
|
|
||||||
".7z",
|
|
||||||
".doc",
|
|
||||||
".docx",
|
|
||||||
".xls",
|
|
||||||
".xlsx",
|
|
||||||
".ppt",
|
|
||||||
".pptx",
|
|
||||||
".odt",
|
|
||||||
".ods",
|
|
||||||
".odp",
|
|
||||||
".bin",
|
|
||||||
".dat",
|
|
||||||
".obj",
|
|
||||||
".o",
|
|
||||||
".a",
|
|
||||||
".lib",
|
|
||||||
".wasm",
|
|
||||||
".pyc",
|
|
||||||
".pyo",
|
|
||||||
])
|
|
||||||
const startsWith = (bytes: Uint8Array, prefix: number[]) => prefix.every((value, index) => bytes[index] === value)
|
const startsWith = (bytes: Uint8Array, prefix: number[]) => prefix.every((value, index) => bytes[index] === value)
|
||||||
const mediaMime = (bytes: Uint8Array) => {
|
const mediaMime = (bytes: Uint8Array) => {
|
||||||
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png"
|
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png"
|
||||||
@@ -148,8 +110,7 @@ const mediaMime = (bytes: Uint8Array) => {
|
|||||||
return "image/webp"
|
return "image/webp"
|
||||||
if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d])) return "application/pdf"
|
if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d])) return "application/pdf"
|
||||||
}
|
}
|
||||||
const binary = (resource: string, bytes: Uint8Array) => {
|
const binary = (bytes: Uint8Array) => {
|
||||||
if (extensions.has(path.extname(resource).toLowerCase())) return true
|
|
||||||
if (bytes.length === 0) return false
|
if (bytes.length === 0) return false
|
||||||
let nonPrintable = 0
|
let nonPrintable = 0
|
||||||
for (const byte of bytes) {
|
for (const byte of bytes) {
|
||||||
@@ -158,16 +119,9 @@ const binary = (resource: string, bytes: Uint8Array) => {
|
|||||||
}
|
}
|
||||||
return nonPrintable / bytes.length > 0.3
|
return nonPrintable / bytes.length > 0.3
|
||||||
}
|
}
|
||||||
const decodeUtf8 = (resource: string, decoder: TextDecoder, bytes?: Uint8Array) =>
|
const decodeUtf8 = (decoder: TextDecoder, bytes?: Uint8Array) => decoder.decode(bytes, { stream: bytes !== undefined })
|
||||||
Effect.try({
|
|
||||||
try: () => decoder.decode(bytes, { stream: bytes !== undefined }),
|
|
||||||
catch: (error) => {
|
|
||||||
if (error instanceof TypeError) return new MalformedUtf8Error({ resource })
|
|
||||||
throw error
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const decodeChunk = (resource: string, decoder: TextDecoder, bytes: Uint8Array) =>
|
const decodeChunk = (resource: string, decoder: TextDecoder, bytes: Uint8Array) =>
|
||||||
bytes.includes(0) ? Effect.fail(new BinaryFileError({ resource })) : 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) {
|
export const inspect = Effect.fn("ReadTool.inspect")(function* (fs: FSUtil.Interface, input: string) {
|
||||||
const info = yield* fs.stat(input)
|
const info = yield* fs.stat(input)
|
||||||
@@ -218,19 +172,17 @@ export const read = Effect.fn("ReadTool.read")(function* (
|
|||||||
mime,
|
mime,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (extensions.has(path.extname(resource).toLowerCase()))
|
|
||||||
return yield* Effect.fail(new BinaryFileError({ resource }))
|
|
||||||
const paged = info.size > MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined
|
const paged = info.size > MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined
|
||||||
if (!paged) {
|
if (!paged) {
|
||||||
if (binary(resource, first)) return yield* Effect.fail(new BinaryFileError({ resource }))
|
if (binary(first)) return yield* Effect.fail(new BinaryFileError({ resource }))
|
||||||
const decoder = new TextDecoder("utf-8", { fatal: true })
|
const decoder = new TextDecoder()
|
||||||
const text = [yield* decodeUtf8(resource, decoder, first)]
|
const text = [decodeUtf8(decoder, first)]
|
||||||
while (true) {
|
while (true) {
|
||||||
const chunk = yield* file.readAlloc(64 * 1024)
|
const chunk = yield* file.readAlloc(64 * 1024)
|
||||||
if (Option.isNone(chunk)) break
|
if (Option.isNone(chunk)) break
|
||||||
text.push(yield* decodeChunk(resource, decoder, chunk.value))
|
text.push(yield* decodeChunk(resource, decoder, chunk.value))
|
||||||
}
|
}
|
||||||
text.push(yield* decodeUtf8(resource, decoder))
|
text.push(decodeUtf8(decoder))
|
||||||
return {
|
return {
|
||||||
type: "file" as const,
|
type: "file" as const,
|
||||||
uri: pathToFileURL(real).href,
|
uri: pathToFileURL(real).href,
|
||||||
@@ -243,7 +195,7 @@ export const read = Effect.fn("ReadTool.read")(function* (
|
|||||||
const offset = page.offset || 1
|
const offset = page.offset || 1
|
||||||
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
|
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
|
||||||
const lines: string[] = []
|
const lines: string[] = []
|
||||||
const decoder = new TextDecoder("utf-8", { fatal: true })
|
const decoder = new TextDecoder()
|
||||||
let pending = ""
|
let pending = ""
|
||||||
let discard = false
|
let discard = false
|
||||||
let line = 1
|
let line = 1
|
||||||
@@ -301,8 +253,8 @@ export const read = Effect.fn("ReadTool.read")(function* (
|
|||||||
const newline = chunk.indexOf(10, start)
|
const newline = chunk.indexOf(10, start)
|
||||||
const end = newline === -1 ? chunk.length : newline + 1
|
const end = newline === -1 ? chunk.length : newline + 1
|
||||||
const segment = chunk.subarray(start, end)
|
const segment = chunk.subarray(start, end)
|
||||||
if (binary(resource, segment)) return yield* Effect.fail(new BinaryFileError({ resource }))
|
if (binary(segment)) return yield* Effect.fail(new BinaryFileError({ resource }))
|
||||||
if (!consume(yield* decodeUtf8(resource, decoder, segment))) return false
|
if (!consume(decodeUtf8(decoder, segment))) return false
|
||||||
start = end
|
start = end
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@@ -314,7 +266,7 @@ export const read = Effect.fn("ReadTool.read")(function* (
|
|||||||
done = !(yield* consumeChunk(chunk.value))
|
done = !(yield* consumeChunk(chunk.value))
|
||||||
}
|
}
|
||||||
if (!done) {
|
if (!done) {
|
||||||
const tail = yield* decodeUtf8(resource, decoder)
|
const tail = decodeUtf8(decoder)
|
||||||
if (!discard) pending += tail
|
if (!discard) pending += tail
|
||||||
if (pending) append(pending.endsWith("\r") ? pending.slice(0, -1) : pending)
|
if (pending) append(pending.endsWith("\r") ? pending.slice(0, -1) : pending)
|
||||||
}
|
}
|
||||||
@@ -336,26 +288,26 @@ export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface,
|
|||||||
const items = yield* fs.readDirectoryEntries(real)
|
const items = yield* fs.readDirectoryEntries(real)
|
||||||
const offset = page.offset || 1
|
const offset = page.offset || 1
|
||||||
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
|
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
|
||||||
const entries = yield* Effect.forEach(
|
const visible = items
|
||||||
items,
|
.flatMap((item) =>
|
||||||
(item) =>
|
item.type === "other"
|
||||||
Effect.gen(function* () {
|
? []
|
||||||
const absolute = path.join(real, item.name)
|
: [
|
||||||
const target = yield* fs.realPath(absolute).pipe(Effect.catch(() => Effect.void))
|
ListEntry.make({
|
||||||
if (!target || !FSUtil.contains(real, target)) return
|
path: RelativePath.make(item.name + (item.type === "directory" ? path.sep : "")),
|
||||||
const info = yield* fs.stat(target).pipe(Effect.catch(() => Effect.void))
|
type: item.type,
|
||||||
const type = info?.type === "Directory" ? "directory" : info?.type === "File" ? "file" : undefined
|
|
||||||
if (!type) return
|
|
||||||
return FileSystem.Entry.make({
|
|
||||||
path: RelativePath.make(item.name + (type === "directory" ? path.sep : "")),
|
|
||||||
type,
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
{ concurrency: 16 },
|
],
|
||||||
|
)
|
||||||
|
.sort((a, b) =>
|
||||||
|
a.type === "directory"
|
||||||
|
? b.type === "directory"
|
||||||
|
? a.path.localeCompare(b.path)
|
||||||
|
: -1
|
||||||
|
: b.type === "directory"
|
||||||
|
? 1
|
||||||
|
: a.path.localeCompare(b.path),
|
||||||
)
|
)
|
||||||
const visible = entries
|
|
||||||
.filter((item): item is FileSystem.Entry => item !== undefined)
|
|
||||||
.sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1))
|
|
||||||
const selected = visible.slice(offset - 1, offset - 1 + limit)
|
const selected = visible.slice(offset - 1, offset - 1 + limit)
|
||||||
const truncated = offset - 1 + selected.length < visible.length
|
const truncated = offset - 1 + selected.length < visible.length
|
||||||
return new ListPage({
|
return new ListPage({
|
||||||
|
|||||||
@@ -119,8 +119,16 @@ function agents(info: typeof ConfigV1.Info.Type) {
|
|||||||
...Object.entries(info.agent ?? {}),
|
...Object.entries(info.agent ?? {}),
|
||||||
...Object.entries(info.mode ?? {}).map(([name, agent]) => [name, { ...agent, mode: "primary" as const }] as const),
|
...Object.entries(info.mode ?? {}).map(([name, agent]) => [name, { ...agent, mode: "primary" as const }] as const),
|
||||||
]
|
]
|
||||||
if (!entries.length) return undefined
|
const result = Object.fromEntries(entries.flatMap(([name, agent]) => (agent ? [[name, migrateAgent(agent)]] : [])))
|
||||||
return 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) {
|
export function migrateAgent(info: ConfigAgentV1.Info) {
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ describe("Agent", () => {
|
|||||||
|
|
||||||
yield* agent.transform((editor) => editor.update(id, () => {}))
|
yield* agent.transform((editor) => editor.update(id, () => {}))
|
||||||
const info = yield* agent.get(id)
|
const info = yield* agent.get(id)
|
||||||
|
expect(info?.mode).toBe("primary")
|
||||||
expect(info?.permissions.slice(0, Agent.Info.default(id).permissions.length)).toEqual(
|
expect(info?.permissions.slice(0, Agent.Info.default(id).permissions.length)).toEqual(
|
||||||
Agent.Info.default(id).permissions,
|
Agent.Info.default(id).permissions,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -512,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", () =>
|
it.effect("migrates v1 provider lists to policies", () =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -149,6 +149,28 @@ describe("ConfigNormalize", () => {
|
|||||||
expect(() => Schema.decodeUnknownSync(Info)(result.encoded)).not.toThrow()
|
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", () => {
|
test("recovers malformed named entries and retains a valid legacy collision", () => {
|
||||||
const result = normalized({
|
const result = normalized({
|
||||||
command: { fallback: { template: "legacy" } },
|
command: { fallback: { template: "legacy" } },
|
||||||
@@ -390,7 +412,6 @@ describe("ConfigNormalize", () => {
|
|||||||
const secret = "do-not-log-this-value"
|
const secret = "do-not-log-this-value"
|
||||||
const result = normalized({
|
const result = normalized({
|
||||||
logLevel: "DEBUG",
|
logLevel: "DEBUG",
|
||||||
small_model: secret,
|
|
||||||
agent: { reviewer: { name: secret, prompt: "review" } },
|
agent: { reviewer: { name: secret, prompt: "review" } },
|
||||||
provider: {
|
provider: {
|
||||||
custom: {
|
custom: {
|
||||||
@@ -409,7 +430,6 @@ describe("ConfigNormalize", () => {
|
|||||||
})
|
})
|
||||||
expect(result.diagnostics.filter((item) => item.kind === "unsupported").map((item) => item.path)).toEqual([
|
expect(result.diagnostics.filter((item) => item.kind === "unsupported").map((item) => item.path)).toEqual([
|
||||||
["logLevel"],
|
["logLevel"],
|
||||||
["small_model"],
|
|
||||||
["agent", "reviewer", "name"],
|
["agent", "reviewer", "name"],
|
||||||
["provider", "custom", "id"],
|
["provider", "custom", "id"],
|
||||||
["provider", "custom", "whitelist"],
|
["provider", "custom", "whitelist"],
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import fs from "node:fs/promises"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { ChildProcessSpawner } from "effect/unstable/process"
|
||||||
|
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
|
||||||
|
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||||
|
import { execDefaults, Failed, makeFiles, makeMemoryDriver } from "../src/environment/index"
|
||||||
|
import { tmpdir } from "./fixture/tmpdir"
|
||||||
|
import { environmentConformance } from "./lib/environment-conformance"
|
||||||
|
|
||||||
|
environmentConformance("memory environment", () =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
const driver = makeMemoryDriver()
|
||||||
|
return {
|
||||||
|
files: makeFiles(driver),
|
||||||
|
root: `/workspace-${crypto.randomUUID()}`,
|
||||||
|
symlink: driver.symlink,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
environmentConformance(
|
||||||
|
"GNU exec environment",
|
||||||
|
() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||||
|
const tmp = yield* Effect.promise(() => tmpdir("opencode-environment-"))
|
||||||
|
return {
|
||||||
|
files: execDefaults(spawner),
|
||||||
|
root: tmp.path,
|
||||||
|
symlink: (target: string, link: string) =>
|
||||||
|
Effect.tryPromise({
|
||||||
|
try: () => fs.symlink(target, link),
|
||||||
|
catch: (cause) => new Failed({ path: link, cause }),
|
||||||
|
}),
|
||||||
|
dispose: Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||||
|
}
|
||||||
|
}).pipe(Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))),
|
||||||
|
process.platform !== "linux",
|
||||||
|
)
|
||||||
@@ -43,7 +43,7 @@ describe("FileMutation", () => {
|
|||||||
|
|
||||||
expect(yield* (yield* FileMutation.Service).write({ target, content: "after" })).toEqual({
|
expect(yield* (yield* FileMutation.Service).write({ target, content: "after" })).toEqual({
|
||||||
operation: "write",
|
operation: "write",
|
||||||
target: target.canonical,
|
target: target.absolute,
|
||||||
resource: "hello.txt",
|
resource: "hello.txt",
|
||||||
existed: true,
|
existed: true,
|
||||||
})
|
})
|
||||||
@@ -62,11 +62,11 @@ describe("FileMutation", () => {
|
|||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
operation: "write",
|
operation: "write",
|
||||||
target: target.canonical,
|
target: target.absolute,
|
||||||
resource: "src/nested/hello.txt",
|
resource: "src/nested/hello.txt",
|
||||||
existed: false,
|
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)),
|
}).pipe(provide(directory)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -84,7 +84,7 @@ describe("FileMutation", () => {
|
|||||||
yield* files.writeTextPreservingBom({ target: created, content: "\uFEFF\uFEFF\uFEFFcreated" })
|
yield* files.writeTextPreservingBom({ target: created, content: "\uFEFF\uFEFF\uFEFFcreated" })
|
||||||
|
|
||||||
expect(yield* Effect.promise(() => fs.readFile(preservedPath, "utf8"))).toBe("\uFEFFafter")
|
expect(yield* Effect.promise(() => fs.readFile(preservedPath, "utf8"))).toBe("\uFEFFafter")
|
||||||
expect(yield* Effect.promise(() => fs.readFile(created.canonical, "utf8"))).toBe("\uFEFFcreated")
|
expect(yield* Effect.promise(() => fs.readFile(created.absolute, "utf8"))).toBe("\uFEFFcreated")
|
||||||
}).pipe(provide(directory)),
|
}).pipe(provide(directory)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -99,7 +99,7 @@ describe("FileMutation", () => {
|
|||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
operation: "write",
|
operation: "write",
|
||||||
target: target.canonical,
|
target: target.absolute,
|
||||||
resource: target.resource,
|
resource: target.resource,
|
||||||
existed: false,
|
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) =>
|
withTmp((directory) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const targetPath = path.join(directory, "shared.txt")
|
const targetPath = path.join(directory, "shared.txt")
|
||||||
@@ -152,7 +152,7 @@ describe("FileMutation", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("allows distinct canonical targets to proceed independently", () =>
|
it.live("allows distinct absolute targets to proceed independently", () =>
|
||||||
withTmp((directory) =>
|
withTmp((directory) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const firstStarted = yield* Deferred.make<void>()
|
const firstStarted = yield* Deferred.make<void>()
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { Failed, NotFound, WrongKind, type Files } from "../../src/environment/index"
|
||||||
|
import { it } from "./effect"
|
||||||
|
|
||||||
|
export interface EnvironmentHarness {
|
||||||
|
readonly files: Files
|
||||||
|
readonly root: string
|
||||||
|
readonly symlink?: (target: string, path: string) => Effect.Effect<void, Failed>
|
||||||
|
readonly dispose?: Effect.Effect<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const environmentConformance = <E>(
|
||||||
|
name: string,
|
||||||
|
makeHarness: () => Effect.Effect<EnvironmentHarness, E>,
|
||||||
|
skip = false,
|
||||||
|
) => {
|
||||||
|
const check = <A, E2>(title: string, body: (harness: EnvironmentHarness) => Effect.Effect<A, E2>) =>
|
||||||
|
it.live(title, () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const harness = yield* Effect.acquireRelease(makeHarness(), (harness) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* Effect.ignore(harness.files.remove(harness.root))
|
||||||
|
if (harness.dispose) yield* harness.dispose
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
yield* harness.files.mkdir(harness.root)
|
||||||
|
return yield* body(harness)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const bytes = (value: string) => new TextEncoder().encode(value)
|
||||||
|
const text = (value: Uint8Array) => new TextDecoder().decode(value)
|
||||||
|
const suite = skip ? describe.skip : describe
|
||||||
|
|
||||||
|
suite(name, () => {
|
||||||
|
check("writes, stats, and reads a file with its info", (harness) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const target = `${harness.root}/hello.txt`
|
||||||
|
yield* harness.files.write(target, bytes("hello"))
|
||||||
|
const result = yield* harness.files.read(target)
|
||||||
|
expect(text(result.bytes)).toBe("hello")
|
||||||
|
expect(result.info.type).toBe("file")
|
||||||
|
expect(result.info.size).toBe(5)
|
||||||
|
expect(yield* harness.files.stat(target)).toEqual(result.info)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
check("reports missing paths", (harness) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const target = `${harness.root}/missing`
|
||||||
|
expect(yield* Effect.flip(harness.files.read(target))).toBeInstanceOf(NotFound)
|
||||||
|
expect(yield* Effect.flip(harness.files.stat(target))).toBeInstanceOf(NotFound)
|
||||||
|
expect(yield* Effect.flip(harness.files.list(target))).toBeInstanceOf(NotFound)
|
||||||
|
expect(yield* Effect.flip(harness.files.move(target, `${harness.root}/other`))).toBeInstanceOf(NotFound)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
check("reports the actual kind", (harness) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const directory = `${harness.root}/directory`
|
||||||
|
const file = `${harness.root}/file`
|
||||||
|
yield* harness.files.mkdir(directory)
|
||||||
|
yield* harness.files.write(file, bytes("data"))
|
||||||
|
const readError = yield* Effect.flip(harness.files.read(directory))
|
||||||
|
const listError = yield* Effect.flip(harness.files.list(file))
|
||||||
|
expect(readError).toBeInstanceOf(WrongKind)
|
||||||
|
expect((readError as WrongKind).actual).toBe("directory")
|
||||||
|
expect(listError).toBeInstanceOf(WrongKind)
|
||||||
|
expect((listError as WrongKind).actual).toBe("file")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
check("write creates parent directories", (harness) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const target = `${harness.root}/one/two/file`
|
||||||
|
yield* harness.files.write(target, bytes("nested"))
|
||||||
|
yield* harness.files.write(`${harness.root}/empty`, new Uint8Array())
|
||||||
|
expect((yield* harness.files.stat(`${harness.root}/one/two`)).type).toBe("directory")
|
||||||
|
expect(yield* harness.files.stat(`${harness.root}/empty`)).toMatchObject({ type: "file", size: 0 })
|
||||||
|
expect(text((yield* harness.files.read(target)).bytes)).toBe("nested")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
check("reads byte ranges", (harness) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const target = `${harness.root}/range`
|
||||||
|
yield* harness.files.write(target, bytes("0123456789"))
|
||||||
|
expect(text((yield* harness.files.read(target, { offset: 2, length: 4 })).bytes)).toBe("2345")
|
||||||
|
expect(text((yield* harness.files.read(target, { offset: 8, length: 8 })).bytes)).toBe("89")
|
||||||
|
expect(text((yield* harness.files.read(target, { offset: 20, length: 4 })).bytes)).toBe("")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
check("lists immediate entries with their kinds", (harness) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* harness.files.write(`${harness.root}/file name`, bytes("data"))
|
||||||
|
yield* harness.files.mkdir(`${harness.root}/directory`)
|
||||||
|
yield* harness.files.write(`${harness.root}/directory/nested`, bytes("nested"))
|
||||||
|
const entries = yield* harness.files.list(harness.root)
|
||||||
|
expect(entries.toSorted((a, b) => a.name.localeCompare(b.name))).toEqual([
|
||||||
|
{ name: "directory", type: "directory" },
|
||||||
|
{ name: "file name", type: "file" },
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
check("reports symlinks without resolving them", (harness) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
if (!harness.symlink) return
|
||||||
|
yield* harness.files.write(`${harness.root}/target`, bytes("target"))
|
||||||
|
yield* harness.files.write(`${harness.root}/target-dir/file`, bytes("through link"))
|
||||||
|
yield* harness.symlink("target", `${harness.root}/link`)
|
||||||
|
yield* harness.symlink("target-dir", `${harness.root}/link-dir`)
|
||||||
|
expect((yield* harness.files.stat(`${harness.root}/link`)).type).toBe("symlink")
|
||||||
|
expect(yield* harness.files.list(harness.root)).toContainEqual({ name: "link", type: "symlink" })
|
||||||
|
expect(text((yield* harness.files.read(`${harness.root}/link-dir/file`)).bytes)).toBe("through link")
|
||||||
|
const listError = yield* Effect.flip(harness.files.list(`${harness.root}/link-dir`))
|
||||||
|
expect(listError).toBeInstanceOf(WrongKind)
|
||||||
|
expect((listError as WrongKind).actual).toBe("symlink")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
check("follows symlinks when reading", (harness) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
if (!harness.symlink) return
|
||||||
|
yield* harness.files.write(`${harness.root}/target`, bytes("target content"))
|
||||||
|
yield* harness.files.mkdir(`${harness.root}/directory`)
|
||||||
|
yield* harness.symlink("target", `${harness.root}/file-link`)
|
||||||
|
yield* harness.symlink("directory", `${harness.root}/directory-link`)
|
||||||
|
yield* harness.symlink("missing", `${harness.root}/dangling-link`)
|
||||||
|
|
||||||
|
const result = yield* harness.files.read(`${harness.root}/file-link`)
|
||||||
|
expect(text(result.bytes)).toBe("target content")
|
||||||
|
expect(result.info.type).toBe("file")
|
||||||
|
expect(result.info.size).toBe(bytes("target content").length)
|
||||||
|
|
||||||
|
const directoryError = yield* Effect.flip(harness.files.read(`${harness.root}/directory-link`))
|
||||||
|
expect(directoryError).toBeInstanceOf(WrongKind)
|
||||||
|
expect((directoryError as WrongKind).actual).toBe("directory")
|
||||||
|
expect(yield* Effect.flip(harness.files.read(`${harness.root}/dangling-link`))).toBeInstanceOf(NotFound)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
check("moves files and removes trees idempotently", (harness) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const source = `${harness.root}/source/file`
|
||||||
|
const destination = `${harness.root}/destination`
|
||||||
|
yield* harness.files.write(source, bytes("moved"))
|
||||||
|
yield* harness.files.move(source, destination)
|
||||||
|
expect(text((yield* harness.files.read(destination)).bytes)).toBe("moved")
|
||||||
|
expect(yield* Effect.flip(harness.files.stat(source))).toBeInstanceOf(NotFound)
|
||||||
|
yield* harness.files.remove(`${harness.root}/source`)
|
||||||
|
yield* harness.files.remove(`${harness.root}/source`)
|
||||||
|
expect(yield* Effect.flip(harness.files.stat(`${harness.root}/source`))).toBeInstanceOf(NotFound)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -37,7 +37,7 @@ describe("LocationMutation", () => {
|
|||||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "hello.txt" })
|
const target = yield* (yield* LocationMutation.Service).resolve({ path: "hello.txt" })
|
||||||
|
|
||||||
expect(target).toMatchObject({
|
expect(target).toMatchObject({
|
||||||
canonical: yield* Effect.promise(() => fs.realpath(targetPath)),
|
absolute: targetPath,
|
||||||
resource: "hello.txt",
|
resource: "hello.txt",
|
||||||
})
|
})
|
||||||
expect(target.externalDirectory).toBeUndefined()
|
expect(target.externalDirectory).toBeUndefined()
|
||||||
@@ -50,10 +50,8 @@ describe("LocationMutation", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
|
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
|
||||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("src", "new.txt") })
|
const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("src", "new.txt") })
|
||||||
const root = yield* Effect.promise(() => fs.realpath(directory))
|
|
||||||
|
|
||||||
expect(target).toMatchObject({
|
expect(target).toMatchObject({
|
||||||
canonical: path.join(root, "src", "new.txt"),
|
absolute: path.join(directory, "src", "new.txt"),
|
||||||
resource: "src/new.txt",
|
resource: "src/new.txt",
|
||||||
})
|
})
|
||||||
}).pipe(provide(directory)),
|
}).pipe(provide(directory)),
|
||||||
@@ -64,9 +62,9 @@ describe("LocationMutation", () => {
|
|||||||
withTmp((directory) =>
|
withTmp((directory) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "../outside.txt" })
|
const target = yield* (yield* LocationMutation.Service).resolve({ path: "../outside.txt" })
|
||||||
const root = yield* Effect.promise(() => fs.realpath(path.dirname(directory)))
|
const root = path.dirname(directory)
|
||||||
expect(target).toMatchObject({
|
expect(target).toMatchObject({
|
||||||
canonical: path.join(root, "outside.txt"),
|
absolute: path.join(root, "outside.txt"),
|
||||||
resource: path.join(root, "outside.txt").replaceAll("\\", "/"),
|
resource: path.join(root, "outside.txt").replaceAll("\\", "/"),
|
||||||
})
|
})
|
||||||
expect(target.externalDirectory).toMatchObject({
|
expect(target.externalDirectory).toMatchObject({
|
||||||
@@ -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) => {
|
withTmp((directory) => {
|
||||||
const outside = `${directory}-outside`
|
const outside = `${directory}-outside`
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
@@ -88,7 +86,7 @@ describe("LocationMutation", () => {
|
|||||||
})
|
})
|
||||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("escape", "new.txt") })
|
const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("escape", "new.txt") })
|
||||||
expect(target).toMatchObject({
|
expect(target).toMatchObject({
|
||||||
canonical: path.join(yield* Effect.promise(() => fs.realpath(outside)), "new.txt"),
|
absolute: path.join(directory, "escape", "new.txt"),
|
||||||
resource: "escape/new.txt",
|
resource: "escape/new.txt",
|
||||||
})
|
})
|
||||||
expect(target.externalDirectory).toBeUndefined()
|
expect(target.externalDirectory).toBeUndefined()
|
||||||
@@ -107,7 +105,7 @@ describe("LocationMutation", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
expect(yield* (yield* LocationMutation.Service).resolve({ path: "linked/new.txt" })).toMatchObject({
|
expect(yield* (yield* LocationMutation.Service).resolve({ path: "linked/new.txt" })).toMatchObject({
|
||||||
canonical: path.join(yield* Effect.promise(() => fs.realpath(directory)), "actual", "new.txt"),
|
absolute: path.join(directory, "linked", "new.txt"),
|
||||||
resource: "linked/new.txt",
|
resource: "linked/new.txt",
|
||||||
})
|
})
|
||||||
}).pipe(provide(directory)),
|
}).pipe(provide(directory)),
|
||||||
@@ -120,7 +118,7 @@ describe("LocationMutation", () => {
|
|||||||
const targetPath = path.join(directory, "new.txt")
|
const targetPath = path.join(directory, "new.txt")
|
||||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||||
expect(target).toMatchObject({
|
expect(target).toMatchObject({
|
||||||
canonical: path.join(yield* Effect.promise(() => fs.realpath(directory)), "new.txt"),
|
absolute: targetPath,
|
||||||
resource: "new.txt",
|
resource: "new.txt",
|
||||||
})
|
})
|
||||||
expect(target.externalDirectory).toBeUndefined()
|
expect(target.externalDirectory).toBeUndefined()
|
||||||
@@ -134,9 +132,9 @@ describe("LocationMutation", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const targetPath = path.join(outside, "new.txt")
|
const targetPath = path.join(outside, "new.txt")
|
||||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||||
const root = yield* Effect.promise(() => fs.realpath(outside))
|
const root = outside
|
||||||
expect(target).toMatchObject({
|
expect(target).toMatchObject({
|
||||||
canonical: path.join(root, "new.txt"),
|
absolute: path.join(root, "new.txt"),
|
||||||
resource: path.join(root, "new.txt").replaceAll("\\", "/"),
|
resource: path.join(root, "new.txt").replaceAll("\\", "/"),
|
||||||
})
|
})
|
||||||
expect(target.externalDirectory).toMatchObject({
|
expect(target.externalDirectory).toMatchObject({
|
||||||
@@ -155,24 +153,23 @@ describe("LocationMutation", () => {
|
|||||||
const targetPath = path.join(outside, "existing.txt")
|
const targetPath = path.join(outside, "existing.txt")
|
||||||
yield* Effect.promise(() => fs.writeFile(targetPath, "existing"))
|
yield* Effect.promise(() => fs.writeFile(targetPath, "existing"))
|
||||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||||
const root = yield* Effect.promise(() => fs.realpath(outside))
|
expect(target).toMatchObject({ absolute: targetPath })
|
||||||
expect(target).toMatchObject({ canonical: path.join(root, "existing.txt") })
|
expect(target.externalDirectory?.directory).toBe(outside)
|
||||||
expect(target.externalDirectory?.directory).toBe(root)
|
|
||||||
}).pipe(provide(directory)),
|
}).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((directory) =>
|
||||||
withTmp((outside) =>
|
withTmp((outside) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const targetPath = path.join(outside, "new", "nested", "file.txt")
|
const targetPath = path.join(outside, "new", "nested", "file.txt")
|
||||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||||
const root = yield* Effect.promise(() => fs.realpath(outside))
|
const parent = path.dirname(targetPath)
|
||||||
expect(target.externalDirectory).toMatchObject({
|
expect(target.externalDirectory).toMatchObject({
|
||||||
directory: root,
|
directory: parent,
|
||||||
resource: path.join(root, "*").replaceAll("\\", "/"),
|
resource: path.join(parent, "*").replaceAll("\\", "/"),
|
||||||
})
|
})
|
||||||
}).pipe(provide(directory)),
|
}).pipe(provide(directory)),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1086,4 +1086,78 @@ describe("Session.pending", () => {
|
|||||||
expect(yield* session.pending(sessionID)).toEqual([])
|
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 { describe, expect } from "bun:test"
|
||||||
|
import fs from "fs/promises"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Effect, FileSystem } from "effect"
|
import { Effect, FileSystem } from "effect"
|
||||||
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
|
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
|
||||||
@@ -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* () {
|
Effect.gen(function* () {
|
||||||
const { fs, files, directory } = yield* fixture
|
const { fs, files, directory } = yield* fixture
|
||||||
const binary = path.join(directory, "archive.dat")
|
const binary = path.join(directory, "archive.dat")
|
||||||
const malformed = path.join(directory, "malformed.txt")
|
const malformed = path.join(directory, "malformed.txt")
|
||||||
yield* files.writeFile(binary, Uint8Array.of(0, 1, 2, 3))
|
yield* files.writeFile(binary, Uint8Array.of(0, 1, 2, 3))
|
||||||
const malformedContent = new Uint8Array(64 * 1024 + 1).fill(97)
|
yield* files.writeFile(malformed, Uint8Array.of(0x68, 0x69, 0x80))
|
||||||
malformedContent[64 * 1024] = 0x80
|
|
||||||
yield* files.writeFile(malformed, malformedContent)
|
|
||||||
|
|
||||||
const binaryError = yield* ReadToolFileSystem.read(fs, binary, "archive.dat").pipe(Effect.flip)
|
const binaryError = yield* ReadToolFileSystem.read(fs, binary, "archive.dat").pipe(Effect.flip)
|
||||||
const 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).toBeInstanceOf(ReadToolFileSystem.BinaryFileError)
|
||||||
expect(binaryError.message).toBe("Cannot read binary file: archive.dat")
|
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,
|
||||||
LocationMutation.Service.of({
|
LocationMutation.Service.of({
|
||||||
resolve: (input) => {
|
resolve: (input) => {
|
||||||
const canonical = path.resolve(process.cwd(), input.path)
|
const absolute = path.resolve(process.cwd(), input.path)
|
||||||
const external = path.isAbsolute(input.path) && !FSUtil.contains(process.cwd(), canonical)
|
const external = path.isAbsolute(input.path) && !FSUtil.contains(process.cwd(), absolute)
|
||||||
const resource = external ? canonical.replaceAll("\\", "/") : path.relative(process.cwd(), canonical) || "."
|
const resource = external ? absolute.replaceAll("\\", "/") : path.relative(process.cwd(), absolute) || "."
|
||||||
const directory = path.dirname(canonical)
|
const directory = path.dirname(absolute)
|
||||||
const externalResource = path.join(directory, "*").replaceAll("\\", "/")
|
const externalResource = path.join(directory, "*").replaceAll("\\", "/")
|
||||||
return Effect.succeed({
|
return Effect.succeed({
|
||||||
canonical,
|
absolute,
|
||||||
resource,
|
resource,
|
||||||
externalDirectory: external
|
externalDirectory: external
|
||||||
? {
|
? {
|
||||||
@@ -311,9 +311,7 @@ describe("ReadTool", () => {
|
|||||||
})
|
})
|
||||||
expect(settled.status).toBe("completed")
|
expect(settled.status).toBe("completed")
|
||||||
if (settled.status !== "completed") return
|
if (settled.status !== "completed") return
|
||||||
// Image base64 is carried by the content file item only; read produces no
|
expect(settled.metadata).toEqual({ truncated: false })
|
||||||
// metadata, so the original bytes are never persisted twice.
|
|
||||||
expect(settled.metadata).toBeUndefined()
|
|
||||||
expect(settled.content).toMatchObject([
|
expect(settled.content).toMatchObject([
|
||||||
{ type: "text", text: "Image read successfully" },
|
{ type: "text", text: "Image read successfully" },
|
||||||
{ type: "file", mime: "image/png", uri: `data:image/png;base64,${png}` },
|
{ type: "file", mime: "image/png", uri: `data:image/png;base64,${png}` },
|
||||||
@@ -621,10 +619,6 @@ describe("ReadTool", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const registry = yield* Tool.Service
|
const registry = yield* Tool.Service
|
||||||
for (const [error, message] of [
|
for (const [error, message] of [
|
||||||
[
|
|
||||||
new ReadToolFileSystem.MalformedUtf8Error({ resource: "invalid.txt" }),
|
|
||||||
"File is not valid UTF-8: invalid.txt",
|
|
||||||
],
|
|
||||||
[new ReadToolFileSystem.OffsetOutOfRangeError({ offset: 10 }), "Offset 10 is out of range"],
|
[new ReadToolFileSystem.OffsetOutOfRangeError({ offset: 10 }), "Offset 10 is out of range"],
|
||||||
[
|
[
|
||||||
new ReadToolFileSystem.PathKindError({ resource: "socket", expected: "a file" }),
|
new ReadToolFileSystem.PathKindError({ resource: "socket", expected: "a file" }),
|
||||||
@@ -730,8 +724,12 @@ describe("ReadTool", () => {
|
|||||||
input: { path: "src", offset: 2, limit: 10 },
|
input: { path: "src", offset: 2, limit: 10 },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
expect(result).toMatchObject({ status: "completed", output: { entries: listResult.entries, truncated: true, next: 4 } })
|
expect(result).toMatchObject({
|
||||||
|
status: "completed",
|
||||||
|
output: { entries: listResult.entries, truncated: true, next: 4 },
|
||||||
|
})
|
||||||
if (result.status !== "completed") return
|
if (result.status !== "completed") return
|
||||||
|
expect(result.metadata).toEqual({ truncated: true })
|
||||||
expect(result.content).toEqual([
|
expect(result.content).toEqual([
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
@@ -806,6 +804,7 @@ describe("ReadTool", () => {
|
|||||||
output: { type: "text-page", content: "hello", mime: "text/plain", offset: 2, truncated: true, next: 3 },
|
output: { type: "text-page", content: "hello", mime: "text/plain", offset: 2, truncated: true, next: 3 },
|
||||||
})
|
})
|
||||||
if (result.status !== "completed") return
|
if (result.status !== "completed") return
|
||||||
|
expect(result.metadata).toEqual({ truncated: true })
|
||||||
expect(result.content).toEqual([
|
expect(result.content).toEqual([
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
|||||||
import { Shell } from "@opencode-ai/core/shell"
|
import { Shell } from "@opencode-ai/core/shell"
|
||||||
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
|
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
|
||||||
import { ShellTool } from "@opencode-ai/core/tool/plugin/shell"
|
import { ShellTool } from "@opencode-ai/core/tool/plugin/shell"
|
||||||
|
import { ToolOutput } from "@opencode-ai/core/tool-output"
|
||||||
import { Tool } from "@opencode-ai/core/tool"
|
import { Tool } from "@opencode-ai/core/tool"
|
||||||
import { tmpdir } from "./fixture/tmpdir"
|
import { tmpdir } from "./fixture/tmpdir"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
@@ -171,6 +172,9 @@ const overflowCommand = (bytes: number) =>
|
|||||||
isWindows
|
isWindows
|
||||||
? `[Console]::Out.Write('output-start' + ('x' * ${bytes}) + 'output-end'); Start-Sleep -Milliseconds 100`
|
? `[Console]::Out.Write('output-start' + ('x' * ${bytes}) + 'output-end'); Start-Sleep -Milliseconds 100`
|
||||||
: `printf output-start; head -c ${bytes} /dev/zero | tr '\\0' 'x'; printf output-end`
|
: `printf output-start; head -c ${bytes} /dev/zero | tr '\\0' 'x'; printf output-end`
|
||||||
|
const lineOverflowCommand = isWindows
|
||||||
|
? "[Console]::Out.Write('one' + [Environment]::NewLine + 'two' + [Environment]::NewLine + 'three')"
|
||||||
|
: "printf 'one\\ntwo\\nthree'"
|
||||||
const progressOverflowCommand = (bytes: number, release: string) =>
|
const progressOverflowCommand = (bytes: number, release: string) =>
|
||||||
isWindows
|
isWindows
|
||||||
? `[Console]::Out.Write(('x' * ${bytes})); while (!(Test-Path -LiteralPath '${release}')) { Start-Sleep -Milliseconds 50 }`
|
? `[Console]::Out.Write(('x' * ${bytes})); while (!(Test-Path -LiteralPath '${release}')) { Start-Sleep -Milliseconds 50 }`
|
||||||
@@ -477,7 +481,7 @@ describe("ShellTool", () => {
|
|||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
(tmp) => {
|
(tmp) => {
|
||||||
reset()
|
reset()
|
||||||
const bytes = ShellTool.MAX_CAPTURE_BYTES + 1024
|
const bytes = ToolOutput.MAX_BYTES + 1024
|
||||||
return withSession(tmp.path, (registry) =>
|
return withSession(tmp.path, (registry) =>
|
||||||
executeTool(registry, call({ command: overflowCommand(bytes) }, "call-overflow")),
|
executeTool(registry, call({ command: overflowCommand(bytes) }, "call-overflow")),
|
||||||
).pipe(
|
).pipe(
|
||||||
@@ -501,6 +505,33 @@ describe("ShellTool", () => {
|
|||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.live("uses configured line limits", () =>
|
||||||
|
Effect.acquireUseRelease(
|
||||||
|
Effect.promise(() => tmpdir()),
|
||||||
|
(tmp) => {
|
||||||
|
reset()
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
yield* Effect.promise(() =>
|
||||||
|
Bun.write(
|
||||||
|
path.join(tmp.path, "opencode.json"),
|
||||||
|
JSON.stringify({ tool_output: { max_lines: 2, max_bytes: 1_000 } }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const settled = yield* withSession(tmp.path, (registry) =>
|
||||||
|
executeTool(registry, call({ command: lineOverflowCommand }, "call-line-overflow")),
|
||||||
|
)
|
||||||
|
expect(settled.metadata).toMatchObject({ exit: 0, truncated: true })
|
||||||
|
const content = settled.content?.[0]
|
||||||
|
if (!content || content.type !== "text") throw new Error("Expected text content")
|
||||||
|
expect(content.text).not.toContain("one")
|
||||||
|
expect(content.text).toStartWith("two\nthree")
|
||||||
|
expect(content.text).toContain("output truncated; full output saved to:")
|
||||||
|
})
|
||||||
|
},
|
||||||
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
it.live(
|
it.live(
|
||||||
"reports the shell ID for a running command",
|
"reports the shell ID for a running command",
|
||||||
() =>
|
() =>
|
||||||
@@ -515,7 +546,7 @@ describe("ShellTool", () => {
|
|||||||
const observed = yield* Deferred.make<string>()
|
const observed = yield* Deferred.make<string>()
|
||||||
yield* executeTool(registry, {
|
yield* executeTool(registry, {
|
||||||
...call(
|
...call(
|
||||||
{ command: progressOverflowCommand(ShellTool.MAX_CAPTURE_BYTES + 1024, release) },
|
{ command: progressOverflowCommand(ToolOutput.MAX_BYTES + 1024, release) },
|
||||||
"call-progress",
|
"call-progress",
|
||||||
),
|
),
|
||||||
progress: (update) =>
|
progress: (update) =>
|
||||||
|
|||||||
@@ -90,13 +90,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
|
|||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.provide(
|
Effect.provide(
|
||||||
AppNodeBuilder.build(
|
AppNodeBuilder.build(
|
||||||
LayerNode.group([
|
LayerNode.group([Tool.node, Tool.node, LocationMutation.node, FileMutation.node, writeToolNode]),
|
||||||
Tool.node,
|
|
||||||
Tool.node,
|
|
||||||
LocationMutation.node,
|
|
||||||
FileMutation.node,
|
|
||||||
writeToolNode,
|
|
||||||
]),
|
|
||||||
[
|
[
|
||||||
[FSUtil.node, filesystem],
|
[FSUtil.node, filesystem],
|
||||||
[Location.node, activeLocation],
|
[Location.node, activeLocation],
|
||||||
@@ -230,7 +224,10 @@ describe("WriteTool", () => {
|
|||||||
const deduplicated = path.join(tmp.path, "deduplicated.txt")
|
const deduplicated = path.join(tmp.path, "deduplicated.txt")
|
||||||
formatFile = (target) =>
|
formatFile = (target) =>
|
||||||
Effect.promise(async () => {
|
Effect.promise(async () => {
|
||||||
await fs.writeFile(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 true
|
||||||
})
|
})
|
||||||
return Effect.promise(() =>
|
return Effect.promise(() =>
|
||||||
@@ -323,24 +320,22 @@ describe("WriteTool", () => {
|
|||||||
).pipe(
|
).pipe(
|
||||||
Effect.andThen((settled) =>
|
Effect.andThen((settled) =>
|
||||||
Effect.gen(function* () {
|
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.map((input) => input.action)).toEqual(["external_directory", "edit"])
|
||||||
expect(assertions[0]).toMatchObject({
|
expect(assertions[0]).toMatchObject({
|
||||||
resources: [
|
resources: [path.join(outside.path, "*").replaceAll("\\", "/")],
|
||||||
path.join(yield* Effect.promise(() => fs.realpath(outside.path)), "*").replaceAll("\\", "/"),
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
expect(assertions[1]).toMatchObject({ resources: [canonicalTarget.replaceAll("\\", "/")], save: ["*"] })
|
expect(assertions[1]).toMatchObject({ resources: [absoluteTarget.replaceAll("\\", "/")], save: ["*"] })
|
||||||
expect(settled).toMatchObject({
|
expect(settled).toMatchObject({
|
||||||
status: "completed",
|
status: "completed",
|
||||||
output: {
|
output: {
|
||||||
target: canonicalTarget,
|
target: absoluteTarget,
|
||||||
resource: canonicalTarget.replaceAll("\\", "/"),
|
resource: absoluteTarget.replaceAll("\\", "/"),
|
||||||
existed: false,
|
existed: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("external")
|
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("external")
|
||||||
expect(writes).toEqual([canonicalTarget])
|
expect(writes).toEqual([absoluteTarget])
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -368,12 +363,10 @@ describe("WriteTool", () => {
|
|||||||
),
|
),
|
||||||
Effect.andThen(
|
Effect.andThen(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const canonicalRepo = yield* Effect.promise(() => fs.realpath(repo))
|
|
||||||
const canonicalNested = yield* Effect.promise(() => fs.realpath(nested))
|
|
||||||
expect(assertions[0]).toMatchObject({
|
expect(assertions[0]).toMatchObject({
|
||||||
action: "external_directory",
|
action: "external_directory",
|
||||||
resources: [path.join(canonicalNested, "*").replaceAll("\\", "/")],
|
resources: [path.join(nested, "*").replaceAll("\\", "/")],
|
||||||
save: [path.join(canonicalRepo, "*").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"
|
import { onMount } from "solid-js"
|
||||||
|
|
||||||
export function DesktopFirstLaunchOnboarding(props: { initialUrl: string; onLoaded: () => void }) {
|
export function DesktopFirstLaunchOnboarding(props: { initialUrl: string; onLoaded: () => void }) {
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const settings = useSettings()
|
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -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()),
|
[server.ready.promise, tabs.ready.promise, tabs.recentReady.promise].map((p) => p ?? Promise.resolve()),
|
||||||
)
|
)
|
||||||
const existingInstall = await window.api.isOldLayoutEligible()
|
const existingInstall = await window.api.isOldLayoutEligible()
|
||||||
settings.general.setOldLayoutEligible(existingInstall)
|
|
||||||
if (!server.isLocal()) return
|
if (!server.isLocal()) return
|
||||||
|
|
||||||
const pending = await window.api.isFirstLaunchOnboardingPending()
|
const pending = await window.api.isFirstLaunchOnboardingPending()
|
||||||
|
|||||||
@@ -522,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(
|
.add(
|
||||||
HttpApiEndpoint.get("session.instructions.entry.list", "/api/session/:sessionID/instructions/entries", {
|
HttpApiEndpoint.get("session.instructions.entry.list", "/api/session/:sessionID/instructions/entries", {
|
||||||
params: { sessionID: Session.ID },
|
params: { sessionID: Session.ID },
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export const Info = Schema.Struct({
|
|||||||
id,
|
id,
|
||||||
name: Name.make(id),
|
name: Name.make(id),
|
||||||
request: { settings: {}, headers: {}, body: {} },
|
request: { settings: {}, headers: {}, body: {} },
|
||||||
mode: "all",
|
mode: "primary",
|
||||||
hidden: false,
|
hidden: false,
|
||||||
permissions: [
|
permissions: [
|
||||||
{ action: "*", resource: "*", effect: "allow" },
|
{ action: "*", resource: "*", effect: "allow" },
|
||||||
|
|||||||
@@ -152,13 +152,15 @@ export const Forked = Event.durable({
|
|||||||
})
|
})
|
||||||
export type Forked = typeof Forked.Type
|
export type Forked = typeof Forked.Type
|
||||||
|
|
||||||
|
const InputRef = {
|
||||||
|
...Base,
|
||||||
|
inputID: SessionMessage.ID,
|
||||||
|
}
|
||||||
|
|
||||||
export const InputPromoted = Event.durable({
|
export const InputPromoted = Event.durable({
|
||||||
type: "session.input.promoted",
|
type: "session.input.promoted",
|
||||||
...options,
|
...options,
|
||||||
schema: {
|
schema: InputRef,
|
||||||
sessionID: SessionID,
|
|
||||||
inputID: SessionMessage.ID,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
export type InputPromoted = typeof InputPromoted.Type
|
export type InputPromoted = typeof InputPromoted.Type
|
||||||
|
|
||||||
@@ -166,13 +168,33 @@ export const InputAdmitted = Event.durable({
|
|||||||
type: "session.input.admitted",
|
type: "session.input.admitted",
|
||||||
...options,
|
...options,
|
||||||
schema: {
|
schema: {
|
||||||
...Base,
|
...InputRef,
|
||||||
inputID: SessionMessage.ID,
|
|
||||||
input: SessionPending.Message,
|
input: SessionPending.Message,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export type InputAdmitted = typeof InputAdmitted.Type
|
export type InputAdmitted = typeof InputAdmitted.Type
|
||||||
|
|
||||||
|
export const InputCancelled = Event.durable({
|
||||||
|
type: "session.input.cancelled",
|
||||||
|
...options,
|
||||||
|
schema: InputRef,
|
||||||
|
})
|
||||||
|
export type InputCancelled = typeof InputCancelled.Type
|
||||||
|
|
||||||
|
export const InputSteered = Event.durable({
|
||||||
|
type: "session.input.steered",
|
||||||
|
...options,
|
||||||
|
schema: InputRef,
|
||||||
|
})
|
||||||
|
export type InputSteered = typeof InputSteered.Type
|
||||||
|
|
||||||
|
export const InputQueued = Event.durable({
|
||||||
|
type: "session.input.queued",
|
||||||
|
...options,
|
||||||
|
schema: InputRef,
|
||||||
|
})
|
||||||
|
export type InputQueued = typeof InputQueued.Type
|
||||||
|
|
||||||
export namespace Execution {
|
export namespace Execution {
|
||||||
export const Started = Event.durable({ type: "session.execution.started", ...options, schema: Base })
|
export const Started = Event.durable({ type: "session.execution.started", ...options, schema: Base })
|
||||||
export type Started = typeof Started.Type
|
export type Started = typeof Started.Type
|
||||||
@@ -580,6 +602,9 @@ export const Definitions = Event.inventory(
|
|||||||
Forked,
|
Forked,
|
||||||
InputPromoted,
|
InputPromoted,
|
||||||
InputAdmitted,
|
InputAdmitted,
|
||||||
|
InputCancelled,
|
||||||
|
InputSteered,
|
||||||
|
InputQueued,
|
||||||
Execution.Started,
|
Execution.Started,
|
||||||
Execution.Succeeded,
|
Execution.Succeeded,
|
||||||
Execution.Failed,
|
Execution.Failed,
|
||||||
@@ -621,13 +646,16 @@ export const DurableDefinitions = Event.inventory(
|
|||||||
...Definitions.filter((definition) => definition.durability === "durable"),
|
...Definitions.filter((definition) => definition.durability === "durable"),
|
||||||
UsageRecorded,
|
UsageRecorded,
|
||||||
)
|
)
|
||||||
|
export const EphemeralDefinitions = Event.inventory(
|
||||||
|
...Definitions.filter((definition) => definition.durability === "ephemeral"),
|
||||||
|
)
|
||||||
|
|
||||||
export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" })
|
export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" })
|
||||||
.pipe(Schema.toTaggedUnion("type"))
|
.pipe(Schema.toTaggedUnion("type"))
|
||||||
.annotate({ identifier: "Session.Event.Durable" })
|
.annotate({ identifier: "Session.Event.Durable" })
|
||||||
export type DurableEvent = typeof Durable.Type
|
export type DurableEvent = typeof Durable.Type
|
||||||
|
|
||||||
export const All = Schema.Union(Event.inventory(...Definitions, UsageRecorded), { mode: "oneOf" }).pipe(
|
export const All = Schema.Union([Durable, ...EphemeralDefinitions], { mode: "oneOf" }).pipe(
|
||||||
Schema.toTaggedUnion("type"),
|
Schema.toTaggedUnion("type"),
|
||||||
)
|
)
|
||||||
export type Event = typeof All.Type
|
export type Event = typeof All.Type
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ describe("public event manifest", () => {
|
|||||||
"session.forked.2",
|
"session.forked.2",
|
||||||
"session.input.promoted.1",
|
"session.input.promoted.1",
|
||||||
"session.input.admitted.1",
|
"session.input.admitted.1",
|
||||||
|
"session.input.cancelled.1",
|
||||||
|
"session.input.steered.1",
|
||||||
|
"session.input.queued.1",
|
||||||
"session.execution.started.1",
|
"session.execution.started.1",
|
||||||
"session.execution.succeeded.1",
|
"session.execution.succeeded.1",
|
||||||
"session.execution.failed.1",
|
"session.execution.failed.1",
|
||||||
|
|||||||
@@ -26,6 +26,22 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* Session.Service
|
const session = yield* Session.Service
|
||||||
const transfer = yield* SessionTransfer.Service
|
const transfer = yield* SessionTransfer.Service
|
||||||
|
const pendingMutation = (effect: ReturnType<typeof session.cancelPending>, conflict: string) =>
|
||||||
|
effect.pipe(
|
||||||
|
Effect.catchTag(
|
||||||
|
"Session.NotFoundError",
|
||||||
|
(error) =>
|
||||||
|
new SessionNotFoundError({
|
||||||
|
sessionID: error.sessionID,
|
||||||
|
message: `Session not found: ${error.sessionID}`,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Effect.catchTag(
|
||||||
|
"Session.PendingInputConflictError",
|
||||||
|
(error) => new ConflictError({ resource: error.inputID, message: `${conflict}: ${error.inputID}` }),
|
||||||
|
),
|
||||||
|
Effect.as(HttpApiSchema.NoContent.make()),
|
||||||
|
)
|
||||||
|
|
||||||
return handlers
|
return handlers
|
||||||
.handle(
|
.handle(
|
||||||
@@ -661,6 +677,33 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
.handle(
|
||||||
|
"session.pending.cancel",
|
||||||
|
Effect.fn(function* (ctx) {
|
||||||
|
return yield* pendingMutation(
|
||||||
|
session.cancelPending({ sessionID: ctx.params.sessionID, inputID: ctx.params.inputID }),
|
||||||
|
"Pending input can no longer be cancelled",
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handle(
|
||||||
|
"session.pending.steer",
|
||||||
|
Effect.fn(function* (ctx) {
|
||||||
|
return yield* pendingMutation(
|
||||||
|
session.steerPending({ sessionID: ctx.params.sessionID, inputID: ctx.params.inputID }),
|
||||||
|
"Pending input is no longer queued",
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handle(
|
||||||
|
"session.pending.queue",
|
||||||
|
Effect.fn(function* (ctx) {
|
||||||
|
return yield* pendingMutation(
|
||||||
|
session.queuePending({ sessionID: ctx.params.sessionID, inputID: ctx.params.inputID }),
|
||||||
|
"Pending input is no longer a steer",
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
.handle(
|
.handle(
|
||||||
"session.instructions.entry.list",
|
"session.instructions.entry.list",
|
||||||
Effect.fn(function* (ctx) {
|
Effect.fn(function* (ctx) {
|
||||||
|
|||||||
@@ -462,6 +462,7 @@ function newLayout() {
|
|||||||
function webSearchProviderLabel(provider: unknown) {
|
function webSearchProviderLabel(provider: unknown) {
|
||||||
if (provider === "parallel") return "Parallel Web Search"
|
if (provider === "parallel") return "Parallel Web Search"
|
||||||
if (provider === "exa") return "Exa Web Search"
|
if (provider === "exa") return "Exa Web Search"
|
||||||
|
if (provider === "firecrawl") return "Firecrawl Web Search"
|
||||||
return "Web Search"
|
return "Web Search"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,12 +53,14 @@ import { useLocation } from "../../context/location"
|
|||||||
import { Keymap, type KeymapCommand } from "../../context/keymap"
|
import { Keymap, type KeymapCommand } from "../../context/keymap"
|
||||||
import { abbreviateHome } from "../../runtime"
|
import { abbreviateHome } from "../../runtime"
|
||||||
import { PluginSlot } from "../../plugin/render"
|
import { PluginSlot } from "../../plugin/render"
|
||||||
|
import type { SessionPending } from "@opencode-ai/schema/session-pending"
|
||||||
|
|
||||||
export type PromptProps = {
|
export type PromptProps = {
|
||||||
sessionID?: string
|
sessionID?: string
|
||||||
visible?: boolean
|
visible?: boolean
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
onSubmit?: () => void
|
onSubmit?: () => void
|
||||||
|
onEmptySubmit?: () => boolean | Promise<boolean>
|
||||||
ref?: (ref: PromptRef | undefined) => void
|
ref?: (ref: PromptRef | undefined) => void
|
||||||
hint?: JSX.Element
|
hint?: JSX.Element
|
||||||
right?: JSX.Element
|
right?: JSX.Element
|
||||||
@@ -357,6 +359,20 @@ export function Prompt(props: PromptProps) {
|
|||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Queue prompt",
|
||||||
|
name: "prompt.queue",
|
||||||
|
category: "Prompt",
|
||||||
|
palette: undefined,
|
||||||
|
run: async (_input: string | undefined, event?: KeyEvent) => {
|
||||||
|
event?.preventDefault()
|
||||||
|
event?.stopPropagation()
|
||||||
|
if (!input.focused) return
|
||||||
|
const handled = await submit("queue")
|
||||||
|
if (!handled) return
|
||||||
|
dialog.clear()
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Remove editor context",
|
title: "Remove editor context",
|
||||||
name: "prompt.editor_context.clear",
|
name: "prompt.editor_context.clear",
|
||||||
@@ -515,6 +531,11 @@ export function Prompt(props: PromptProps) {
|
|||||||
commands: promptCommands(),
|
commands: promptCommands(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
Keymap.createLayer(() => ({
|
||||||
|
priority: 1,
|
||||||
|
bindings: ["prompt.queue"],
|
||||||
|
}))
|
||||||
|
|
||||||
Keymap.createLayer(() => ({
|
Keymap.createLayer(() => ({
|
||||||
bindings: [
|
bindings: [
|
||||||
"prompt.submit",
|
"prompt.submit",
|
||||||
@@ -900,7 +921,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
let submitting = false
|
let submitting = false
|
||||||
async function submit() {
|
async function submit(delivery: SessionPending.Delivery = "steer") {
|
||||||
// Prevent overlapping invocations (e.g. a double-pressed Enter, or the
|
// Prevent overlapping invocations (e.g. a double-pressed Enter, or the
|
||||||
// input's native onSubmit racing another dispatch). Without this guard,
|
// input's native onSubmit racing another dispatch). Without this guard,
|
||||||
// a second call slips past the empty-input check before the first call
|
// a second call slips past the empty-input check before the first call
|
||||||
@@ -910,13 +931,13 @@ export function Prompt(props: PromptProps) {
|
|||||||
if (submitting) return false
|
if (submitting) return false
|
||||||
submitting = true
|
submitting = true
|
||||||
try {
|
try {
|
||||||
return await submitInner()
|
return await submitInner(delivery)
|
||||||
} finally {
|
} finally {
|
||||||
submitting = false
|
submitting = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitInner() {
|
async function submitInner(delivery: SessionPending.Delivery) {
|
||||||
// IME: double-defer may fire before onContentChange flushes the last
|
// IME: double-defer may fire before onContentChange flushes the last
|
||||||
// composed character (e.g. Korean hangul) to the store, so read
|
// composed character (e.g. Korean hangul) to the store, so read
|
||||||
// plainText directly and sync before any downstream reads.
|
// plainText directly and sync before any downstream reads.
|
||||||
@@ -927,14 +948,25 @@ export function Prompt(props: PromptProps) {
|
|||||||
if (props.disabled) return false
|
if (props.disabled) return false
|
||||||
if (move.creating()) return false
|
if (move.creating()) return false
|
||||||
if (auto()?.visible) return false
|
if (auto()?.visible) return false
|
||||||
if (!store.prompt.text) return false
|
|
||||||
const trimmed = store.prompt.text.trim()
|
const trimmed = store.prompt.text.trim()
|
||||||
|
if (!trimmed) return delivery === "steer" ? (await props.onEmptySubmit?.()) === true : false
|
||||||
|
if (
|
||||||
|
delivery === "queue" &&
|
||||||
|
(store.mode === "shell" || trimmed === "exit" || trimmed === "quit" || trimmed === ":q")
|
||||||
|
) {
|
||||||
|
toast.show({ message: "This prompt cannot be queued", variant: "warning" })
|
||||||
|
return false
|
||||||
|
}
|
||||||
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
|
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
|
||||||
void exit()
|
void exit()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
const slash = argumentSlash(store.prompt.text, keymapCommands())
|
const slash = argumentSlash(store.prompt.text, keymapCommands())
|
||||||
if (slash) {
|
if (slash) {
|
||||||
|
if (delivery === "queue") {
|
||||||
|
toast.show({ message: "This prompt cannot be queued", variant: "warning" })
|
||||||
|
return false
|
||||||
|
}
|
||||||
clearPrompt()
|
clearPrompt()
|
||||||
await slash.command.run(slash.input)
|
await slash.command.run(slash.input)
|
||||||
return true
|
return true
|
||||||
@@ -958,6 +990,16 @@ export function Prompt(props: PromptProps) {
|
|||||||
const isCommand =
|
const isCommand =
|
||||||
slashHead !== undefined &&
|
slashHead !== undefined &&
|
||||||
(data.location.command.list(currentLocation.ref) ?? []).some((command) => command.name === slashHead.name)
|
(data.location.command.list(currentLocation.ref) ?? []).some((command) => command.name === slashHead.name)
|
||||||
|
if (delivery === "queue" && isSkill) {
|
||||||
|
toast.show({ message: "Skills cannot be queued", variant: "warning" })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const editorSelection = editorContext()
|
||||||
|
const pendingEditorSelection = editorSelection && editor.labelState() === "pending" ? editorSelection : undefined
|
||||||
|
if (delivery === "queue" && pendingEditorSelection) {
|
||||||
|
toast.show({ message: "Editor context cannot be queued", variant: "warning" })
|
||||||
|
return false
|
||||||
|
}
|
||||||
const agent = local.agent.current()
|
const agent = local.agent.current()
|
||||||
if (!agent) return false
|
if (!agent) return false
|
||||||
const selection = local.model.selection()
|
const selection = local.model.selection()
|
||||||
@@ -1016,8 +1058,6 @@ export function Prompt(props: PromptProps) {
|
|||||||
|
|
||||||
// Capture mode before it gets reset
|
// Capture mode before it gets reset
|
||||||
const currentMode = store.mode
|
const currentMode = store.mode
|
||||||
const editorSelection = editorContext()
|
|
||||||
const pendingEditorSelection = editorSelection && editor.labelState() === "pending" ? editorSelection : undefined
|
|
||||||
|
|
||||||
if (store.mode === "shell") {
|
if (store.mode === "shell") {
|
||||||
move.startSubmit()
|
move.startSubmit()
|
||||||
@@ -1040,6 +1080,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
model,
|
model,
|
||||||
files: store.prompt.files,
|
files: store.prompt.files,
|
||||||
agents: store.prompt.agents,
|
agents: store.prompt.agents,
|
||||||
|
delivery,
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
cancelCommit()
|
cancelCommit()
|
||||||
@@ -1049,7 +1090,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
move.startSubmit()
|
move.startSubmit()
|
||||||
void client.api.session.skill({
|
void client.api.session.skill({
|
||||||
sessionID,
|
sessionID,
|
||||||
skill: slashHead!.name,
|
skill: slashHead.name,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
move.startSubmit()
|
move.startSubmit()
|
||||||
@@ -1105,6 +1146,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
text: inputText,
|
text: inputText,
|
||||||
files: store.prompt.files,
|
files: store.prompt.files,
|
||||||
agents: store.prompt.agents,
|
agents: store.prompt.agents,
|
||||||
|
delivery,
|
||||||
})
|
})
|
||||||
.then(
|
.then(
|
||||||
() => undefined,
|
() => undefined,
|
||||||
|
|||||||
@@ -103,7 +103,8 @@ export const Definitions = {
|
|||||||
session_interrupt: keybind("escape", "Interrupt current session"),
|
session_interrupt: keybind("escape", "Interrupt current session"),
|
||||||
session_background: keybind("ctrl+b", "Background blocking session tools"),
|
session_background: keybind("ctrl+b", "Background blocking session tools"),
|
||||||
session_compact: keybind("<leader>c", "Compact the session"),
|
session_compact: keybind("<leader>c", "Compact the session"),
|
||||||
session_queued_prompts: keybind("<leader>q", "View pending work"),
|
session_queued_prompts: keybind("<leader>q", "View queued prompts"),
|
||||||
|
queued_prompt_delete: keybind("ctrl+d", "Delete queued prompt"),
|
||||||
session_child_first: keybind("down", "Toggle subagent picker"),
|
session_child_first: keybind("down", "Toggle subagent picker"),
|
||||||
session_parent: keybind("up", "Go to parent session"),
|
session_parent: keybind("up", "Go to parent session"),
|
||||||
session_pin_toggle: keybind("ctrl+f", "Pin or unpin session in the session list"),
|
session_pin_toggle: keybind("ctrl+f", "Pin or unpin session in the session list"),
|
||||||
@@ -161,6 +162,7 @@ export const Definitions = {
|
|||||||
display_thinking: keybind("none", "Toggle thinking blocks visibility"),
|
display_thinking: keybind("none", "Toggle thinking blocks visibility"),
|
||||||
|
|
||||||
prompt_submit: keybind("none", "Submit prompt"),
|
prompt_submit: keybind("none", "Submit prompt"),
|
||||||
|
prompt_queue: keybind("alt+return", "Queue prompt"),
|
||||||
prompt_editor_context_clear: keybind("none", "Clear editor context"),
|
prompt_editor_context_clear: keybind("none", "Clear editor context"),
|
||||||
prompt_skills: keybind("none", "Open skill selector"),
|
prompt_skills: keybind("none", "Open skill selector"),
|
||||||
prompt_stash: keybind("none", "Stash prompt"),
|
prompt_stash: keybind("none", "Stash prompt"),
|
||||||
@@ -170,7 +172,7 @@ export const Definitions = {
|
|||||||
input_clear: keybind("ctrl+c", "Clear input field"),
|
input_clear: keybind("ctrl+c", "Clear input field"),
|
||||||
input_paste: keybind({ key: "ctrl+v", preventDefault: false }, "Paste from clipboard"),
|
input_paste: keybind({ key: "ctrl+v", preventDefault: false }, "Paste from clipboard"),
|
||||||
input_submit: keybind("return", "Submit input"),
|
input_submit: keybind("return", "Submit input"),
|
||||||
input_newline: keybind("shift+return,ctrl+return,alt+return,ctrl+j", "Insert newline in input"),
|
input_newline: keybind("shift+return,ctrl+return,ctrl+j", "Insert newline in input"),
|
||||||
input_move_left: keybind("left,ctrl+b", "Move cursor left in input"),
|
input_move_left: keybind("left,ctrl+b", "Move cursor left in input"),
|
||||||
input_move_right: keybind("right,ctrl+f", "Move cursor right in input"),
|
input_move_right: keybind("right,ctrl+f", "Move cursor right in input"),
|
||||||
input_move_up: keybind("up", "Move cursor up in input"),
|
input_move_up: keybind("up", "Move cursor up in input"),
|
||||||
@@ -305,6 +307,7 @@ export const CommandMap = {
|
|||||||
session_background: "session.background",
|
session_background: "session.background",
|
||||||
session_compact: "session.compact",
|
session_compact: "session.compact",
|
||||||
session_queued_prompts: "session.queued_prompts",
|
session_queued_prompts: "session.queued_prompts",
|
||||||
|
queued_prompt_delete: "queued_prompt.delete",
|
||||||
session_child_first: "session.child.first",
|
session_child_first: "session.child.first",
|
||||||
session_parent: "session.parent",
|
session_parent: "session.parent",
|
||||||
session_pin_toggle: "session.pin.toggle",
|
session_pin_toggle: "session.pin.toggle",
|
||||||
@@ -359,6 +362,7 @@ export const CommandMap = {
|
|||||||
messages_redo: "session.redo",
|
messages_redo: "session.redo",
|
||||||
display_thinking: "session.toggle.thinking",
|
display_thinking: "session.toggle.thinking",
|
||||||
prompt_submit: "prompt.submit",
|
prompt_submit: "prompt.submit",
|
||||||
|
prompt_queue: "prompt.queue",
|
||||||
prompt_editor_context_clear: "prompt.editor_context.clear",
|
prompt_editor_context_clear: "prompt.editor_context.clear",
|
||||||
prompt_skills: "prompt.skills",
|
prompt_skills: "prompt.skills",
|
||||||
prompt_stash: "prompt.stash",
|
prompt_stash: "prompt.stash",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
ModelInfo,
|
ModelInfo,
|
||||||
PermissionSavedInfo,
|
PermissionSavedInfo,
|
||||||
PermissionRequest,
|
PermissionRequest,
|
||||||
|
PermissionReplyInput,
|
||||||
Project,
|
Project,
|
||||||
ProviderInfo,
|
ProviderInfo,
|
||||||
ReferenceInfo,
|
ReferenceInfo,
|
||||||
@@ -31,11 +32,13 @@ import type {
|
|||||||
OpenCodeEvent,
|
OpenCodeEvent,
|
||||||
WebSearchProvider,
|
WebSearchProvider,
|
||||||
} from "@opencode-ai/client"
|
} from "@opencode-ai/client"
|
||||||
|
import { isPermissionNotFoundError } from "@opencode-ai/client"
|
||||||
import type { Plugin } from "@opencode-ai/plugin/tui"
|
import type { Plugin } from "@opencode-ai/plugin/tui"
|
||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
import { createSimpleContext } from "./helper"
|
import { createSimpleContext } from "./helper"
|
||||||
import { useClient } from "./client"
|
import { useClient } from "./client"
|
||||||
import { nonEmptyToolContent } from "../util/tool-display"
|
import { nonEmptyToolContent } from "../util/tool-display"
|
||||||
|
import type { SessionPending } from "@opencode-ai/schema/session-pending"
|
||||||
import { createEffect, createSignal, onCleanup } from "solid-js"
|
import { createEffect, createSignal, onCleanup } from "solid-js"
|
||||||
|
|
||||||
export type DataSessionStatus = "idle" | "running"
|
export type DataSessionStatus = "idle" | "running"
|
||||||
@@ -168,12 +171,38 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
|
|
||||||
function removePending(sessionID: string, inputID?: string) {
|
function removePending(sessionID: string, inputID?: string) {
|
||||||
if (!inputID) return
|
if (!inputID) return
|
||||||
|
if (store.session.pending[sessionID]?.some((item) => item.id === inputID))
|
||||||
setStore(
|
setStore(
|
||||||
"session",
|
"session",
|
||||||
"pending",
|
"pending",
|
||||||
sessionID,
|
sessionID,
|
||||||
(store.session.pending[sessionID] ?? []).filter((item) => item.id !== inputID),
|
(store.session.pending[sessionID] ?? []).filter((item) => item.id !== inputID),
|
||||||
)
|
)
|
||||||
|
if (store.session.input[sessionID]?.includes(inputID))
|
||||||
|
setStore(
|
||||||
|
"session",
|
||||||
|
"input",
|
||||||
|
sessionID,
|
||||||
|
(store.session.input[sessionID] ?? []).filter((id) => id !== inputID),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePermission(sessionID: string, requestID: string) {
|
||||||
|
const requests = store.session.permission[sessionID]
|
||||||
|
if (!requests?.some((request) => request.id === requestID)) return
|
||||||
|
setStore(
|
||||||
|
"session",
|
||||||
|
"permission",
|
||||||
|
sessionID,
|
||||||
|
requests.filter((request) => request.id !== requestID),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePending(sessionID: string, inputID: string, delivery: SessionPending.Delivery) {
|
||||||
|
const index = store.session.pending[sessionID]?.findIndex((item) => item.id === inputID) ?? -1
|
||||||
|
const item = store.session.pending[sessionID]?.[index]
|
||||||
|
if (index < 0 || !item || item.type === "compaction" || item.delivery === delivery) return
|
||||||
|
setStore("session", "pending", sessionID, index, { ...item, delivery })
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = {
|
const message = {
|
||||||
@@ -222,6 +251,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && !item.time?.completed,
|
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && !item.time?.completed,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
reindex(messages: SessionMessageInfo[], index: Map<string, number>, start: number) {
|
||||||
|
for (let position = start; position < messages.length; position++) {
|
||||||
|
const item = messages[position]
|
||||||
|
if (item) index.set(item.id, position)
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
function index(sessionID: string) {
|
function index(sessionID: string) {
|
||||||
@@ -403,24 +438,36 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
break
|
break
|
||||||
case "session.input.promoted": {
|
case "session.input.promoted": {
|
||||||
|
const admitted = store.session.input[event.data.sessionID]?.includes(event.data.inputID) ?? false
|
||||||
removePending(event.data.sessionID, event.data.inputID)
|
removePending(event.data.sessionID, event.data.inputID)
|
||||||
message.update(event.data.sessionID, (draft, index) => {
|
message.update(event.data.sessionID, (draft, index) => {
|
||||||
const position = index.get(event.data.inputID)
|
const position = index.get(event.data.inputID)
|
||||||
if (position === undefined) return
|
if (position === undefined) return
|
||||||
const existing = draft[position]
|
const existing = draft[position]
|
||||||
if (!existing || !store.session.input[event.data.sessionID]?.includes(event.data.inputID)) return
|
if (!existing || !admitted) return
|
||||||
existing.time.created = event.created
|
existing.time.created = event.created
|
||||||
draft.splice(position, 1)
|
draft.splice(position, 1)
|
||||||
draft.push(existing)
|
draft.push(existing)
|
||||||
index.clear()
|
message.reindex(draft, index, position)
|
||||||
draft.forEach((message, indexValue) => index.set(message.id, indexValue))
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "session.input.steered":
|
||||||
|
updatePending(event.data.sessionID, event.data.inputID, "steer")
|
||||||
|
break
|
||||||
|
case "session.input.queued":
|
||||||
|
updatePending(event.data.sessionID, event.data.inputID, "queue")
|
||||||
|
break
|
||||||
|
case "session.input.cancelled": {
|
||||||
|
removePending(event.data.sessionID, event.data.inputID)
|
||||||
|
if (messageIndex.get(event.data.sessionID)?.has(event.data.inputID))
|
||||||
|
message.update(event.data.sessionID, (draft, index) => {
|
||||||
|
const position = index.get(event.data.inputID)
|
||||||
|
if (position === undefined) return
|
||||||
|
draft.splice(position, 1)
|
||||||
|
index.delete(event.data.inputID)
|
||||||
|
message.reindex(draft, index, position)
|
||||||
})
|
})
|
||||||
setStore(
|
|
||||||
"session",
|
|
||||||
"input",
|
|
||||||
event.data.sessionID,
|
|
||||||
(store.session.input[event.data.sessionID] ?? []).filter((id) => id !== event.data.inputID),
|
|
||||||
)
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "session.input.admitted":
|
case "session.input.admitted":
|
||||||
@@ -840,14 +887,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
])
|
])
|
||||||
break
|
break
|
||||||
case "permission.replied":
|
case "permission.replied":
|
||||||
setStore(
|
removePermission(event.data.sessionID, event.data.requestID)
|
||||||
"session",
|
|
||||||
"permission",
|
|
||||||
event.data.sessionID,
|
|
||||||
(store.session.permission[event.data.sessionID] ?? []).filter(
|
|
||||||
(request) => request.id !== event.data.requestID,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
break
|
break
|
||||||
case "form.created":
|
case "form.created":
|
||||||
if (store.session.form[event.data.form.sessionID]?.some((form) => form.id === event.data.form.id)) break
|
if (store.session.form[event.data.form.sessionID]?.some((form) => form.id === event.data.form.id)) break
|
||||||
@@ -1036,6 +1076,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
invalidate(sessionID: string) {
|
invalidate(sessionID: string) {
|
||||||
sync.invalidate(`session.permission:${sessionID}`)
|
sync.invalidate(`session.permission:${sessionID}`)
|
||||||
},
|
},
|
||||||
|
async reply(input: PermissionReplyInput) {
|
||||||
|
await client.api.permission.reply(input).catch((error: unknown) => {
|
||||||
|
if (!isPermissionNotFoundError(error)) throw error
|
||||||
|
})
|
||||||
|
removePermission(input.sessionID, input.requestID)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
form: {
|
form: {
|
||||||
list(sessionID: string, ref?: LocationRef) {
|
list(sessionID: string, ref?: LocationRef) {
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { TextAttributes, type InputRenderable, type KeyEvent } from "@opentui/co
|
|||||||
import { useKeyboard, type JSX } from "@opentui/solid"
|
import { useKeyboard, type JSX } from "@opentui/solid"
|
||||||
import fuzzysort from "fuzzysort"
|
import fuzzysort from "fuzzysort"
|
||||||
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
|
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
|
||||||
|
import { Keymap } from "../context/keymap"
|
||||||
import { RunFooterMenu, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
|
import { RunFooterMenu, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
|
||||||
|
import { monoShortcut } from "./mono"
|
||||||
import type { RunFooterTheme } from "./theme"
|
import type { RunFooterTheme } from "./theme"
|
||||||
import type {
|
import type {
|
||||||
FooterQueuedPrompt,
|
FooterQueuedPrompt,
|
||||||
@@ -56,6 +58,10 @@ type SkillEntry = PanelEntry & {
|
|||||||
name: string
|
name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type QueuedPromptEntry = PanelEntry & {
|
||||||
|
prompt: FooterQueuedPrompt
|
||||||
|
}
|
||||||
|
|
||||||
type SubagentEntry = PanelEntry & {
|
type SubagentEntry = PanelEntry & {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
current: boolean
|
current: boolean
|
||||||
@@ -442,7 +448,7 @@ export function RunCommandMenuBody(props: {
|
|||||||
{
|
{
|
||||||
action: "queued" as const,
|
action: "queued" as const,
|
||||||
category: "Agent",
|
category: "Agent",
|
||||||
display: "View pending work",
|
display: "View queued prompts",
|
||||||
footer: `${props.queued().length} pending`,
|
footer: `${props.queued().length} pending`,
|
||||||
keywords: props
|
keywords: props
|
||||||
.queued()
|
.queued()
|
||||||
@@ -837,28 +843,48 @@ export function RunQueuedPromptSelectBody(props: {
|
|||||||
theme: Accessor<RunFooterTheme>
|
theme: Accessor<RunFooterTheme>
|
||||||
prompts: Accessor<FooterQueuedPrompt[]>
|
prompts: Accessor<FooterQueuedPrompt[]>
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
|
onSteer: (prompt: FooterQueuedPrompt) => void
|
||||||
|
onDelete: (prompt: FooterQueuedPrompt) => void
|
||||||
onRows?: (rows: number) => void
|
onRows?: (rows: number) => void
|
||||||
mono?: boolean
|
mono?: boolean
|
||||||
}) {
|
}) {
|
||||||
const entries = createMemo(() =>
|
const entries = createMemo<QueuedPromptEntry[]>(() =>
|
||||||
props.prompts().map((prompt) => ({
|
props.prompts().map((prompt) => ({
|
||||||
category: "",
|
category: "",
|
||||||
display: prompt.prompt.text.replaceAll("\n", " "),
|
display: prompt.prompt.text.replaceAll("\n", " "),
|
||||||
footer: prompt.delivery,
|
footer: "queued",
|
||||||
keywords: prompt.prompt.text,
|
keywords: prompt.prompt.text,
|
||||||
|
prompt,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
const controller = createSearchablePanelController({
|
const controller = createSearchablePanelController({
|
||||||
entries,
|
entries,
|
||||||
limit: SUBAGENT_LIST_ROWS,
|
limit: SUBAGENT_LIST_ROWS,
|
||||||
onClose: props.onClose,
|
onClose: props.onClose,
|
||||||
onSelect: props.onClose,
|
onSelect: (item) => props.onSteer(item.prompt),
|
||||||
onRows: props.onRows,
|
onRows: props.onRows,
|
||||||
})
|
})
|
||||||
|
const shortcuts = Keymap.useShortcuts()
|
||||||
|
const deleteShortcut = () => monoShortcut(shortcuts.get("queued_prompt.delete") ?? "", props.mono ?? false)
|
||||||
|
Keymap.createLayer(() => ({
|
||||||
|
priority: 1,
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
id: "queued_prompt.delete",
|
||||||
|
title: "Delete queued prompt",
|
||||||
|
group: "Prompt",
|
||||||
|
run() {
|
||||||
|
const item = controller.items()[controller.menu.selected()]
|
||||||
|
if (!item) return false
|
||||||
|
props.onDelete(item.prompt)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelShell
|
<PanelShell
|
||||||
title="Pending work"
|
title="Queued prompts"
|
||||||
query={controller.query()}
|
query={controller.query()}
|
||||||
count={controller.items().length}
|
count={controller.items().length}
|
||||||
total={entries().length}
|
total={entries().length}
|
||||||
@@ -866,6 +892,7 @@ export function RunQueuedPromptSelectBody(props: {
|
|||||||
theme={props.theme}
|
theme={props.theme}
|
||||||
inputRef={controller.inputRef}
|
inputRef={controller.inputRef}
|
||||||
onQuery={controller.setQuery}
|
onQuery={controller.setQuery}
|
||||||
|
hint={["enter steer", deleteShortcut() ? `${deleteShortcut()} delete` : undefined].filter(Boolean).join(" · ")}
|
||||||
mono={props.mono}
|
mono={props.mono}
|
||||||
>
|
>
|
||||||
<RunFooterMenu
|
<RunFooterMenu
|
||||||
@@ -875,7 +902,7 @@ export function RunQueuedPromptSelectBody(props: {
|
|||||||
offset={controller.menu.offset}
|
offset={controller.menu.offset}
|
||||||
rows={controller.menu.rows}
|
rows={controller.menu.rows}
|
||||||
limit={SUBAGENT_LIST_ROWS}
|
limit={SUBAGENT_LIST_ROWS}
|
||||||
empty="No pending work"
|
empty="No queued prompts"
|
||||||
border={false}
|
border={false}
|
||||||
paddingLeft={panelPad(props.mono)}
|
paddingLeft={panelPad(props.mono)}
|
||||||
paddingRight={panelPad(props.mono)}
|
paddingRight={panelPad(props.mono)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/** @jsxImportSource @opentui/solid */
|
/** @jsxImportSource @opentui/solid */
|
||||||
import type { TextareaRenderable } from "@opentui/core"
|
import { decodePasteBytes, stripAnsiSequences, type TextareaRenderable } from "@opentui/core"
|
||||||
import { useKeyboard } from "@opentui/solid"
|
import { useKeyboard, usePaste } from "@opentui/solid"
|
||||||
import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||||
import {
|
import {
|
||||||
createFormBodyState,
|
createFormBodyState,
|
||||||
@@ -149,6 +149,14 @@ export function RunFormBody(props: {
|
|||||||
if (formSingle(props.request)) submit(next)
|
if (formSingle(props.request)) submit(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
usePaste((event) => {
|
||||||
|
const field = current()
|
||||||
|
if (!field || textual() || !custom() || confirm()) return
|
||||||
|
event.preventDefault()
|
||||||
|
const next = formPick(formSetSelected(state(), rows().length), props.request)
|
||||||
|
setState(formSetDraft(next, field, formInput(next, field) + stripAnsiSequences(decodePasteBytes(event.bytes))))
|
||||||
|
})
|
||||||
|
|
||||||
const moveField = (direction: -1 | 1) => {
|
const moveField = (direction: -1 | 1) => {
|
||||||
const next = (state().field + direction + props.request.fields.length + 1) % (props.request.fields.length + 1)
|
const next = (state().field + direction + props.request.fields.length + 1) % (props.request.fields.length + 1)
|
||||||
if (direction < 0 || confirm()) {
|
if (direction < 0 || confirm()) {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
displayCharAt,
|
displayCharAt,
|
||||||
displaySlice,
|
displaySlice,
|
||||||
isExitCommand,
|
isExitCommand,
|
||||||
|
isCompactCommand,
|
||||||
mentionTriggerIndex,
|
mentionTriggerIndex,
|
||||||
isNewCommand,
|
isNewCommand,
|
||||||
movePromptHistory,
|
movePromptHistory,
|
||||||
@@ -31,7 +32,16 @@ import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.edit
|
|||||||
import { monoTruncateMiddle } from "./mono"
|
import { monoTruncateMiddle } from "./mono"
|
||||||
import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
|
import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
|
||||||
import type { RunFooterTheme } from "./theme"
|
import type { RunFooterTheme } from "./theme"
|
||||||
import type { FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunReference } from "./types"
|
import type {
|
||||||
|
FooterQueuedPrompt,
|
||||||
|
FooterState,
|
||||||
|
RunAgent,
|
||||||
|
RunCommand,
|
||||||
|
RunDelivery,
|
||||||
|
RunPrompt,
|
||||||
|
RunPromptPart,
|
||||||
|
RunReference,
|
||||||
|
} from "./types"
|
||||||
|
|
||||||
const AUTOCOMPLETE_ROWS = FOOTER_MENU_ROWS
|
const AUTOCOMPLETE_ROWS = FOOTER_MENU_ROWS
|
||||||
const AUTOCOMPLETE_BOTTOM_ROWS = 1
|
const AUTOCOMPLETE_BOTTOM_ROWS = 1
|
||||||
@@ -72,6 +82,8 @@ type PromptInput = {
|
|||||||
theme: Accessor<RunFooterTheme>
|
theme: Accessor<RunFooterTheme>
|
||||||
mono: Accessor<boolean>
|
mono: Accessor<boolean>
|
||||||
history?: Accessor<RunPrompt[]>
|
history?: Accessor<RunPrompt[]>
|
||||||
|
queuedPrompts: Accessor<FooterQueuedPrompt[]>
|
||||||
|
onQueuedPromptSteer: (inputID: string) => Promise<boolean>
|
||||||
onSubmit: (input: RunPrompt) => boolean | Promise<boolean>
|
onSubmit: (input: RunPrompt) => boolean | Promise<boolean>
|
||||||
onCycle: () => void
|
onCycle: () => void
|
||||||
onInterrupt: () => boolean
|
onInterrupt: () => boolean
|
||||||
@@ -980,8 +992,18 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
Keymap.createLayer(() => ({
|
Keymap.createLayer(() => ({
|
||||||
|
priority: 1,
|
||||||
enabled: input.prompt() && !visible(),
|
enabled: input.prompt() && !visible(),
|
||||||
commands: [
|
commands: [
|
||||||
|
{
|
||||||
|
id: "prompt.queue",
|
||||||
|
title: "Queue prompt",
|
||||||
|
group: "Prompt",
|
||||||
|
run() {
|
||||||
|
syncDraft()
|
||||||
|
submitPrompt(promptCopy(draft), "queue")
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "prompt.editor",
|
id: "prompt.editor",
|
||||||
title: "Open editor",
|
title: "Open editor",
|
||||||
@@ -1116,7 +1138,8 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitPrompt = (next: RunPrompt) => {
|
let submitting = false
|
||||||
|
const submitPrompt = (next: RunPrompt, delivery: RunDelivery = "steer") => {
|
||||||
if (!area || area.isDestroyed) {
|
if (!area || area.isDestroyed) {
|
||||||
draft = promptCopy(next)
|
draft = promptCopy(next)
|
||||||
}
|
}
|
||||||
@@ -1130,12 +1153,34 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
hide()
|
hide()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (submitting) return
|
||||||
|
|
||||||
if (!next.text.trim()) {
|
if (!next.text.trim()) {
|
||||||
|
const queued = delivery === "steer" ? input.queuedPrompts()[0] : undefined
|
||||||
|
if (queued) {
|
||||||
|
submitting = true
|
||||||
|
void input.onQueuedPromptSteer(queued.messageID).finally(() => {
|
||||||
|
submitting = false
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
input.onStatus(input.state().phase === "running" ? "waiting for current response" : "empty prompt ignored")
|
input.onStatus(input.state().phase === "running" ? "waiting for current response" : "empty prompt ignored")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const command = next.mode === "shell" ? undefined : selectedCommand(next.text, next.command)
|
const command = next.mode === "shell" ? undefined : selectedCommand(next.text, next.command)
|
||||||
|
if (
|
||||||
|
delivery === "queue" &&
|
||||||
|
(next.mode === "shell" ||
|
||||||
|
command?.source === "skill" ||
|
||||||
|
isNewCommand(next.text) ||
|
||||||
|
isCompactCommand(next.text) ||
|
||||||
|
isExitCommand(next.text) ||
|
||||||
|
next.text.trim().toLowerCase() === "/settings")
|
||||||
|
) {
|
||||||
|
input.onStatus("this prompt cannot be queued")
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!command && next.mode !== "shell" && isExitCommand(next.text)) {
|
if (!command && next.mode !== "shell" && isExitCommand(next.text)) {
|
||||||
input.onExit()
|
input.onExit()
|
||||||
return
|
return
|
||||||
@@ -1157,14 +1202,16 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const submit = command
|
const submit = command
|
||||||
? { ...next, command }
|
? { ...next, command, delivery }
|
||||||
: parsed?.type === "command"
|
: parsed?.type === "command"
|
||||||
? { ...next, command: parsed.command }
|
? { ...next, command: parsed.command, delivery }
|
||||||
: next
|
: { ...next, delivery }
|
||||||
const shellMode = next.mode === "shell"
|
const shellMode = next.mode === "shell"
|
||||||
|
|
||||||
|
submitting = true
|
||||||
resetDraft()
|
resetDraft()
|
||||||
queueMicrotask(async () => {
|
queueMicrotask(async () => {
|
||||||
|
try {
|
||||||
if (await input.onSubmit(submit)) {
|
if (await input.onSubmit(submit)) {
|
||||||
push(next)
|
push(next)
|
||||||
if (shellMode) {
|
if (shellMode) {
|
||||||
@@ -1173,8 +1220,10 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
restore(next)
|
restore(next)
|
||||||
|
} finally {
|
||||||
|
submitting = false
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import type {
|
|||||||
MiniSettingChange,
|
MiniSettingChange,
|
||||||
MiniSettings,
|
MiniSettings,
|
||||||
PermissionReply,
|
PermissionReply,
|
||||||
|
QueuedPromptAction,
|
||||||
RunAgent,
|
RunAgent,
|
||||||
RunCommand,
|
RunCommand,
|
||||||
RunInput,
|
RunInput,
|
||||||
@@ -96,6 +97,7 @@ type RunFooterOptions = {
|
|||||||
onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
|
onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
|
||||||
onInterrupt?: () => void
|
onInterrupt?: () => void
|
||||||
onBackground?: () => void
|
onBackground?: () => void
|
||||||
|
onQueuedPromptAction?: (action: QueuedPromptAction, inputID: string) => Promise<void>
|
||||||
onEditorOpen: (input: { value: string }) => Promise<string | undefined>
|
onEditorOpen: (input: { value: string }) => Promise<string | undefined>
|
||||||
onSubagentSelect?: (sessionID: string | undefined) => void
|
onSubagentSelect?: (sessionID: string | undefined) => void
|
||||||
onSubagentInterrupt?: (sessionID: string) => void
|
onSubagentInterrupt?: (sessionID: string) => void
|
||||||
@@ -343,6 +345,7 @@ export class RunFooter implements FooterApi {
|
|||||||
onCycle: footer.handleCycle,
|
onCycle: footer.handleCycle,
|
||||||
onInterrupt: footer.handleInterrupt,
|
onInterrupt: footer.handleInterrupt,
|
||||||
onBackground: options.onBackground,
|
onBackground: options.onBackground,
|
||||||
|
onQueuedPromptAction: options.onQueuedPromptAction,
|
||||||
onEditorOpen: options.onEditorOpen,
|
onEditorOpen: options.onEditorOpen,
|
||||||
onInputClear: footer.handleInputClear,
|
onInputClear: footer.handleInputClear,
|
||||||
onExitRequest: footer.handleExit,
|
onExitRequest: footer.handleExit,
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ import { Keymap } from "../context/keymap"
|
|||||||
import { modelInfo } from "./variant.shared"
|
import { modelInfo } from "./variant.shared"
|
||||||
import { monoShortcut } from "./mono"
|
import { monoShortcut } from "./mono"
|
||||||
import { stringWidth } from "../util/string-width"
|
import { stringWidth } from "../util/string-width"
|
||||||
|
import { errorMessage } from "../util/error"
|
||||||
|
import { createSingleFlight } from "../util/single-flight"
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
FooterPromptRoute,
|
FooterPromptRoute,
|
||||||
@@ -46,6 +48,7 @@ import type {
|
|||||||
MiniSettingChange,
|
MiniSettingChange,
|
||||||
MiniSettings,
|
MiniSettings,
|
||||||
PermissionReply,
|
PermissionReply,
|
||||||
|
QueuedPromptAction,
|
||||||
RunAgent,
|
RunAgent,
|
||||||
RunCommand,
|
RunCommand,
|
||||||
RunInput,
|
RunInput,
|
||||||
@@ -92,13 +95,14 @@ type RunFooterViewProps = {
|
|||||||
mono: boolean
|
mono: boolean
|
||||||
miniSettings: () => MiniSettings
|
miniSettings: () => MiniSettings
|
||||||
history?: () => RunPrompt[]
|
history?: () => RunPrompt[]
|
||||||
onSubmit: (input: RunPrompt) => boolean
|
onSubmit: (input: RunPrompt) => boolean | Promise<boolean>
|
||||||
onPermissionReply: (input: PermissionReply) => void | Promise<void>
|
onPermissionReply: (input: PermissionReply) => void | Promise<void>
|
||||||
onFormReply: (input: FormReply) => void | Promise<void>
|
onFormReply: (input: FormReply) => void | Promise<void>
|
||||||
onFormCancel: (input: FormCancel) => void | Promise<void>
|
onFormCancel: (input: FormCancel) => void | Promise<void>
|
||||||
onCycle: () => void
|
onCycle: () => void
|
||||||
onInterrupt: () => boolean
|
onInterrupt: () => boolean
|
||||||
onBackground?: () => void
|
onBackground?: () => void
|
||||||
|
onQueuedPromptAction?: (action: QueuedPromptAction, inputID: string) => Promise<void>
|
||||||
onEditorOpen: (input: { value: string }) => Promise<string | undefined>
|
onEditorOpen: (input: { value: string }) => Promise<string | undefined>
|
||||||
onInputClear: () => void
|
onInputClear: () => void
|
||||||
onExitRequest?: () => boolean
|
onExitRequest?: () => boolean
|
||||||
@@ -132,6 +136,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
const [route, setRoute] = createSignal<FooterPromptRoute>({ type: "composer" })
|
const [route, setRoute] = createSignal<FooterPromptRoute>({ type: "composer" })
|
||||||
const [subagentMenuRows, setSubagentMenuRows] = createSignal(RUN_SUBAGENT_PANEL_ROWS)
|
const [subagentMenuRows, setSubagentMenuRows] = createSignal(RUN_SUBAGENT_PANEL_ROWS)
|
||||||
const queuedPrompts = createMemo(() => props.queuedPrompts?.() ?? [])
|
const queuedPrompts = createMemo(() => props.queuedPrompts?.() ?? [])
|
||||||
|
const queue = createMemo(() => queuedPrompts().filter((item) => item.delivery === "queue"))
|
||||||
const skills = createMemo(() => (props.commands() ?? []).filter((item) => item.source === "skill"))
|
const skills = createMemo(() => (props.commands() ?? []).filter((item) => item.source === "skill"))
|
||||||
const prompt = createMemo(() => active().type === "prompt" && route().type === "composer")
|
const prompt = createMemo(() => active().type === "prompt" && route().type === "composer")
|
||||||
const selectingSubagent = createMemo(() => active().type === "prompt" && route().type === "subagent-menu")
|
const selectingSubagent = createMemo(() => active().type === "prompt" && route().type === "subagent-menu")
|
||||||
@@ -229,7 +234,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
const details = [busy() ? "running" : "idle", `agent ${props.currentAgent()}`]
|
const details = [busy() ? "running" : "idle", `agent ${props.currentAgent()}`]
|
||||||
if (current) details.push(variant ? `${current} ${variant}` : current)
|
if (current) details.push(variant ? `${current} ${variant}` : current)
|
||||||
if (usage()) details.push(props.mono ? usage().replaceAll(" · ", " - ") : usage())
|
if (usage()) details.push(props.mono ? usage().replaceAll(" · ", " - ") : usage())
|
||||||
if (queuedPrompts().length > 0) details.push(`${queuedPrompts().length} pending`)
|
if (queue().length > 0) details.push(`${queue().length} queued`)
|
||||||
if (activeTabs().length > 0) details.push(`${activeTabs().length} subagent${activeTabs().length === 1 ? "" : "s"}`)
|
if (activeTabs().length > 0) details.push(`${activeTabs().length} subagent${activeTabs().length === 1 ? "" : "s"}`)
|
||||||
return details.join(props.mono ? " - " : " · ")
|
return details.join(props.mono ? " - " : " · ")
|
||||||
})
|
})
|
||||||
@@ -309,7 +314,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const openQueuedMenu = () => {
|
const openQueuedMenu = () => {
|
||||||
if (queuedPrompts().length === 0) return
|
if (queue().length === 0) return
|
||||||
setRoute({ type: "queued-menu" })
|
setRoute({ type: "queued-menu" })
|
||||||
props.onSubagentSelect?.(undefined)
|
props.onSubagentSelect?.(undefined)
|
||||||
}
|
}
|
||||||
@@ -318,6 +323,22 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
setRoute({ type: "composer" })
|
setRoute({ type: "composer" })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const runQueuedAction = createSingleFlight<string>()
|
||||||
|
const queuedPromptAction = async (action: QueuedPromptAction, inputID: string) => {
|
||||||
|
const run = props.onQueuedPromptAction
|
||||||
|
if (!run) return false
|
||||||
|
const result = await runQueuedAction(inputID, async () => {
|
||||||
|
const error = await run(action, inputID).then(
|
||||||
|
() => undefined,
|
||||||
|
(error) => error,
|
||||||
|
)
|
||||||
|
if (!error) return true
|
||||||
|
props.onStatus(`failed to ${action === "cancel" ? "delete" : action} queued prompt: ${errorMessage(error)}`)
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
return result ?? false
|
||||||
|
}
|
||||||
|
|
||||||
const openTab = (sessionID: string) => {
|
const openTab = (sessionID: string) => {
|
||||||
setRoute({ type: "subagent", sessionID })
|
setRoute({ type: "subagent", sessionID })
|
||||||
props.onSubagentSelect?.(sessionID)
|
props.onSubagentSelect?.(sessionID)
|
||||||
@@ -357,6 +378,8 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
theme,
|
theme,
|
||||||
mono: () => props.mono,
|
mono: () => props.mono,
|
||||||
history: props.history,
|
history: props.history,
|
||||||
|
queuedPrompts: queue,
|
||||||
|
onQueuedPromptSteer: (inputID) => queuedPromptAction("steer", inputID),
|
||||||
onSubmit: props.onSubmit,
|
onSubmit: props.onSubmit,
|
||||||
onCycle: props.onCycle,
|
onCycle: props.onCycle,
|
||||||
onInterrupt: props.onInterrupt,
|
onInterrupt: props.onInterrupt,
|
||||||
@@ -451,13 +474,12 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
if (foregroundSubagents() && backgroundShortcut()) {
|
if (foregroundSubagents() && backgroundShortcut()) {
|
||||||
items.push({ key: backgroundShortcut(), label: "background" })
|
items.push({ key: backgroundShortcut(), label: "background" })
|
||||||
}
|
}
|
||||||
if (queuedPrompts().length > 0 && queuedShortcut()) {
|
if (queue().length > 0 && queuedShortcut()) {
|
||||||
items.push({ key: queuedShortcut(), label: `${queuedPrompts().length} pending` })
|
items.push({ key: queuedShortcut(), label: `${queue().length} queued` })
|
||||||
}
|
}
|
||||||
if (activeTabs().length > 0 && subagentShortcut()) {
|
if (activeTabs().length > 0 && subagentShortcut()) {
|
||||||
items.push({ key: subagentShortcut(), label: "subagents" })
|
items.push({ key: subagentShortcut(), label: "subagents" })
|
||||||
}
|
}
|
||||||
|
|
||||||
return items
|
return items
|
||||||
})
|
})
|
||||||
const commandHint = createMemo(() => {
|
const commandHint = createMemo(() => {
|
||||||
@@ -568,11 +590,11 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
Keymap.createLayer(() => ({
|
Keymap.createLayer(() => ({
|
||||||
enabled: active().type === "prompt" && route().type === "composer" && queuedPrompts().length > 0,
|
enabled: active().type === "prompt" && route().type === "composer" && queue().length > 0,
|
||||||
commands: [
|
commands: [
|
||||||
{
|
{
|
||||||
id: "session.queued_prompts",
|
id: "session.queued_prompts",
|
||||||
title: "View pending work",
|
title: "View queued prompts",
|
||||||
group: "Session",
|
group: "Session",
|
||||||
run: openQueuedMenu,
|
run: openQueuedMenu,
|
||||||
},
|
},
|
||||||
@@ -630,7 +652,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (route().type !== "queued-menu" || queuedPrompts().length > 0) return
|
if (route().type !== "queued-menu" || queue().length > 0) return
|
||||||
closePanel()
|
closePanel()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -734,8 +756,16 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
<Match when={selectingQueued()}>
|
<Match when={selectingQueued()}>
|
||||||
<RunQueuedPromptSelectBody
|
<RunQueuedPromptSelectBody
|
||||||
theme={theme}
|
theme={theme}
|
||||||
prompts={queuedPrompts}
|
prompts={queue}
|
||||||
onClose={closePanel}
|
onClose={closePanel}
|
||||||
|
onSteer={(item) => {
|
||||||
|
void queuedPromptAction("steer", item.messageID).then((steered) => {
|
||||||
|
if (steered) closePanel()
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
onDelete={(item) => {
|
||||||
|
void queuedPromptAction("cancel", item.messageID)
|
||||||
|
}}
|
||||||
onRows={setSubagentMenuRows}
|
onRows={setSubagentMenuRows}
|
||||||
mono={props.mono}
|
mono={props.mono}
|
||||||
/>
|
/>
|
||||||
@@ -745,7 +775,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
theme={theme}
|
theme={theme}
|
||||||
commands={props.commands}
|
commands={props.commands}
|
||||||
subagents={tabs}
|
subagents={tabs}
|
||||||
queued={queuedPrompts}
|
queued={queue}
|
||||||
variants={props.variants}
|
variants={props.variants}
|
||||||
variantCycle={variantCycle()}
|
variantCycle={variantCycle()}
|
||||||
onClose={closePanel}
|
onClose={closePanel}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import type {
|
|||||||
MiniSettings,
|
MiniSettings,
|
||||||
MiniHost,
|
MiniHost,
|
||||||
PermissionReply,
|
PermissionReply,
|
||||||
|
QueuedPromptAction,
|
||||||
RunAgent,
|
RunAgent,
|
||||||
RunInput,
|
RunInput,
|
||||||
RunPrompt,
|
RunPrompt,
|
||||||
@@ -70,6 +71,7 @@ export type LifecycleInput = {
|
|||||||
onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
|
onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
|
||||||
onInterrupt?: () => void
|
onInterrupt?: () => void
|
||||||
onBackground?: () => void
|
onBackground?: () => void
|
||||||
|
onQueuedPromptAction?: (action: QueuedPromptAction, inputID: string) => Promise<void>
|
||||||
onSubagentSelect?: (sessionID: string | undefined) => void
|
onSubagentSelect?: (sessionID: string | undefined) => void
|
||||||
onSubagentInterrupt?: (sessionID: string) => void
|
onSubagentInterrupt?: (sessionID: string) => void
|
||||||
}
|
}
|
||||||
@@ -243,6 +245,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
|||||||
onVariantSelect: input.onVariantSelect,
|
onVariantSelect: input.onVariantSelect,
|
||||||
onInterrupt: input.onInterrupt,
|
onInterrupt: input.onInterrupt,
|
||||||
onBackground: input.onBackground,
|
onBackground: input.onBackground,
|
||||||
|
onQueuedPromptAction: input.onQueuedPromptAction,
|
||||||
onEditorOpen: async ({ value }) => {
|
onEditorOpen: async ({ value }) => {
|
||||||
if (closed || renderer.isDestroyed) {
|
if (closed || renderer.isDestroyed) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||||
import { Locale } from "../util/locale"
|
import { Locale } from "../util/locale"
|
||||||
import { isCompactCommand, isExitCommand, isNewCommand } from "./prompt.shared"
|
import { isCompactCommand, isExitCommand, isNewCommand } from "./prompt.shared"
|
||||||
import type { FooterApi, FooterEvent, RunPrompt } from "./types"
|
import type { FooterApi, FooterEvent, RunDelivery, RunPrompt } from "./types"
|
||||||
|
|
||||||
type Trace = {
|
type Trace = {
|
||||||
write(type: string, data?: unknown): void
|
write(type: string, data?: unknown): void
|
||||||
@@ -21,11 +21,11 @@ export type QueueInput = {
|
|||||||
footer: FooterApi
|
footer: FooterApi
|
||||||
initialInput?: string
|
initialInput?: string
|
||||||
trace?: Trace
|
trace?: Trace
|
||||||
onSend?: (prompt: RunPrompt, delivery: "steer" | "queue") => void
|
onSend?: (prompt: RunPrompt, delivery: RunDelivery) => void
|
||||||
onAdmissionError?: (prompt: RunPrompt, error: unknown) => void | Promise<void>
|
onAdmissionError?: (prompt: RunPrompt, error: unknown) => void | Promise<void>
|
||||||
onNewSession?: () => void | Promise<void>
|
onNewSession?: () => void | Promise<void>
|
||||||
onCompact?: () => void | Promise<void>
|
onCompact?: () => void | Promise<void>
|
||||||
admit: (prompt: RunPrompt, signal: AbortSignal) => Promise<void>
|
admit: (prompt: RunPrompt, delivery: RunDelivery, signal: AbortSignal) => Promise<void>
|
||||||
settle: () => Promise<void>
|
settle: () => Promise<void>
|
||||||
run: (prompt: RunPrompt, signal: AbortSignal, admitted: () => void) => Promise<void>
|
run: (prompt: RunPrompt, signal: AbortSignal, admitted: () => void) => Promise<void>
|
||||||
}
|
}
|
||||||
@@ -183,7 +183,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
|||||||
input.trace?.write("ui.commit", commit)
|
input.trace?.write("ui.commit", commit)
|
||||||
input.footer.append(commit)
|
input.footer.append(commit)
|
||||||
}
|
}
|
||||||
input.onSend?.(sent, "steer")
|
input.onSend?.(sent, sent.delivery ?? "steer")
|
||||||
|
|
||||||
if (state.closed) {
|
if (state.closed) {
|
||||||
break
|
break
|
||||||
@@ -276,10 +276,11 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
|||||||
const sent = { ...prompt, messageID: SessionMessage.ID.create() }
|
const sent = { ...prompt, messageID: SessionMessage.ID.create() }
|
||||||
const admission = state.admission
|
const admission = state.admission
|
||||||
admissionVersion += 1
|
admissionVersion += 1
|
||||||
input.onSend?.(sent, "queue")
|
const delivery = prompt.delivery ?? "queue"
|
||||||
|
input.onSend?.(sent, delivery)
|
||||||
admissions = admissions
|
admissions = admissions
|
||||||
.then(() => admission)
|
.then(() => admission)
|
||||||
.then(() => input.admit(sent, admissionController.signal))
|
.then(() => input.admit(sent, delivery, admissionController.signal))
|
||||||
.catch((error) => (state.closed ? undefined : input.onAdmissionError?.(sent, error)))
|
.catch((error) => (state.closed ? undefined : input.onAdmissionError?.(sent, error)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -390,6 +390,15 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
log?.write("send.background", { sessionID: state.sessionID })
|
log?.write("send.background", { sessionID: state.sessionID })
|
||||||
void state.sdk.session.background({ sessionID: state.sessionID }).catch(() => {})
|
void state.sdk.session.background({ sessionID: state.sessionID }).catch(() => {})
|
||||||
},
|
},
|
||||||
|
onQueuedPromptAction: async (action, inputID) => {
|
||||||
|
if (!state.sessionID) return
|
||||||
|
log?.write(`send.pending.${action}`, { sessionID: state.sessionID, inputID })
|
||||||
|
if (action === "steer") {
|
||||||
|
await state.sdk.session.pending.steer({ sessionID: state.sessionID, inputID })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await state.sdk.session.pending.cancel({ sessionID: state.sessionID, inputID })
|
||||||
|
},
|
||||||
onSubagentInterrupt: (sessionID) => {
|
onSubagentInterrupt: (sessionID) => {
|
||||||
log?.write("send.subagent.interrupt", { sessionID })
|
log?.write("send.subagent.interrupt", { sessionID })
|
||||||
void state.sdk.session.interrupt({ sessionID }).catch(() => {})
|
void state.sdk.session.interrupt({ sessionID }).catch(() => {})
|
||||||
@@ -892,7 +901,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
trace: log,
|
trace: log,
|
||||||
onSend: (prompt, delivery) => {
|
onSend: (prompt, delivery) => {
|
||||||
state.shown = true
|
state.shown = true
|
||||||
state.history.push(prompt)
|
state.history.push({ ...prompt, delivery: undefined })
|
||||||
if (prompt.mode !== "shell" && delivery === "steer") {
|
if (prompt.mode !== "shell" && delivery === "steer") {
|
||||||
rememberLocal({
|
rememberLocal({
|
||||||
kind: "user",
|
kind: "user",
|
||||||
@@ -903,10 +912,11 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
admit: async (prompt, signal) => {
|
admit: async (prompt, delivery, signal) => {
|
||||||
await state.switching?.catch(() => {})
|
await state.switching?.catch(() => {})
|
||||||
const next = await ensureStream()
|
const next = await ensureStream()
|
||||||
await next.handle.queuePromptTurn({
|
await next.handle.admitPromptTurn(
|
||||||
|
{
|
||||||
agent: state.agent,
|
agent: state.agent,
|
||||||
model: state.model,
|
model: state.model,
|
||||||
variant: state.activeVariant,
|
variant: state.activeVariant,
|
||||||
@@ -914,7 +924,9 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
files: input.files,
|
files: input.files,
|
||||||
includeFiles: false,
|
includeFiles: false,
|
||||||
signal,
|
signal,
|
||||||
})
|
},
|
||||||
|
delivery,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
onAdmissionError: renderPromptError,
|
onAdmissionError: renderPromptError,
|
||||||
onCompact: async () => {
|
onCompact: async () => {
|
||||||
|
|||||||
@@ -653,6 +653,10 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (event.type === "session.input.cancelled") {
|
||||||
|
child.prompts.delete(event.data.inputID)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (event.type === "session.step.started") {
|
if (event.type === "session.step.started") {
|
||||||
touch(child, event.created)
|
touch(child, event.created)
|
||||||
if (child.label === FALLBACK_LABEL && event.data.agent) child.label = Locale.titlecase(event.data.agent)
|
if (child.label === FALLBACK_LABEL && event.data.agent) child.label = Locale.titlecase(event.data.agent)
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import type {
|
|||||||
FooterQueuedPrompt,
|
FooterQueuedPrompt,
|
||||||
RunFilePart,
|
RunFilePart,
|
||||||
RunInput,
|
RunInput,
|
||||||
|
RunDelivery,
|
||||||
RunPrompt,
|
RunPrompt,
|
||||||
RunPromptPart,
|
RunPromptPart,
|
||||||
StreamCommit,
|
StreamCommit,
|
||||||
@@ -71,7 +72,7 @@ export type SessionResizeReplayInput = {
|
|||||||
|
|
||||||
export type SessionTransport = {
|
export type SessionTransport = {
|
||||||
runPromptTurn(input: SessionTurnInput, admitted?: () => void): Promise<void>
|
runPromptTurn(input: SessionTurnInput, admitted?: () => void): Promise<void>
|
||||||
queuePromptTurn(input: SessionTurnInput): Promise<void>
|
admitPromptTurn(input: SessionTurnInput, delivery: RunDelivery): Promise<void>
|
||||||
waitForIdle(): Promise<void>
|
waitForIdle(): Promise<void>
|
||||||
interruptActiveTurn(): Promise<void>
|
interruptActiveTurn(): Promise<void>
|
||||||
selectSubagent(sessionID: string | undefined): void
|
selectSubagent(sessionID: string | undefined): void
|
||||||
@@ -515,8 +516,12 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let syncedPending: string[] | undefined
|
||||||
const syncPending = () => {
|
const syncPending = () => {
|
||||||
const prompts = [...state.pending.values()]
|
const prompts = [...state.pending.values()].filter((item) => item.delivery === "queue")
|
||||||
|
const ids = prompts.map((item) => item.messageID)
|
||||||
|
if (syncedPending?.length === ids.length && syncedPending.every((id, index) => id === ids[index])) return
|
||||||
|
syncedPending = ids
|
||||||
input.trace?.write("ui.patch", { pending: prompts.length })
|
input.trace?.write("ui.patch", { pending: prompts.length })
|
||||||
input.footer.event({ type: "queued.prompts", prompts })
|
input.footer.event({ type: "queued.prompts", prompts })
|
||||||
}
|
}
|
||||||
@@ -934,6 +939,36 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||||||
write([], { phase: "running", status: "waiting for assistant" })
|
write([], { phase: "running", status: "waiting for assistant" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (event.type === "session.input.steered") {
|
||||||
|
const pending = state.pending.get(event.data.inputID)
|
||||||
|
if (!pending) return
|
||||||
|
state.pending.set(event.data.inputID, { ...pending, delivery: "steer" })
|
||||||
|
syncPending()
|
||||||
|
if (state.messageIDs.has(event.data.inputID)) return
|
||||||
|
state.messageIDs.add(event.data.inputID)
|
||||||
|
write([
|
||||||
|
{
|
||||||
|
kind: "user",
|
||||||
|
source: "system",
|
||||||
|
text: pending.prompt.text,
|
||||||
|
phase: "start",
|
||||||
|
messageID: event.data.inputID,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.type === "session.input.queued") {
|
||||||
|
const pending = state.pending.get(event.data.inputID)
|
||||||
|
if (!pending) return
|
||||||
|
state.pending.set(event.data.inputID, { ...pending, delivery: "queue" })
|
||||||
|
syncPending()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.type === "session.input.cancelled") {
|
||||||
|
state.admitted.delete(event.data.inputID)
|
||||||
|
if (state.pending.delete(event.data.inputID)) syncPending()
|
||||||
|
return
|
||||||
|
}
|
||||||
if (event.type === "session.step.started") {
|
if (event.type === "session.step.started") {
|
||||||
state.stepModel = { providerID: event.data.model.providerID, modelID: event.data.model.id }
|
state.stepModel = { providerID: event.data.model.providerID, modelID: event.data.model.id }
|
||||||
write([], { phase: "running", status: "assistant responding" })
|
write([], { phase: "running", status: "assistant responding" })
|
||||||
@@ -1577,7 +1612,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||||||
let queuedResizeReplay: SessionResizeReplayInput | undefined
|
let queuedResizeReplay: SessionResizeReplayInput | undefined
|
||||||
let closing: Promise<void> | undefined
|
let closing: Promise<void> | undefined
|
||||||
|
|
||||||
const admitPrompt = async (next: SessionTurnInput, client: OpenCodeClient, delivery: "steer" | "queue") => {
|
const admitPrompt = async (next: SessionTurnInput, client: OpenCodeClient, delivery: RunDelivery) => {
|
||||||
const messageID = next.prompt.messageID
|
const messageID = next.prompt.messageID
|
||||||
if (!messageID) throw new Error("Prompt message ID is required")
|
if (!messageID) throw new Error("Prompt message ID is required")
|
||||||
const command = next.prompt.command
|
const command = next.prompt.command
|
||||||
@@ -1643,14 +1678,20 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async queuePromptTurn(next) {
|
async admitPromptTurn(next, delivery) {
|
||||||
if (next.prompt.mode === "shell" || next.prompt.command?.source === "skill")
|
if (next.prompt.mode === "shell" || next.prompt.command?.source === "skill")
|
||||||
throw new Error("This prompt cannot be queued")
|
throw new Error("This prompt cannot be queued")
|
||||||
if (!state.connected) throw new Error("Event stream is reconnecting")
|
if (!state.connected) throw new Error("Event stream is reconnecting")
|
||||||
const client = sdk
|
const client = sdk
|
||||||
if (next.agent)
|
if (next.agent)
|
||||||
await client.session.switchAgent({ sessionID: input.sessionID, agent: next.agent }, { signal: next.signal })
|
await client.session.switchAgent({ sessionID: input.sessionID, agent: next.agent }, { signal: next.signal })
|
||||||
mergePending(await admitPrompt(next, client, "queue"))
|
if (!next.prompt.command) {
|
||||||
|
const selected = await resolveSelectedModel(input, client, next)
|
||||||
|
if (next.variant && !selected) throw new Error("Cannot select a variant before selecting a model")
|
||||||
|
if (selected)
|
||||||
|
await client.session.switchModel({ sessionID: input.sessionID, model: selected }, { signal: next.signal })
|
||||||
|
}
|
||||||
|
mergePending(await admitPrompt(next, client, delivery))
|
||||||
settlementClient = client
|
settlementClient = client
|
||||||
},
|
},
|
||||||
async waitForIdle() {
|
async waitForIdle() {
|
||||||
@@ -1688,7 +1729,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (command) {
|
if (command) {
|
||||||
await runTurnWait(next, messageID, client, () => admitPrompt(next, client, "steer"), admitted)
|
await runTurnWait(next, messageID, client, () => admitPrompt(next, client, next.prompt.delivery ?? "steer"), admitted)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1700,7 +1741,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||||||
if (selected)
|
if (selected)
|
||||||
await client.session.switchModel({ sessionID: input.sessionID, model: selected }, { signal: next.signal })
|
await client.session.switchModel({ sessionID: input.sessionID, model: selected }, { signal: next.signal })
|
||||||
|
|
||||||
await runTurnWait(next, messageID, client, () => admitPrompt(next, client, "steer"), admitted)
|
await runTurnWait(next, messageID, client, () => admitPrompt(next, client, next.prompt.delivery ?? "steer"), admitted)
|
||||||
},
|
},
|
||||||
async interruptActiveTurn() {
|
async interruptActiveTurn() {
|
||||||
// A running shell holds no drain, so session.interrupt cannot reach it;
|
// A running shell holds no drain, so session.interrupt cannot reach it;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import type {
|
|||||||
} from "@opencode-ai/client/promise"
|
} from "@opencode-ai/client/promise"
|
||||||
import type { Config } from "../config"
|
import type { Config } from "../config"
|
||||||
import type { CliRenderer } from "@opentui/core"
|
import type { CliRenderer } from "@opentui/core"
|
||||||
|
import type { SessionPending } from "@opencode-ai/schema/session-pending"
|
||||||
|
|
||||||
export type RunFilePart = {
|
export type RunFilePart = {
|
||||||
type: "file"
|
type: "file"
|
||||||
@@ -71,10 +72,13 @@ export type RunProvider = {
|
|||||||
models: Record<string, RunProviderModel>
|
models: Record<string, RunProviderModel>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RunDelivery = SessionPending.Delivery
|
||||||
|
|
||||||
export type RunPrompt = {
|
export type RunPrompt = {
|
||||||
messageID?: string
|
messageID?: string
|
||||||
text: string
|
text: string
|
||||||
parts: RunPromptPart[]
|
parts: RunPromptPart[]
|
||||||
|
delivery?: RunDelivery
|
||||||
mode?: "shell"
|
mode?: "shell"
|
||||||
command?: {
|
command?: {
|
||||||
name: string
|
name: string
|
||||||
@@ -87,9 +91,11 @@ export type RunPrompt = {
|
|||||||
export type FooterQueuedPrompt = {
|
export type FooterQueuedPrompt = {
|
||||||
messageID: string
|
messageID: string
|
||||||
prompt: RunPrompt
|
prompt: RunPrompt
|
||||||
delivery: "steer" | "queue"
|
delivery: RunDelivery
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type QueuedPromptAction = "steer" | "cancel"
|
||||||
|
|
||||||
export type RunAgent = {
|
export type RunAgent = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
|
import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
|
||||||
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
|
import { usePaste, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||||
import type { ScrollBoxRenderable, TextareaRenderable } from "@opentui/core"
|
import { decodePasteBytes, stripAnsiSequences, type ScrollBoxRenderable, type TextareaRenderable } from "@opentui/core"
|
||||||
import open from "open"
|
import open from "open"
|
||||||
import { useTheme, useThemes } from "../../context/theme"
|
import { useTheme, useThemes } from "../../context/theme"
|
||||||
import type { FormField, FormValue } from "@opencode-ai/client"
|
import type { FormField, FormValue } from "@opencode-ai/client"
|
||||||
@@ -265,6 +265,19 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||||||
pick(row.value)
|
pick(row.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
usePaste((event) => {
|
||||||
|
if (keymap.mode.current() !== FORM_MODE) return
|
||||||
|
const current = answerField()
|
||||||
|
if (!current || textual() || !custom() || confirm()) return
|
||||||
|
event.preventDefault()
|
||||||
|
setStore("selected", rows().length)
|
||||||
|
setStore("custom", {
|
||||||
|
...store.custom,
|
||||||
|
[current.key]: input() + stripAnsiSequences(decodePasteBytes(event.bytes)),
|
||||||
|
})
|
||||||
|
setStore("editing", true)
|
||||||
|
})
|
||||||
|
|
||||||
function commitInput(text: string) {
|
function commitInput(text: string) {
|
||||||
const current = answerField()
|
const current = answerField()
|
||||||
if (!current) return false
|
if (!current) return false
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import { useClient } from "../../context/client"
|
|||||||
import { useEditorContext } from "../../context/editor"
|
import { useEditorContext } from "../../context/editor"
|
||||||
import { openEditor } from "../../editor"
|
import { openEditor } from "../../editor"
|
||||||
import { useDialog } from "../../ui/dialog"
|
import { useDialog } from "../../ui/dialog"
|
||||||
|
import { DialogSelect } from "../../ui/dialog-select"
|
||||||
import { DialogSessionRename } from "../../component/dialog-session-rename"
|
import { DialogSessionRename } from "../../component/dialog-session-rename"
|
||||||
import { DialogMessage } from "./dialog-message"
|
import { DialogMessage } from "./dialog-message"
|
||||||
import { DialogFork } from "./dialog-fork"
|
import { DialogFork } from "./dialog-fork"
|
||||||
@@ -97,6 +98,8 @@ import { stringWidth } from "../../util/string-width"
|
|||||||
import { useArgs } from "../../context/args"
|
import { useArgs } from "../../context/args"
|
||||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||||
import { useSessionTabs } from "../../context/session-tabs"
|
import { useSessionTabs } from "../../context/session-tabs"
|
||||||
|
import { createSingleFlight } from "../../util/single-flight"
|
||||||
|
import type { SessionPending } from "@opencode-ai/schema/session-pending"
|
||||||
|
|
||||||
addDefaultParsers(parsers.parsers)
|
addDefaultParsers(parsers.parsers)
|
||||||
|
|
||||||
@@ -109,6 +112,7 @@ const NAVIGATION_SLACK_ID = "session-navigation-slack"
|
|||||||
const TRANSCRIPT_TAIL_ROWS = 40
|
const TRANSCRIPT_TAIL_ROWS = 40
|
||||||
const TRANSCRIPT_BACKFILL_CHUNK = 60
|
const TRANSCRIPT_BACKFILL_CHUNK = 60
|
||||||
const TRANSCRIPT_BACKFILL_DELAY = 120
|
const TRANSCRIPT_BACKFILL_DELAY = 120
|
||||||
|
type PendingAction = "steer" | "queue" | "cancel"
|
||||||
|
|
||||||
const context = createContext<{
|
const context = createContext<{
|
||||||
width: number
|
width: number
|
||||||
@@ -120,6 +124,8 @@ const context = createContext<{
|
|||||||
diffWrapMode: () => "word" | "none"
|
diffWrapMode: () => "word" | "none"
|
||||||
models: () => ModelInfo[]
|
models: () => ModelInfo[]
|
||||||
config: ReturnType<typeof useConfig>["data"]
|
config: ReturnType<typeof useConfig>["data"]
|
||||||
|
mutatePending: (action: PendingAction, inputID: string) => Promise<boolean>
|
||||||
|
pendingDelivery: (inputID: string) => SessionPending.Delivery | undefined
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
function use() {
|
function use() {
|
||||||
@@ -175,6 +181,13 @@ export function Session() {
|
|||||||
.flatMap((sessionID) => data.session.form.list(sessionID) ?? [])
|
.flatMap((sessionID) => data.session.form.list(sessionID) ?? [])
|
||||||
.concat(global)
|
.concat(global)
|
||||||
})
|
})
|
||||||
|
const pendingUsers = createMemo(() =>
|
||||||
|
data.session.pending.list(route.sessionID).flatMap((item) => (item.type === "user" ? [item] : [])),
|
||||||
|
)
|
||||||
|
const pendingDeliveries = createMemo(() => new Map(pendingUsers().map((item) => [item.id, item.delivery])))
|
||||||
|
const queuedPrompts = createMemo(() =>
|
||||||
|
pendingUsers().flatMap((item) => (item.delivery === "queue" ? [{ id: item.id, text: item.data.text }] : [])),
|
||||||
|
)
|
||||||
const [composer, setComposer] = createStore({
|
const [composer, setComposer] = createStore({
|
||||||
open: false,
|
open: false,
|
||||||
tab: undefined as string | undefined,
|
tab: undefined as string | undefined,
|
||||||
@@ -227,7 +240,7 @@ export function Session() {
|
|||||||
permissions().forEach((request) => {
|
permissions().forEach((request) => {
|
||||||
if (autoApproved.has(request.id)) return
|
if (autoApproved.has(request.id)) return
|
||||||
autoApproved.add(request.id)
|
autoApproved.add(request.id)
|
||||||
void client.api.permission
|
void data.session.permission
|
||||||
.reply({
|
.reply({
|
||||||
sessionID: request.sessionID,
|
sessionID: request.sessionID,
|
||||||
reply: "once",
|
reply: "once",
|
||||||
@@ -369,6 +382,55 @@ export function Session() {
|
|||||||
})
|
})
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
|
const runPendingAction = createSingleFlight<string>()
|
||||||
|
const mutatePending = async (action: PendingAction, inputID: string) => {
|
||||||
|
const result = await runPendingAction(inputID, async () => {
|
||||||
|
const request =
|
||||||
|
action === "steer"
|
||||||
|
? client.api.session.pending.steer({ sessionID: route.sessionID, inputID })
|
||||||
|
: action === "queue"
|
||||||
|
? client.api.session.pending.queue({ sessionID: route.sessionID, inputID })
|
||||||
|
: client.api.session.pending.cancel({ sessionID: route.sessionID, inputID })
|
||||||
|
const error = await request.then(
|
||||||
|
() => undefined,
|
||||||
|
(error) => error,
|
||||||
|
)
|
||||||
|
if (!error) return true
|
||||||
|
const label = action === "cancel" ? "delete" : action
|
||||||
|
toast.show({ title: `Failed to ${label} pending prompt`, message: errorMessage(error), variant: "error" })
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
return result ?? false
|
||||||
|
}
|
||||||
|
const openQueuedPrompts = () =>
|
||||||
|
dialog.replace(() => (
|
||||||
|
<DialogSelect
|
||||||
|
title="Queued prompts"
|
||||||
|
options={queuedPrompts().map((prompt, index) => ({
|
||||||
|
title: prompt.text,
|
||||||
|
value: prompt.id,
|
||||||
|
footer: `${index + 1} of ${queuedPrompts().length}`,
|
||||||
|
}))}
|
||||||
|
onSelect={(option) => {
|
||||||
|
void mutatePending("steer", option.value).then((steered) => {
|
||||||
|
if (steered) dialog.clear()
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
command: "queued_prompt.delete",
|
||||||
|
title: "delete",
|
||||||
|
onTrigger: (option) => {
|
||||||
|
const last = queuedPrompts().length === 1
|
||||||
|
void mutatePending("cancel", option.value).then((cancelled) => {
|
||||||
|
if (cancelled && last) dialog.clear()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
footerHints={[{ title: "steer", label: "enter" }]}
|
||||||
|
/>
|
||||||
|
))
|
||||||
const unavailable = (feature: string) => {
|
const unavailable = (feature: string) => {
|
||||||
toast.show({ message: `${feature} is not implemented for V2 sessions yet`, variant: "error", duration: 5000 })
|
toast.show({ message: `${feature} is not implemented for V2 sessions yet`, variant: "error", duration: 5000 })
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
@@ -871,6 +933,13 @@ export function Session() {
|
|||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "View queued prompts",
|
||||||
|
id: "session.queued_prompts",
|
||||||
|
group: "Session",
|
||||||
|
enabled: queuedPrompts().length > 0,
|
||||||
|
run: openQueuedPrompts,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Go to parent session",
|
title: "Go to parent session",
|
||||||
id: "session.parent",
|
id: "session.parent",
|
||||||
@@ -942,6 +1011,8 @@ export function Session() {
|
|||||||
diffWrapMode,
|
diffWrapMode,
|
||||||
models,
|
models,
|
||||||
config,
|
config,
|
||||||
|
mutatePending,
|
||||||
|
pendingDelivery: (inputID) => pendingDeliveries().get(inputID),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<box flexDirection="row" flexGrow={1} minHeight={0}>
|
<box flexDirection="row" flexGrow={1} minHeight={0}>
|
||||||
@@ -997,6 +1068,9 @@ export function Session() {
|
|||||||
</Show>
|
</Show>
|
||||||
</scrollbox>
|
</scrollbox>
|
||||||
<box flexShrink={0}>
|
<box flexShrink={0}>
|
||||||
|
<Show when={!composer.open && !disabled() && queuedPrompts().length > 0}>
|
||||||
|
<QueuedPromptDock prompts={queuedPrompts()} onOpen={openQueuedPrompts} />
|
||||||
|
</Show>
|
||||||
<PluginSlot name="session.composer.top" input={{ sessionID: route.sessionID }} mode="all" />
|
<PluginSlot name="session.composer.top" input={{ sessionID: route.sessionID }} mode="all" />
|
||||||
<Composer
|
<Composer
|
||||||
sessionID={route.sessionID}
|
sessionID={route.sessionID}
|
||||||
@@ -1032,6 +1106,11 @@ export function Session() {
|
|||||||
onSubmit={() => {
|
onSubmit={() => {
|
||||||
toBottom()
|
toBottom()
|
||||||
}}
|
}}
|
||||||
|
onEmptySubmit={async () => {
|
||||||
|
const next = queuedPrompts()[0]
|
||||||
|
if (!next) return false
|
||||||
|
return mutatePending("steer", next.id)
|
||||||
|
}}
|
||||||
sessionID={route.sessionID}
|
sessionID={route.sessionID}
|
||||||
/>
|
/>
|
||||||
</Match>
|
</Match>
|
||||||
@@ -1813,6 +1892,7 @@ function ShellMessage(props: { message: Extract<SessionMessageInfo, { type: "she
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
|
width="100%"
|
||||||
border={["left"]}
|
border={["left"]}
|
||||||
paddingTop={1}
|
paddingTop={1}
|
||||||
paddingBottom={1}
|
paddingBottom={1}
|
||||||
@@ -1840,18 +1920,20 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
|||||||
const mode = themes.mode
|
const mode = themes.mode
|
||||||
const [hover, setHover] = createSignal(false)
|
const [hover, setHover] = createSignal(false)
|
||||||
const color = createMemo(() => local.agent.color(data.session.get(ctx.sessionID)?.agent ?? "build"))
|
const color = createMemo(() => local.agent.color(data.session.get(ctx.sessionID)?.agent ?? "build"))
|
||||||
const queued = createMemo(
|
const delivery = createMemo(() => ctx.pendingDelivery(props.message.id))
|
||||||
() => data.session.status(ctx.sessionID) === "running" && data.session.input.has(ctx.sessionID, props.message.id),
|
|
||||||
)
|
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
const promptRef = usePromptRef()
|
const promptRef = usePromptRef()
|
||||||
|
|
||||||
|
const updatePendingSteer = async (action: "queue" | "cancel") => {
|
||||||
|
if (await ctx.mutatePending(action, props.message.id)) dialog.clear()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={props.message.text.trim() || files().length}>
|
<Show when={props.message.text.trim() || files().length}>
|
||||||
<box
|
<box
|
||||||
border={["left"]}
|
border={["left"]}
|
||||||
borderColor={queued() ? theme.border.default : color()}
|
borderColor={delivery() ? theme.border.default : color()}
|
||||||
customBorderChars={SplitBorder.customBorderChars}
|
customBorderChars={SplitBorder.customBorderChars}
|
||||||
>
|
>
|
||||||
<box
|
<box
|
||||||
@@ -1863,6 +1945,21 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
|||||||
}}
|
}}
|
||||||
onMouseUp={() => {
|
onMouseUp={() => {
|
||||||
if (renderer.getSelection()?.getSelectedText()) return
|
if (renderer.getSelection()?.getSelectedText()) return
|
||||||
|
if (delivery() === "steer") {
|
||||||
|
dialog.replace(() => (
|
||||||
|
<DialogSelect
|
||||||
|
title="Pending steer"
|
||||||
|
options={[
|
||||||
|
{ title: "Move to queue", value: "queue" as const },
|
||||||
|
{ title: "Delete", value: "cancel" as const },
|
||||||
|
]}
|
||||||
|
onSelect={(option) => {
|
||||||
|
void updatePendingSteer(option.value)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
return
|
||||||
|
}
|
||||||
dialog.replace(() => (
|
dialog.replace(() => (
|
||||||
<DialogMessage
|
<DialogMessage
|
||||||
messageID={props.message.id}
|
messageID={props.message.id}
|
||||||
@@ -1910,6 +2007,35 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function QueuedPromptDock(props: { prompts: { id: string; text: string }[]; onOpen: () => void }) {
|
||||||
|
const theme = useTheme("elevated")
|
||||||
|
const next = createMemo(() => props.prompts[0]?.text)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
border={["left"]}
|
||||||
|
borderColor={theme.border.default}
|
||||||
|
customBorderChars={SplitBorder.customBorderChars}
|
||||||
|
onMouseUp={props.onOpen}
|
||||||
|
>
|
||||||
|
<box
|
||||||
|
width="100%"
|
||||||
|
paddingTop={1}
|
||||||
|
paddingBottom={1}
|
||||||
|
paddingLeft={2}
|
||||||
|
paddingRight={1}
|
||||||
|
backgroundColor={theme.background.default}
|
||||||
|
flexDirection="row"
|
||||||
|
>
|
||||||
|
<text fg={theme.text.subdued} wrapMode="none" truncate flexGrow={1} flexShrink={1} minWidth={0}>
|
||||||
|
<span style={{ fg: theme.text.default }}>{props.prompts.length} queued</span>
|
||||||
|
<Show when={next()}>{(text) => <> · {text()}</>}</Show>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
|
function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ import { createMemo, For, Match, Show, Switch } from "solid-js"
|
|||||||
import { Portal, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
import { Portal, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||||
import type { TextareaRenderable } from "@opentui/core"
|
import type { TextareaRenderable } from "@opentui/core"
|
||||||
import { useTheme, useThemes } from "../../context/theme"
|
import { useTheme, useThemes } from "../../context/theme"
|
||||||
import type { PermissionRequest } from "@opencode-ai/client"
|
import type { PermissionReply, PermissionRequest } from "@opencode-ai/client"
|
||||||
import { useClient } from "../../context/client"
|
|
||||||
import { SplitBorder } from "../../ui/border"
|
import { SplitBorder } from "../../ui/border"
|
||||||
import { useData } from "../../context/data"
|
import { useData } from "../../context/data"
|
||||||
import { filetype } from "../../util/filetype"
|
import { filetype } from "../../util/filetype"
|
||||||
@@ -15,6 +14,7 @@ import { Keymap } from "../../context/keymap"
|
|||||||
import { usePathFormatter } from "../../context/path-format"
|
import { usePathFormatter } from "../../context/path-format"
|
||||||
import { SimulationSemantics } from "../../simulation/semantics"
|
import { SimulationSemantics } from "../../simulation/semantics"
|
||||||
import { PatchDiff } from "../../component/patch-diff"
|
import { PatchDiff } from "../../component/patch-diff"
|
||||||
|
import { useToast } from "../../ui/toast"
|
||||||
|
|
||||||
type PermissionStage = "permission" | "always" | "reject"
|
type PermissionStage = "permission" | "always" | "reject"
|
||||||
|
|
||||||
@@ -110,8 +110,8 @@ function EditBody(props: { file?: string; diff?: string; patch?: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PermissionPrompt(props: { request: PermissionRequest; directory?: string }) {
|
export function PermissionPrompt(props: { request: PermissionRequest; directory?: string }) {
|
||||||
const client = useClient()
|
|
||||||
const data = useData()
|
const data = useData()
|
||||||
|
const toast = useToast()
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
stage: "permission" as PermissionStage,
|
stage: "permission" as PermissionStage,
|
||||||
})
|
})
|
||||||
@@ -132,6 +132,12 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
|||||||
|
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
|
|
||||||
|
function reply(value: PermissionReply, message?: string) {
|
||||||
|
void data.session.permission
|
||||||
|
.reply({ sessionID: props.request.sessionID, requestID: props.request.id, reply: value, message })
|
||||||
|
.catch((error: unknown) => toast.error(error))
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={store.stage === "always"}>
|
<Match when={store.stage === "always"}>
|
||||||
@@ -151,11 +157,7 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
|||||||
onSelect={(option) => {
|
onSelect={(option) => {
|
||||||
setStore("stage", "permission")
|
setStore("stage", "permission")
|
||||||
if (option === "cancel") return
|
if (option === "cancel") return
|
||||||
void client.api.permission.reply({
|
reply("always")
|
||||||
sessionID: props.request.sessionID,
|
|
||||||
reply: "always",
|
|
||||||
requestID: props.request.id,
|
|
||||||
})
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Match>
|
</Match>
|
||||||
@@ -164,12 +166,7 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
|||||||
action={props.request.action}
|
action={props.request.action}
|
||||||
instance={props.request.id}
|
instance={props.request.id}
|
||||||
onConfirm={(message) => {
|
onConfirm={(message) => {
|
||||||
void client.api.permission.reply({
|
reply("reject", message || undefined)
|
||||||
sessionID: props.request.sessionID,
|
|
||||||
reply: "reject",
|
|
||||||
requestID: props.request.id,
|
|
||||||
message: message || undefined,
|
|
||||||
})
|
|
||||||
}}
|
}}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setStore("stage", "permission")
|
setStore("stage", "permission")
|
||||||
@@ -265,18 +262,10 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
|||||||
setStore("stage", "reject")
|
setStore("stage", "reject")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
void client.api.permission.reply({
|
reply("reject")
|
||||||
sessionID: props.request.sessionID,
|
|
||||||
reply: "reject",
|
|
||||||
requestID: props.request.id,
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
void client.api.permission.reply({
|
reply("once")
|
||||||
sessionID: props.request.sessionID,
|
|
||||||
reply: "once",
|
|
||||||
requestID: props.request.id,
|
|
||||||
})
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user