mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-17 21:21:18 -04:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e3b26ac47 | |||
| 759695d87c | |||
| f14724dfb1 | |||
| cc53db4406 | |||
| fa055143ea |
@@ -8,7 +8,7 @@ import { File } from "@opencode-ai/session-ui/file"
|
||||
import { Font } from "@opencode-ai/ui/font"
|
||||
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
||||
import { MetaProvider } from "@solidjs/meta"
|
||||
import { type BaseRouterProps, Navigate, Route, Router, useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||
import {
|
||||
type Component,
|
||||
@@ -37,8 +37,6 @@ import { SettingsProvider } from "@/context/settings"
|
||||
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
|
||||
import { SDKProvider } from "@/context/sdk"
|
||||
import { WslServersProvider } from "@/wsl/context"
|
||||
import { desktopRecentProjectCommand } from "@/desktop-menu"
|
||||
import { displayName } from "@/pages/layout/helpers"
|
||||
import { DirectoryDataProvider } from "@/pages/directory-layout"
|
||||
import Layout from "@/pages/layout"
|
||||
import { ErrorPage } from "./pages/error"
|
||||
@@ -184,8 +182,6 @@ function DesktopCommands() {
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const global = useGlobal()
|
||||
const navigate = useNavigate()
|
||||
|
||||
command.register("desktop", () => {
|
||||
const commands: CommandOption[] = []
|
||||
@@ -199,28 +195,6 @@ function DesktopCommands() {
|
||||
},
|
||||
})
|
||||
}
|
||||
global.servers.list().forEach((server) => {
|
||||
const ctx = global.ensureServerCtx(server)
|
||||
ctx.projects.recent().forEach((project) => {
|
||||
commands.push({
|
||||
id: desktopRecentProjectCommand(ServerConnection.key(server), project.worktree),
|
||||
title: displayName(project),
|
||||
category: language.t("command.category.file"),
|
||||
hidden: true,
|
||||
onSelect: () => {
|
||||
const location = { directory: project.worktree }
|
||||
void ctx.sdk.api.file
|
||||
.list({ path: ".", location })
|
||||
.then(() => ctx.sdk.api.project.current({ location }))
|
||||
.then((value) => ctx.sync.child(project.worktree, { bootstrap: false })[1]("project", value.id))
|
||||
.catch(() => undefined)
|
||||
ctx.projects.open(project.worktree)
|
||||
ctx.projects.touch(project.worktree)
|
||||
navigate("/")
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
return commands
|
||||
})
|
||||
|
||||
|
||||
@@ -6,18 +6,9 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
|
||||
import { useCommand } from "@/context/command"
|
||||
import {
|
||||
DESKTOP_MENU,
|
||||
desktopMenuVisible,
|
||||
desktopRecentProjectCommand,
|
||||
type DesktopMenuAction,
|
||||
type DesktopMenuEntry,
|
||||
} from "@/desktop-menu"
|
||||
import { DESKTOP_MENU, desktopMenuVisible, type DesktopMenuAction, type DesktopMenuEntry } from "@/desktop-menu"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { ServerConnection, serverName } from "@/context/servers"
|
||||
import { displayName } from "@/pages/layout/helpers"
|
||||
|
||||
export function WindowsAppMenu(props: {
|
||||
command: ReturnType<typeof useCommand>
|
||||
@@ -26,7 +17,6 @@ export function WindowsAppMenu(props: {
|
||||
}) {
|
||||
let lastFocused: HTMLElement | undefined
|
||||
const language = useLanguage()
|
||||
const global = useGlobal()
|
||||
|
||||
const rememberFocus = () => {
|
||||
const active = document.activeElement
|
||||
@@ -100,49 +90,6 @@ export function WindowsAppMenu(props: {
|
||||
{(entry) => {
|
||||
// Static menu data: an early return keeps the union narrowing a Show fallback would lose.
|
||||
if (entry.type === "separator") return <DropdownMenu.Separator />
|
||||
if (entry.dynamic === "recentProjects") {
|
||||
const servers = global.servers.list()
|
||||
const groups = servers
|
||||
.map((server) => ({
|
||||
server,
|
||||
projects: global.ensureServerCtx(server).projects.recent().slice(0, 5),
|
||||
}))
|
||||
.filter((group) => group.projects.length > 0)
|
||||
return (
|
||||
<DesktopMenuSubmenu label={entry.labelKey ? language.t(entry.labelKey) : ""}>
|
||||
<For each={groups}>
|
||||
{(group, index) => (
|
||||
<>
|
||||
<Show when={index() > 0}>
|
||||
<DropdownMenu.Separator />
|
||||
</Show>
|
||||
<Show when={servers.length > 1}>
|
||||
<DropdownMenu.GroupLabel class="desktop-app-menu-heading">
|
||||
{serverName(group.server)}
|
||||
</DropdownMenu.GroupLabel>
|
||||
</Show>
|
||||
<For each={group.projects}>
|
||||
{(project) => (
|
||||
<DesktopMenuItem
|
||||
label={displayName(project)}
|
||||
disabled={false}
|
||||
onSelect={() =>
|
||||
runCommand(
|
||||
desktopRecentProjectCommand(
|
||||
ServerConnection.key(group.server),
|
||||
project.worktree,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</DesktopMenuSubmenu>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<DesktopMenuItem
|
||||
label={entry.labelKey ? language.t(entry.labelKey) : ""}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Accessor, createEffect, createMemo, createRoot } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServers } from "./servers"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { knownProjectWorktrees } from "./project-suggestions"
|
||||
import { useServerHealth } from "@/utils/server-health"
|
||||
import { createServerSdkContext } from "./server-sdk"
|
||||
import { createServerSyncContext } from "./server-sync"
|
||||
@@ -130,17 +129,6 @@ function createServerController(
|
||||
.slice(0, RECENTLY_CLOSED_DISPLAY_LIMIT)
|
||||
.map((worktree) => enrich({ worktree, expanded: false }))
|
||||
})
|
||||
const knownProjectsList = createMemo(() => {
|
||||
return knownProjectWorktrees({
|
||||
open: projects.list().map((project) => project.worktree),
|
||||
recentlyClosed: projects.recentlyClosed(),
|
||||
known: sync.data.project.map((project) => project.worktree),
|
||||
limit: RECENTLY_CLOSED_DISPLAY_LIMIT,
|
||||
}).map((worktree) => enrich({ worktree, expanded: false }))
|
||||
})
|
||||
const recentProjectsList = createMemo(() =>
|
||||
[...recentlyClosedList(), ...knownProjectsList()].slice(0, RECENTLY_CLOSED_DISPLAY_LIMIT),
|
||||
)
|
||||
|
||||
const isLocal =
|
||||
(conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url))
|
||||
@@ -153,8 +141,6 @@ function createServerController(
|
||||
...projects,
|
||||
list: projectsList,
|
||||
recentlyClosed: recentlyClosedList,
|
||||
known: knownProjectsList,
|
||||
recent: recentProjectsList,
|
||||
},
|
||||
permission,
|
||||
notification,
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { knownProjectWorktrees } from "./project-suggestions"
|
||||
|
||||
test("suggests known projects that are neither open nor recently closed", () => {
|
||||
expect(
|
||||
knownProjectWorktrees({
|
||||
open: ["/code/open"],
|
||||
recentlyClosed: ["/code/recent"],
|
||||
known: ["/code/open", "/code/recent", "/code/known", "/code/other"],
|
||||
limit: 5,
|
||||
}),
|
||||
).toEqual(["/code/known", "/code/other"])
|
||||
})
|
||||
|
||||
test("deduplicates paths using platform path semantics and caps suggestions", () => {
|
||||
expect(
|
||||
knownProjectWorktrees({
|
||||
open: ["/code/open/"],
|
||||
recentlyClosed: [],
|
||||
known: ["/code/open", "/code/one", "/code/two"],
|
||||
limit: 1,
|
||||
}),
|
||||
).toEqual(["/code/one"])
|
||||
})
|
||||
@@ -1,11 +0,0 @@
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
|
||||
export function knownProjectWorktrees(input: {
|
||||
open: string[]
|
||||
recentlyClosed: string[]
|
||||
known: string[]
|
||||
limit: number
|
||||
}) {
|
||||
const hidden = new Set([...input.open, ...input.recentlyClosed].map(pathKey))
|
||||
return input.known.filter((worktree) => !hidden.has(pathKey(worktree))).slice(0, input.limit)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { DESKTOP_MENU, desktopRecentProjectCommand } from "./desktop-menu"
|
||||
import { DESKTOP_MENU } from "./desktop-menu"
|
||||
|
||||
describe("desktop menu", () => {
|
||||
test("exports logs through the desktop command registry", () => {
|
||||
@@ -20,19 +20,4 @@ describe("desktop menu", () => {
|
||||
expect(windowMenu?.labelKey).toBe("desktop.menu.window")
|
||||
expect(roleItems.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test("places recent projects directly below open project", () => {
|
||||
const file = DESKTOP_MENU.find((menu) => menu.id === "file")
|
||||
const open = file?.items?.findIndex((item) => item.type === "item" && item.command === "project.open") ?? -1
|
||||
const recent = file?.items?.findIndex((item) => item.type === "item" && item.dynamic === "recentProjects") ?? -1
|
||||
|
||||
expect(open).toBeGreaterThanOrEqual(0)
|
||||
expect(recent).toBe(open + 1)
|
||||
})
|
||||
|
||||
test("creates distinct recent project commands", () => {
|
||||
expect(desktopRecentProjectCommand("server:a", "/code/one")).not.toBe(
|
||||
desktopRecentProjectCommand("server:a", "/code/two"),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -48,7 +48,6 @@ export type DesktopMenuItem = {
|
||||
type: "item"
|
||||
labelKey?: DesktopNativeKey
|
||||
command?: string
|
||||
dynamic?: "recentProjects"
|
||||
action?: DesktopMenuAction
|
||||
role?: DesktopMenuRole
|
||||
href?: string
|
||||
@@ -113,11 +112,6 @@ export const DESKTOP_MENU: DesktopMenu[] = [
|
||||
command: "project.open",
|
||||
accelerator: { macos: "Cmd+O" },
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
labelKey: "desktop.menu.openRecentProjects",
|
||||
dynamic: "recentProjects",
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
labelKey: "desktop.menu.settings",
|
||||
@@ -303,16 +297,6 @@ export const DESKTOP_MENU: DesktopMenu[] = [
|
||||
},
|
||||
]
|
||||
|
||||
export type DesktopRecentProject = {
|
||||
command: string
|
||||
label: string
|
||||
server?: string
|
||||
}
|
||||
|
||||
export function desktopRecentProjectCommand(server: string, directory: string) {
|
||||
return `project.openRecent:${encodeURIComponent(server)}:${encodeURIComponent(directory)}`
|
||||
}
|
||||
|
||||
export function desktopMenuVisible(item: { platforms?: DesktopMenuPlatform[] }, platform: DesktopMenuPlatform) {
|
||||
return !item.platforms || item.platforms.includes(platform)
|
||||
}
|
||||
|
||||
@@ -242,7 +242,6 @@ export const DESKTOP_NATIVE_ENGLISH = {
|
||||
"desktop.menu.exportLogs": "Export Logs...",
|
||||
"desktop.menu.newSession": "New Session",
|
||||
"desktop.menu.openProject": "Open Project...",
|
||||
"desktop.menu.openRecentProjects": "Open Recent Projects",
|
||||
"desktop.menu.newWindow": "New Window",
|
||||
"desktop.menu.closeWindow": "Close Window",
|
||||
"desktop.menu.undo": "Undo",
|
||||
|
||||
@@ -637,7 +637,7 @@ export const dict = {
|
||||
"home.title": "Home",
|
||||
"home.projects": "Projects",
|
||||
"home.project.add": "Add project",
|
||||
"home.recentlyClosed": "Recent projects",
|
||||
"home.recentlyClosed": "Recently closed",
|
||||
"home.server.collapse": "Collapse server projects",
|
||||
"home.server.expand": "Expand server projects",
|
||||
"home.sessions.search.placeholder": "Search sessions",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export { AppBaseProviders, AppInterface } from "./app"
|
||||
export { useLayout } from "./context/layout"
|
||||
export { useGlobal } from "./context/global"
|
||||
export { useServerSDK } from "./context/server-sdk"
|
||||
export { useServerSync } from "./context/server-sync"
|
||||
export { useServers as useServers } from "./context/servers"
|
||||
|
||||
@@ -18,6 +18,8 @@ export function createHomeController() {
|
||||
const focusedServerCtx = useServerCtx(focusedServer)
|
||||
const focusedSync = () => focusedServerCtx()?.sync
|
||||
const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? [])
|
||||
const recentlyClosed = createMemo(() => focusedServerCtx()?.projects.recentlyClosed() ?? [])
|
||||
const homedir = createMemo(() => focusedSync()?.data.path.home ?? "")
|
||||
const selectedProject = createMemo(() => projects().find((project) => project.worktree === selection().directory))
|
||||
const newSessionProject = createMemo(
|
||||
() =>
|
||||
@@ -60,11 +62,11 @@ export function createHomeController() {
|
||||
},
|
||||
project: {
|
||||
list: projects,
|
||||
recentlyClosed,
|
||||
homedir,
|
||||
selected: selectedProject,
|
||||
newSession: newSessionProject,
|
||||
forServer: (conn: ServerConnection.Any) => global.ensureServerCtx(conn).projects.list(),
|
||||
recentForServer: (conn: ServerConnection.Any) => global.ensureServerCtx(conn).projects.recent(),
|
||||
homedirForServer: (conn: ServerConnection.Any) => global.ensureServerCtx(conn).sync.data.path.home,
|
||||
select: (conn: ServerConnection.Any, directory: string) => {
|
||||
const key = ServerConnection.key(conn)
|
||||
if (global.servers.health[key]?.healthy === false) return
|
||||
|
||||
@@ -68,8 +68,8 @@ export function createHomeProjectsController(home: HomeController) {
|
||||
},
|
||||
project: {
|
||||
list: home.project.list,
|
||||
recentForServer: home.project.recentForServer,
|
||||
homedirForServer: home.project.homedirForServer,
|
||||
recentlyClosed: home.project.recentlyClosed,
|
||||
homedir: home.project.homedir,
|
||||
select: home.project.select,
|
||||
add: home.project.add,
|
||||
openNewSession: home.project.openProjectNewSession,
|
||||
|
||||
@@ -31,13 +31,11 @@ export type HomeProjectsViewProps = {
|
||||
language: ReturnType<typeof useLanguage>
|
||||
servers: ServerConnection.Any[]
|
||||
projects: LocalProject[]
|
||||
recentlyClosed: LocalProject[]
|
||||
selection: HomeProjectSelection
|
||||
homedir: string
|
||||
serverHealth: (server: ServerConnection.Any) => ServerHealth | undefined
|
||||
projectsForServer: (server: ServerConnection.Any) => LocalProject[]
|
||||
recentForServer: (server: ServerConnection.Any) => LocalProject[]
|
||||
showRecentForServer: (server: ServerConnection.Any) => boolean
|
||||
onDismissRecent: (server: ServerConnection.Any) => void
|
||||
homedirForServer: (server: ServerConnection.Any) => string
|
||||
collapsed: (server: ServerConnection.Any) => boolean
|
||||
canDefaultServer: boolean
|
||||
defaultServerKey: ServerConnection.Key | null | undefined
|
||||
@@ -83,16 +81,7 @@ export function HomeProjectsView(props: HomeProjectsViewProps) {
|
||||
>
|
||||
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
|
||||
<div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
|
||||
<Show
|
||||
when={
|
||||
props.servers.length === 1 &&
|
||||
!(
|
||||
props.projects.length === 0 &&
|
||||
props.showRecentForServer(props.servers[0]) &&
|
||||
props.recentForServer(props.servers[0]).length > 0
|
||||
)
|
||||
}
|
||||
>
|
||||
<Show when={props.servers.length === 1 && !(props.projects.length === 0 && props.recentlyClosed.length > 0)}>
|
||||
<TooltipV2 placement="bottom" value={props.language.t("home.project.add")}>
|
||||
<IconButtonV2
|
||||
data-action="home-add-project"
|
||||
@@ -113,16 +102,8 @@ export function HomeProjectsView(props: HomeProjectsViewProps) {
|
||||
fallback={
|
||||
<div class="pr-3">
|
||||
<Show
|
||||
when={!props.showRecentForServer(props.servers[0]) && props.projects.length > 0}
|
||||
fallback={
|
||||
<HomeProjectEmpty
|
||||
{...props}
|
||||
{...contextMenuProps}
|
||||
server={props.servers[0]}
|
||||
projects={props.projects}
|
||||
items={props.showRecentForServer(props.servers[0]) ? props.recentForServer(props.servers[0]) : []}
|
||||
/>
|
||||
}
|
||||
when={props.projects.length > 0}
|
||||
fallback={<HomeProjectEmpty {...props} server={props.servers[0]} items={props.recentlyClosed} />}
|
||||
>
|
||||
<HomeProjectList {...props} {...contextMenuProps} server={props.servers[0]} items={props.projects} />
|
||||
</Show>
|
||||
@@ -133,11 +114,8 @@ export function HomeProjectsView(props: HomeProjectsViewProps) {
|
||||
<For each={props.servers}>
|
||||
{(item) => {
|
||||
const projects = () => props.projectsForServer(item)
|
||||
const recent = () => props.recentForServer(item)
|
||||
const healthy = () => !!props.serverHealth(item)?.healthy
|
||||
const hasProjects = () => projects().length > 0
|
||||
const showRecent = () => props.showRecentForServer(item)
|
||||
const hasChildren = () => hasProjects() || (showRecent() && recent().length > 0)
|
||||
const collapsed = () => props.collapsed(item)
|
||||
return (
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
@@ -147,25 +125,11 @@ export function HomeProjectsView(props: HomeProjectsViewProps) {
|
||||
{...contextMenuProps}
|
||||
selected={props.selection.server === ServerConnection.key(item) && !props.selection.directory}
|
||||
collapsed={collapsed()}
|
||||
hasChildren={hasChildren()}
|
||||
health={props.serverHealth(item)}
|
||||
/>
|
||||
<Show when={healthy() && hasChildren() && !collapsed()}>
|
||||
<Show when={healthy() && hasProjects() && !collapsed()}>
|
||||
<div class="mx-3 h-px bg-v2-border-border-base" />
|
||||
<Show
|
||||
when={!showRecent() && hasProjects()}
|
||||
fallback={
|
||||
<HomeProjectEmpty
|
||||
{...props}
|
||||
{...contextMenuProps}
|
||||
server={item}
|
||||
projects={projects()}
|
||||
items={showRecent() ? recent() : []}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<HomeProjectList {...props} {...contextMenuProps} server={item} items={projects()} />
|
||||
</Show>
|
||||
<HomeProjectList {...props} {...contextMenuProps} server={item} items={projects()} />
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
@@ -229,11 +193,10 @@ function HomeServerRow(props: {
|
||||
server: ServerConnection.Any
|
||||
selected: boolean
|
||||
collapsed: boolean
|
||||
hasChildren: boolean
|
||||
health: ServerHealth | undefined
|
||||
}) {
|
||||
const healthy = () => !!props.health?.healthy
|
||||
const canToggle = () => healthy() && props.hasChildren
|
||||
const canToggle = () => healthy() && props.projectsForServer(props.server).length > 0
|
||||
const contextMenuID = () => serverContextMenuID(props.server)
|
||||
onCleanup(() => {
|
||||
const id = contextMenuID()
|
||||
@@ -416,55 +379,37 @@ function HomeProjectSlot(
|
||||
}
|
||||
|
||||
function HomeProjectEmpty(
|
||||
props: HomeProjectsViewProps &
|
||||
HomeProjectsContextMenuProps & {
|
||||
server: ServerConnection.Any
|
||||
projects: LocalProject[]
|
||||
items: LocalProject[]
|
||||
},
|
||||
props: HomeProjectsViewProps & {
|
||||
server: ServerConnection.Any
|
||||
items: LocalProject[]
|
||||
},
|
||||
) {
|
||||
const unreachable = () => props.serverHealth(props.server)?.healthy === false
|
||||
return (
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<Show when={props.projects.length > 0}>
|
||||
<HomeProjectList {...props} server={props.server} items={props.projects} />
|
||||
</Show>
|
||||
<Show when={props.projects.length === 0}>
|
||||
<HomeProjectNavButton
|
||||
type="button"
|
||||
data-action="home-add-project-row"
|
||||
class="disabled:opacity-60 [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted"
|
||||
disabled={unreachable()}
|
||||
onClick={() => props.onChooseProject(props.server)}
|
||||
>
|
||||
<IconV2 name="folder-add-left" size="small" />
|
||||
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("home.project.add")}</span>
|
||||
</HomeProjectNavButton>
|
||||
</Show>
|
||||
<HomeProjectNavButton
|
||||
type="button"
|
||||
data-action="home-add-project-row"
|
||||
class="disabled:opacity-60 [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted"
|
||||
disabled={unreachable()}
|
||||
onClick={() => props.onChooseProject(props.server)}
|
||||
>
|
||||
<IconV2 name="folder-add-left" size="small" />
|
||||
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("home.project.add")}</span>
|
||||
</HomeProjectNavButton>
|
||||
<Show when={props.items.length > 0}>
|
||||
<div class="group/recent relative mt-3 flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-1">
|
||||
<div class="mt-3 flex h-7 min-w-0 shrink-0 items-center pl-1.5 pr-3">
|
||||
<div class="text-v2-text-text-faint [font-weight:530]">{props.language.t("home.recentlyClosed")}</div>
|
||||
<TooltipV2 placement="bottom" value={props.language.t("common.dismiss")}>
|
||||
<IconButtonV2
|
||||
data-action="home-dismiss-recent-projects"
|
||||
class="opacity-0 group-hover/recent:opacity-100 focus-visible:opacity-100"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<IconV2 name="close" />}
|
||||
aria-label={props.language.t("common.dismiss")}
|
||||
onClick={() => props.onDismissRecent(props.server)}
|
||||
/>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
<For each={props.items}>
|
||||
{(project) => <HomeSuggestedProjectRow {...props} project={project} server={props.server} />}
|
||||
{(project) => <HomeRecentlyClosedRow {...props} project={project} server={props.server} />}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeSuggestedProjectRow(
|
||||
function HomeRecentlyClosedRow(
|
||||
props: HomeProjectsViewProps & {
|
||||
project: LocalProject
|
||||
server: ServerConnection.Any
|
||||
@@ -472,44 +417,24 @@ function HomeSuggestedProjectRow(
|
||||
) {
|
||||
const unreachable = () => props.serverHealth(props.server)?.healthy === false
|
||||
const path = () => {
|
||||
const home = props.homedirForServer(props.server)
|
||||
const home = props.homedir
|
||||
const worktree = props.project.worktree
|
||||
if (home && (worktree === home || worktree.startsWith(`${home}/`))) return `~${worktree.slice(home.length)}`
|
||||
return worktree
|
||||
}
|
||||
return (
|
||||
<div class="group/project relative flex h-7 min-w-0 items-center rounded-[6px]">
|
||||
<TooltipV2 class="w-full" placement="right" value={path()}>
|
||||
<HomeProjectNavButton
|
||||
type="button"
|
||||
data-component="home-recent-project-row"
|
||||
class="pr-10 disabled:opacity-60"
|
||||
disabled={unreachable()}
|
||||
onClick={() => props.onAddProjects(props.server, [props.project.worktree])}
|
||||
>
|
||||
<HomeProjectAvatar project={props.project} outline />
|
||||
<span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
|
||||
</HomeProjectNavButton>
|
||||
</TooltipV2>
|
||||
<div
|
||||
class={`
|
||||
hover-reveal absolute right-1 top-1/2 flex -translate-y-1/2
|
||||
group-hover/project:opacity-100 focus-within:opacity-100
|
||||
`}
|
||||
<TooltipV2 placement="right" value={path()}>
|
||||
<HomeProjectNavButton
|
||||
type="button"
|
||||
data-component="home-recently-closed-row"
|
||||
class="disabled:opacity-60"
|
||||
disabled={unreachable()}
|
||||
onClick={() => props.onAddProjects(props.server, [props.project.worktree])}
|
||||
>
|
||||
<TooltipV2 placement="bottom" value={props.language.t("home.project.add")}>
|
||||
<IconButtonV2
|
||||
data-action="home-add-recent-project"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<IconV2 name="plus" />}
|
||||
aria-label={props.language.t("home.project.add")}
|
||||
disabled={unreachable()}
|
||||
onClick={() => props.onAddProjects(props.server, [props.project.worktree])}
|
||||
/>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
</div>
|
||||
<HomeProjectAvatar project={props.project} outline />
|
||||
<span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
|
||||
</HomeProjectNavButton>
|
||||
</TooltipV2>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,33 +1,18 @@
|
||||
import type { HomeProjectsController } from "./home-projects-controller"
|
||||
import { HomeProjectsView } from "./home-projects-view"
|
||||
import type { HomeScrollController } from "./home-scroll-controller"
|
||||
import { ServerConnection } from "@/context/servers"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
export function HomeProjects(props: { projects: HomeProjectsController; scroll: HomeScrollController }) {
|
||||
const recentMode = new Map<ServerConnection.Key, boolean>()
|
||||
const [dismissedRecent, setDismissedRecent] = createStore({} as Record<string, boolean>)
|
||||
const showRecentForServer = (server: ServerConnection.Any) => {
|
||||
const key = ServerConnection.key(server)
|
||||
if (dismissedRecent[key]) return false
|
||||
if (recentMode.get(key)) return true
|
||||
if (props.projects.server.projects(server).length > 0) return false
|
||||
recentMode.set(key, true)
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
<HomeProjectsView
|
||||
language={props.projects.copy.language}
|
||||
servers={props.projects.server.list()}
|
||||
projects={props.projects.project.list()}
|
||||
recentlyClosed={props.projects.project.recentlyClosed()}
|
||||
selection={props.projects.selection.value()}
|
||||
homedir={props.projects.project.homedir()}
|
||||
serverHealth={props.projects.server.health}
|
||||
projectsForServer={props.projects.server.projects}
|
||||
recentForServer={props.projects.project.recentForServer}
|
||||
showRecentForServer={showRecentForServer}
|
||||
onDismissRecent={(server) => setDismissedRecent(ServerConnection.key(server), true)}
|
||||
homedirForServer={props.projects.project.homedirForServer}
|
||||
collapsed={props.projects.server.collapsed}
|
||||
canDefaultServer={props.projects.server.canDefault()}
|
||||
defaultServerKey={props.projects.server.defaultKey()}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
export * as ConfigImagePlugin from "./image.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config.js"
|
||||
import { Image } from "../../image.js"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.config.image",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const image = yield* Image.Service
|
||||
const loaded = { entries: yield* config.entries() }
|
||||
yield* image.transform((draft) => {
|
||||
for (const entry of loaded.entries) {
|
||||
if (entry.type !== "document") continue
|
||||
const configured = entry.info.media?.image
|
||||
if (!configured) continue
|
||||
draft.configure({
|
||||
...(configured.auto_resize === undefined ? {} : { autoResize: configured.auto_resize }),
|
||||
...(configured.max_width === undefined ? {} : { maxWidth: configured.max_width }),
|
||||
...(configured.max_height === undefined ? {} : { maxHeight: configured.max_height }),
|
||||
...(configured.max_base64_bytes === undefined ? {} : { maxBase64Bytes: configured.max_base64_bytes }),
|
||||
})
|
||||
}
|
||||
})
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
Stream.runForEach(() =>
|
||||
config.entries().pipe(
|
||||
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
|
||||
Effect.andThen(image.reload()),
|
||||
),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}),
|
||||
})
|
||||
+33
-17
@@ -2,8 +2,8 @@ export * as Image from "./image.js"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Config } from "./config.js"
|
||||
import { FileSystem } from "./filesystem.js"
|
||||
import { State } from "./state.js"
|
||||
|
||||
export class ResizerUnavailableError extends Schema.TaggedErrorClass<ResizerUnavailableError>()(
|
||||
"Image.ResizerUnavailableError",
|
||||
@@ -32,7 +32,18 @@ export class SizeError extends Schema.TaggedErrorClass<SizeError>()("Image.SizeE
|
||||
}
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
export type Limits = {
|
||||
autoResize: boolean
|
||||
maxWidth: number
|
||||
maxHeight: number
|
||||
maxBase64Bytes: number
|
||||
}
|
||||
|
||||
export type Draft = {
|
||||
configure: (limits: Partial<Limits>) => void
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly normalize: (
|
||||
resource: string,
|
||||
content: FileSystem.Content & { readonly encoding: "base64" },
|
||||
@@ -47,7 +58,23 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Im
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const state = State.create<Limits, Draft>({
|
||||
name: "image",
|
||||
initial: () => ({
|
||||
autoResize: true,
|
||||
maxWidth: 2_000,
|
||||
maxHeight: 2_000,
|
||||
maxBase64Bytes: 5 * 1024 * 1024,
|
||||
}),
|
||||
draft: (draft) => ({
|
||||
configure: (limits) => {
|
||||
if (limits.autoResize !== undefined) draft.autoResize = limits.autoResize
|
||||
if (limits.maxWidth !== undefined) draft.maxWidth = limits.maxWidth
|
||||
if (limits.maxHeight !== undefined) draft.maxHeight = limits.maxHeight
|
||||
if (limits.maxBase64Bytes !== undefined) draft.maxBase64Bytes = limits.maxBase64Bytes
|
||||
},
|
||||
}),
|
||||
})
|
||||
const loadAdapter = yield* Effect.cached(
|
||||
Effect.tryPromise({
|
||||
try: () => import("./image/photon.js"),
|
||||
@@ -58,22 +85,11 @@ const layer = Layer.effect(
|
||||
resource: string,
|
||||
content: FileSystem.Content & { readonly encoding: "base64" },
|
||||
) {
|
||||
const image = Object.assign(
|
||||
{},
|
||||
...(yield* config.entries()).flatMap((entry) =>
|
||||
entry.type === "document" && entry.info.media?.image ? [entry.info.media.image] : [],
|
||||
),
|
||||
)
|
||||
const normalize = yield* loadAdapter
|
||||
return yield* normalize(resource, content, {
|
||||
autoResize: image.auto_resize ?? true,
|
||||
maxWidth: image.max_width ?? 2_000,
|
||||
maxHeight: image.max_height ?? 2_000,
|
||||
maxBase64Bytes: image.max_base64_bytes ?? 5 * 1024 * 1024,
|
||||
})
|
||||
return yield* normalize(resource, content, state.get())
|
||||
})
|
||||
return Service.of({ normalize })
|
||||
return Service.of({ transform: state.transform, reload: state.reload, normalize })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Config.node] })
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [] })
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Config } from "../config.js"
|
||||
import { Credential } from "../credential.js"
|
||||
import { ConfigAgentPlugin } from "../config/plugin/agent.js"
|
||||
import { ConfigCommandPlugin } from "../config/plugin/command.js"
|
||||
import { ConfigImagePlugin } from "../config/plugin/image.js"
|
||||
import { ConfigInstructionPlugin } from "../config/plugin/instruction.js"
|
||||
import { ConfigProviderPlugin } from "../config/plugin/provider.js"
|
||||
import { ConfigPolicyPlugin } from "../config/plugin/policy.js"
|
||||
@@ -224,6 +225,7 @@ const post = [
|
||||
ConfigReferencePlugin.Plugin,
|
||||
ConfigAgentPlugin.Plugin,
|
||||
ConfigCommandPlugin.Plugin,
|
||||
ConfigImagePlugin.Plugin,
|
||||
ConfigSkillPlugin.Plugin,
|
||||
ConfigProviderPlugin.Plugin,
|
||||
ConfigWebSearchPlugin.Plugin,
|
||||
|
||||
@@ -18,6 +18,7 @@ import { LLMGatewayPlugin } from "./provider/llmgateway.js"
|
||||
import { LMStudioPlugin } from "./provider/lmstudio.js"
|
||||
import { MistralPlugin } from "./provider/mistral.js"
|
||||
import { NvidiaPlugin } from "./provider/nvidia.js"
|
||||
import { OllamaPlugin } from "./provider/ollama.js"
|
||||
import { OpenAIPlugin } from "./provider/openai.js"
|
||||
import { SnowflakeCortexPlugin } from "./provider/snowflake-cortex.js"
|
||||
import { OpenAICompatiblePlugin } from "./provider/openai-compatible.js"
|
||||
@@ -28,6 +29,7 @@ import { SapAICorePlugin } from "./provider/sap-ai-core.js"
|
||||
import { TogetherAIPlugin } from "./provider/togetherai.js"
|
||||
import { VercelPlugin } from "./provider/vercel.js"
|
||||
import { VenicePlugin } from "./provider/venice.js"
|
||||
import { VLLMPlugin } from "./provider/vllm.js"
|
||||
import { XAIPlugin } from "./provider/xai.js"
|
||||
import { ZenmuxPlugin } from "./provider/zenmux.js"
|
||||
import type { PluginInternal } from "./internal.js"
|
||||
@@ -52,6 +54,7 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
|
||||
LMStudioPlugin,
|
||||
MistralPlugin,
|
||||
NvidiaPlugin,
|
||||
OllamaPlugin,
|
||||
OpencodePlugin,
|
||||
SnowflakeCortexPlugin,
|
||||
OpenAICompatiblePlugin,
|
||||
@@ -62,6 +65,7 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
|
||||
TogetherAIPlugin,
|
||||
VercelPlugin,
|
||||
VenicePlugin,
|
||||
VLLMPlugin,
|
||||
XAIPlugin,
|
||||
ZenmuxPlugin,
|
||||
DynamicProviderPlugin,
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Document, type Entry } from "@opencode-ai/schema/config"
|
||||
import { Duration, Effect, Schedule, Schema, Semaphore, Stream } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { Config } from "../../config.js"
|
||||
import { Model } from "../../model.js"
|
||||
import { Provider } from "../../provider.js"
|
||||
import type { PluginInternal } from "../internal.js"
|
||||
|
||||
const providerID = "ollama"
|
||||
|
||||
const Details = Schema.Struct({
|
||||
parent_model: Schema.String.pipe(Schema.optional),
|
||||
format: Schema.String,
|
||||
family: Schema.String,
|
||||
families: Schema.Array(Schema.String).pipe(Schema.optional),
|
||||
parameter_size: Schema.String,
|
||||
quantization_level: Schema.String,
|
||||
})
|
||||
|
||||
const RemoteModel = Schema.Struct({
|
||||
name: Schema.String,
|
||||
model: Schema.String,
|
||||
remote_model: Schema.String.pipe(Schema.optional),
|
||||
remote_host: Schema.String.pipe(Schema.optional),
|
||||
modified_at: Schema.String,
|
||||
size: Schema.Int,
|
||||
digest: Schema.String,
|
||||
details: Details,
|
||||
})
|
||||
|
||||
const TagsResponse = Schema.Struct({ models: Schema.Array(RemoteModel) })
|
||||
const ShowRequest = Schema.Struct({ model: Schema.String })
|
||||
const ShowResponse = Schema.Struct({
|
||||
parameters: Schema.String.pipe(Schema.optional),
|
||||
license: Schema.String.pipe(Schema.optional),
|
||||
modified_at: Schema.String.pipe(Schema.optional),
|
||||
details: Details.pipe(Schema.optional),
|
||||
template: Schema.String.pipe(Schema.optional),
|
||||
capabilities: Schema.Array(Schema.String).pipe(Schema.optional),
|
||||
model_info: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
})
|
||||
|
||||
type DiscoveredModel = typeof RemoteModel.Type & { show: typeof ShowResponse.Type }
|
||||
type Discovery = {
|
||||
checked: number
|
||||
apiKey?: string
|
||||
models?: DiscoveredModel[]
|
||||
shows: Map<string, { digest: string; info: typeof ShowResponse.Type }>
|
||||
}
|
||||
|
||||
const discovery = new Map<string, Discovery>()
|
||||
const discoveryLock = Semaphore.makeUnsafe(1)
|
||||
|
||||
export function make(origin = "http://127.0.0.1:11434", interval: Duration.Input = "30 seconds") {
|
||||
return define({
|
||||
id: "opencode.provider.ollama",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
|
||||
const config = yield* Config.Service
|
||||
const source = { current: configured(yield* config.entries(), origin) }
|
||||
const loaded = { models: [] as DiscoveredModel[], hash: "[]" }
|
||||
|
||||
yield* ctx.integration.transform((integrations) => {
|
||||
if (loaded.models.length === 0) return
|
||||
integrations.remove(providerID)
|
||||
})
|
||||
|
||||
yield* ctx.catalog.transform((catalog) => {
|
||||
if (loaded.models.length === 0) return
|
||||
for (const model of catalog.provider.get(providerID)?.models.values() ?? []) {
|
||||
catalog.model.remove(providerID, model.id)
|
||||
}
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.name = "Ollama"
|
||||
provider.activation = "enabled"
|
||||
provider.package = "@opencode-ai/ai/providers/openai-compatible"
|
||||
provider.settings = {
|
||||
baseURL: source.current.baseURL,
|
||||
provider: providerID,
|
||||
apiKey: source.current.apiKey ?? "",
|
||||
}
|
||||
provider.integrationID = undefined
|
||||
})
|
||||
for (const item of loaded.models) {
|
||||
catalog.model.update(providerID, item.model, (model) => {
|
||||
model.modelID = Model.ID.make(item.model)
|
||||
model.name = item.name || item.model
|
||||
model.family = item.show.details?.family
|
||||
? Model.Family.make(item.show.details.family)
|
||||
: item.details.family
|
||||
? Model.Family.make(item.details.family)
|
||||
: undefined
|
||||
model.capabilities = {
|
||||
tools: item.show.capabilities?.includes("tools") ?? false,
|
||||
input: ["text", ...(item.show.capabilities?.includes("vision") ? ["image"] : [])],
|
||||
output: ["text"],
|
||||
}
|
||||
model.limit = {
|
||||
context:
|
||||
Object.entries(item.show.model_info ?? {}).flatMap(([key, value]) =>
|
||||
key.endsWith(".context_length") && typeof value === "number" && value > 0 ? [value] : [],
|
||||
)[0] ?? 0,
|
||||
output: 0,
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const discover = Effect.fn("OllamaPlugin.discover")(function* () {
|
||||
const current = source.current
|
||||
if (!current.tagsEndpoint || !current.showEndpoint) return undefined
|
||||
return yield* discoveryLock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const cached = discovery.get(current.tagsEndpoint)
|
||||
if (cached && cached.apiKey === current.apiKey && Date.now() - cached.checked < Duration.toMillis(interval))
|
||||
return { source: current, models: cached.models }
|
||||
const previous: Discovery =
|
||||
cached && cached.apiKey === current.apiKey
|
||||
? cached
|
||||
: { checked: 0, apiKey: current.apiKey, shows: new Map() }
|
||||
discovery.set(current.tagsEndpoint, { ...previous, checked: Date.now(), apiKey: current.apiKey })
|
||||
const tagsRequest = current.apiKey
|
||||
? HttpClientRequest.get(current.tagsEndpoint).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.bearerToken(current.apiKey),
|
||||
)
|
||||
: HttpClientRequest.get(current.tagsEndpoint).pipe(HttpClientRequest.acceptJson)
|
||||
const response = yield* http
|
||||
.execute(tagsRequest)
|
||||
.pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(TagsResponse)), Effect.timeout("1 second"))
|
||||
const summaries = response.models
|
||||
.filter((model) => model.model.length > 0)
|
||||
.toSorted((a, b) => a.model.localeCompare(b.model))
|
||||
const shows = new Map<string, { digest: string; info: typeof ShowResponse.Type }>()
|
||||
const models = yield* Effect.forEach(
|
||||
summaries,
|
||||
(model) =>
|
||||
Effect.gen(function* () {
|
||||
const saved = previous.shows.get(model.model)
|
||||
const info =
|
||||
saved?.digest === model.digest
|
||||
? saved.info
|
||||
: yield* HttpClientRequest.post(current.showEndpoint).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
current.apiKey ? HttpClientRequest.bearerToken(current.apiKey) : (request) => request,
|
||||
HttpClientRequest.schemaBodyJson(ShowRequest)({ model: model.model }),
|
||||
Effect.flatMap(http.execute),
|
||||
Effect.flatMap(HttpClientResponse.schemaBodyJson(ShowResponse)),
|
||||
Effect.timeout("1 second"),
|
||||
)
|
||||
shows.set(model.model, { digest: model.digest, info })
|
||||
return { ...model, show: info }
|
||||
}).pipe(Effect.catch(() => Effect.succeed(undefined))),
|
||||
{ concurrency: 4 },
|
||||
)
|
||||
const filtered = models.filter(
|
||||
(model): model is DiscoveredModel =>
|
||||
model !== undefined && (model.show.capabilities?.includes("completion") ?? false),
|
||||
)
|
||||
discovery.set(current.tagsEndpoint, {
|
||||
checked: Date.now(),
|
||||
apiKey: current.apiKey,
|
||||
models: filtered,
|
||||
shows,
|
||||
})
|
||||
return { source: current, models: filtered }
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const refresh = Effect.fn("OllamaPlugin.refresh")(function* () {
|
||||
const result = yield* discover()
|
||||
if (!result?.models || result.source !== source.current) return
|
||||
const hash = JSON.stringify(result.models)
|
||||
if (hash === loaded.hash) return
|
||||
loaded.models = result.models
|
||||
loaded.hash = hash
|
||||
yield* ctx.integration.reload()
|
||||
yield* ctx.catalog.reload()
|
||||
})
|
||||
|
||||
// Keep the last successful inventory through transient outages instead of flickering model availability.
|
||||
yield* refresh().pipe(Effect.ignore, Effect.repeat(Schedule.spaced(interval)), Effect.forkScoped)
|
||||
const reload = Effect.fn("OllamaPlugin.reload")(function* () {
|
||||
const next = configured(yield* config.entries(), origin)
|
||||
if (
|
||||
next.baseURL === source.current.baseURL &&
|
||||
next.apiKey === source.current.apiKey &&
|
||||
next.tagsEndpoint === source.current.tagsEndpoint
|
||||
)
|
||||
return
|
||||
source.current = next
|
||||
loaded.models = []
|
||||
loaded.hash = "[]"
|
||||
yield* ctx.integration.reload()
|
||||
yield* ctx.catalog.reload()
|
||||
yield* refresh().pipe(Effect.ignore)
|
||||
})
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
Stream.runForEach(reload),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}),
|
||||
} satisfies PluginInternal.InternalPlugin)
|
||||
}
|
||||
|
||||
export const OllamaPlugin = make()
|
||||
|
||||
function configured(entries: readonly Entry[], origin: string) {
|
||||
const settings = entries
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.flatMap((entry) => {
|
||||
const settings = entry.info.providers?.[providerID]?.settings
|
||||
return settings ? [settings] : []
|
||||
})
|
||||
.reduce<Provider.Settings | undefined>((result, item) => Provider.mergeOverlay(result, item), undefined)
|
||||
const baseURL = (
|
||||
typeof settings?.baseURL === "string" ? settings.baseURL : `${origin.replace(/\/+$/, "")}/v1`
|
||||
).replace(/\/+$/, "")
|
||||
const apiKey = typeof settings?.apiKey === "string" ? settings.apiKey : undefined
|
||||
if (!URL.canParse(baseURL)) return { baseURL, apiKey }
|
||||
const url = new URL(baseURL)
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return { baseURL, apiKey }
|
||||
const prefix = url.pathname.endsWith("/v1") ? url.pathname.slice(0, -3) : url.pathname.replace(/\/+$/, "")
|
||||
url.pathname = `${prefix}/api/tags`
|
||||
url.search = ""
|
||||
url.hash = ""
|
||||
const tagsEndpoint = url.toString()
|
||||
url.pathname = `${prefix}/api/show`
|
||||
return { baseURL, apiKey, tagsEndpoint, showEndpoint: url.toString() }
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Document, type Entry } from "@opencode-ai/schema/config"
|
||||
import { Duration, Effect, Schedule, Schema, Semaphore, Stream } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { Config } from "../../config.js"
|
||||
import { Model } from "../../model.js"
|
||||
import { Provider } from "../../provider.js"
|
||||
import type { PluginInternal } from "../internal.js"
|
||||
|
||||
const providerID = "vllm"
|
||||
|
||||
const RemoteModel = Schema.Struct({
|
||||
id: Schema.String,
|
||||
owned_by: Schema.String,
|
||||
max_model_len: Schema.NullOr(Schema.Int),
|
||||
})
|
||||
|
||||
const Response = Schema.Struct({ data: Schema.Array(RemoteModel) })
|
||||
const discovery = new Map<string, { checked: number; apiKey?: string; models?: (typeof RemoteModel.Type)[] }>()
|
||||
const discoveryLock = Semaphore.makeUnsafe(1)
|
||||
|
||||
export function make(origin = "http://127.0.0.1:8000", interval: Duration.Input = "30 seconds") {
|
||||
return define({
|
||||
id: "opencode.provider.vllm",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
|
||||
const config = yield* Config.Service
|
||||
const source = { current: configured(yield* config.entries(), origin) }
|
||||
const loaded = { models: [] as (typeof RemoteModel.Type)[], hash: "[]" }
|
||||
|
||||
yield* ctx.integration.transform((integrations) => {
|
||||
if (loaded.models.length === 0) return
|
||||
integrations.remove(providerID)
|
||||
})
|
||||
|
||||
yield* ctx.catalog.transform((catalog) => {
|
||||
if (loaded.models.length === 0) return
|
||||
for (const model of catalog.provider.get(providerID)?.models.values() ?? []) {
|
||||
catalog.model.remove(providerID, model.id)
|
||||
}
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.name = "vLLM"
|
||||
provider.package = "@opencode-ai/ai/providers/openai-compatible"
|
||||
provider.settings = {
|
||||
baseURL: source.current.baseURL,
|
||||
provider: providerID,
|
||||
apiKey: source.current.apiKey ?? "",
|
||||
}
|
||||
provider.integrationID = undefined
|
||||
provider.activation = "enabled"
|
||||
})
|
||||
for (const item of loaded.models) {
|
||||
catalog.model.update(providerID, item.id, (model) => {
|
||||
model.modelID = Model.ID.make(item.id)
|
||||
model.name = item.id
|
||||
// Tool calling depends on vLLM server flags and parsers that model discovery does not report.
|
||||
model.capabilities = { tools: false, input: ["text"], output: ["text"] }
|
||||
model.limit = { context: item.max_model_len ?? 0, output: 0 }
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const discover = Effect.fn("VLLMPlugin.discover")(function* () {
|
||||
const current = source.current
|
||||
if (!current.healthEndpoint || !current.modelsEndpoint) return undefined
|
||||
return yield* discoveryLock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const endpoint = `${current.healthEndpoint}\n${current.modelsEndpoint}`
|
||||
const cached = discovery.get(endpoint)
|
||||
if (cached && cached.apiKey === current.apiKey && Date.now() - cached.checked < Duration.toMillis(interval))
|
||||
return { source: current, models: cached.models }
|
||||
discovery.set(endpoint, {
|
||||
checked: Date.now(),
|
||||
apiKey: current.apiKey,
|
||||
models: cached && cached.apiKey === current.apiKey ? cached.models : undefined,
|
||||
})
|
||||
const request = (endpoint: string) =>
|
||||
current.apiKey
|
||||
? HttpClientRequest.get(endpoint).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.bearerToken(current.apiKey),
|
||||
)
|
||||
: HttpClientRequest.get(endpoint).pipe(HttpClientRequest.acceptJson)
|
||||
yield* http.execute(request(current.healthEndpoint)).pipe(Effect.timeout("1 second"))
|
||||
const response = yield* http
|
||||
.execute(request(current.modelsEndpoint))
|
||||
.pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(Response)), Effect.timeout("1 second"))
|
||||
const models = response.data
|
||||
.filter((model) => model.owned_by === providerID && model.id.length > 0)
|
||||
.toSorted((a, b) => a.id.localeCompare(b.id))
|
||||
discovery.set(endpoint, { checked: Date.now(), apiKey: current.apiKey, models })
|
||||
return { source: current, models }
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const refresh = Effect.fn("VLLMPlugin.refresh")(function* () {
|
||||
const result = yield* discover()
|
||||
if (!result?.models || result.source !== source.current) return
|
||||
const hash = JSON.stringify(result.models)
|
||||
if (hash === loaded.hash) return
|
||||
loaded.models = result.models
|
||||
loaded.hash = hash
|
||||
yield* ctx.integration.reload()
|
||||
yield* ctx.catalog.reload()
|
||||
})
|
||||
|
||||
// Keep the last successful inventory through transient outages instead of flickering model availability.
|
||||
yield* refresh().pipe(Effect.ignore, Effect.repeat(Schedule.spaced(interval)), Effect.forkScoped)
|
||||
const reload = Effect.fn("VLLMPlugin.reload")(function* () {
|
||||
const next = configured(yield* config.entries(), origin)
|
||||
if (
|
||||
next.baseURL === source.current.baseURL &&
|
||||
next.apiKey === source.current.apiKey &&
|
||||
next.healthEndpoint === source.current.healthEndpoint &&
|
||||
next.modelsEndpoint === source.current.modelsEndpoint
|
||||
)
|
||||
return
|
||||
source.current = next
|
||||
loaded.models = []
|
||||
loaded.hash = "[]"
|
||||
yield* ctx.integration.reload()
|
||||
yield* ctx.catalog.reload()
|
||||
yield* refresh().pipe(Effect.ignore)
|
||||
})
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
Stream.runForEach(reload),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}),
|
||||
} satisfies PluginInternal.InternalPlugin)
|
||||
}
|
||||
|
||||
export const VLLMPlugin = make()
|
||||
|
||||
function configured(entries: readonly Entry[], origin: string) {
|
||||
const settings = entries
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.flatMap((entry) => {
|
||||
const settings = entry.info.providers?.[providerID]?.settings
|
||||
return settings ? [settings] : []
|
||||
})
|
||||
.reduce<Provider.Settings | undefined>((result, item) => Provider.mergeOverlay(result, item), undefined)
|
||||
const baseURL = (
|
||||
typeof settings?.baseURL === "string" ? settings.baseURL : `${origin.replace(/\/+$/, "")}/v1`
|
||||
).replace(/\/+$/, "")
|
||||
const apiKey = typeof settings?.apiKey === "string" ? settings.apiKey : undefined
|
||||
if (!URL.canParse(baseURL)) return { baseURL, apiKey }
|
||||
const models = new URL(baseURL)
|
||||
if (models.protocol !== "http:" && models.protocol !== "https:") return { baseURL, apiKey }
|
||||
models.pathname = `${models.pathname.replace(/\/+$/, "")}/models`
|
||||
models.search = ""
|
||||
models.hash = ""
|
||||
const health = new URL(baseURL)
|
||||
const path = health.pathname.replace(/\/+$/, "")
|
||||
const prefix = path.endsWith("/v1") ? path.slice(0, -3) : path
|
||||
health.pathname = `${prefix}/health`
|
||||
health.search = ""
|
||||
health.hash = ""
|
||||
return { baseURL, apiKey, healthEndpoint: health.toString(), modelsEndpoint: models.toString() }
|
||||
}
|
||||
@@ -29,10 +29,41 @@ V1 documentation and syntax may be consulted only when the user explicitly
|
||||
asks about V1 or when needed as migration input. Outputs and recommendations
|
||||
must still use V2 unless the user specifically requests a V1 result.
|
||||
|
||||
## [Configuration](https://opencode.ai/v2/docs/config)
|
||||
## [CLI](https://opencode.ai/v2/docs/cli)
|
||||
|
||||
OpenCode configuration uses JSON or JSONC. Include the published schema so the
|
||||
user's editor can validate fields and provide autocomplete:
|
||||
For questions about the terminal interface, command-line invocation, `run`,
|
||||
`mini`, terminal providers, or other CLI behavior, fetch the
|
||||
[CLI guide](https://opencode.ai/v2/docs/cli) and the relevant page linked from
|
||||
that section.
|
||||
|
||||
CLI and TUI preferences are separate from OpenCode's server and project
|
||||
configuration. They live in the global `~/.config/opencode/cli.json`, or
|
||||
`$XDG_CONFIG_HOME/opencode/cli.json` when `XDG_CONFIG_HOME` is set. There is no
|
||||
project-local CLI configuration. Most preferences can also be changed from the
|
||||
TUI by pressing `Ctrl+P` and selecting **Open settings**.
|
||||
|
||||
Fetch the full [CLI configuration guide](https://opencode.ai/v2/docs/cli/config)
|
||||
before editing `cli.json`. It covers terminal-only settings such as themes,
|
||||
keybindings, terminal plugins, scrolling, attention alerts, diff presentation,
|
||||
and terminal integration. Do not put these settings in `opencode.json(c)`.
|
||||
|
||||
### [Keybinds](https://opencode.ai/v2/docs/cli/keybinds)
|
||||
|
||||
Configure keybindings under `keybinds` in `cli.json`. The leader key is the
|
||||
`keybinds.leader` entry; leader timing is configured separately under
|
||||
`leader.timeout`. Bindings can use a string, an array of strings, or an object
|
||||
when event behavior such as `preventDefault` is required. Disable a binding
|
||||
with `"none"` or `false`.
|
||||
|
||||
Never guess a command ID, default binding, or accepted key syntax. Fetch the
|
||||
full [keybind reference](https://opencode.ai/v2/docs/cli/keybinds), which lists
|
||||
the current IDs and defaults, before answering or editing a binding.
|
||||
|
||||
## [OpenCode configuration](https://opencode.ai/v2/docs/config)
|
||||
|
||||
OpenCode's server and project configuration uses JSON or JSONC. Include the
|
||||
published schema so the user's editor can validate fields and provide
|
||||
autocomplete:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
@@ -55,6 +86,10 @@ Common configuration fields include `model`, `default_agent`, `permissions`,
|
||||
`agents`, `commands`, `plugins`, `providers`, `mcp`, `skills`, `instructions`,
|
||||
`references`, `formatter`, and `lsp`.
|
||||
|
||||
This configuration is distinct from `cli.json`. Use the
|
||||
[CLI configuration guide](https://opencode.ai/v2/docs/cli/config) for terminal
|
||||
preferences, especially themes and keybindings.
|
||||
|
||||
Do not guess field names or shapes. Fetch the V2 configuration guide and its
|
||||
linked topic guide as the source of truth, and preserve unrelated settings when
|
||||
editing an existing file. Keep the published `$schema` URL in configuration
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigImagePlugin } from "@opencode-ai/core/config/plugin/image"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Image } from "@opencode-ai/core/image"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { Document, Event, Info, type Entry } from "@opencode-ai/schema/config"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "../plugin/fixture"
|
||||
|
||||
const it = testEffect(Layer.merge(PluginTestLayer, AppNodeBuilder.build(Image.node)))
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
const content = {
|
||||
uri: "file:///pixel.png",
|
||||
content: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
encoding: "base64" as const,
|
||||
mime: "image/png",
|
||||
}
|
||||
|
||||
describe("ConfigImagePlugin.Plugin", () => {
|
||||
it.live("merges image limits and reloads changed config", () =>
|
||||
Effect.gen(function* () {
|
||||
const image = yield* Image.Service
|
||||
const bus = yield* Bus.Service
|
||||
const config = yield* Config.Test
|
||||
const plugins = yield* Plugin.Service
|
||||
yield* ConfigImagePlugin.Plugin.effect(yield* PluginHost.make(plugins))
|
||||
|
||||
expect(yield* limits(image)).toEqual({ maxWidth: 1_200, maxHeight: 900, maxBytes: 1 })
|
||||
|
||||
yield* config.setEntries([document({ auto_resize: false, max_width: 700, max_base64_bytes: 1 })])
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
yield* waitUntil(
|
||||
limits(image).pipe(
|
||||
Effect.map((current) => current.maxWidth === 700 && current.maxHeight === 2_000 && current.maxBytes === 1),
|
||||
),
|
||||
)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
Config.testLayer([
|
||||
document({ auto_resize: false, max_width: 1_200 }),
|
||||
document({ max_height: 900, max_base64_bytes: 1 }),
|
||||
]),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
function document(image: NonNullable<typeof Info.Encoded.media>["image"]): Entry {
|
||||
return new Document({ type: "document", info: decode({ media: { image } }) })
|
||||
}
|
||||
|
||||
const limits = Effect.fnUntraced(function* (image: Image.Interface) {
|
||||
const error = yield* image.normalize("pixel.png", content).pipe(Effect.flip, Effect.orDie)
|
||||
if (error._tag !== "Image.SizeError") return yield* Effect.die(error)
|
||||
return { maxWidth: error.maxWidth, maxHeight: error.maxHeight, maxBytes: error.maxBytes }
|
||||
})
|
||||
|
||||
const waitUntil = Effect.fnUntraced(function* (condition: Effect.Effect<boolean>) {
|
||||
for (let attempt = 0; attempt < 200; attempt++) {
|
||||
if (yield* condition) return
|
||||
yield* Effect.sleep("10 millis")
|
||||
}
|
||||
yield* Effect.die(new Error("Timed out waiting for image config reload"))
|
||||
})
|
||||
@@ -0,0 +1,342 @@
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { OllamaPlugin, make } from "@opencode-ai/core/plugin/provider/ollama"
|
||||
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { Document, Event, Info } from "@opencode-ai/schema/config"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Duration, Effect, Layer, Schema } from "effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(Layer.merge(PluginTestLayer, Config.testLayer()))
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
const decodeShowRequest = Schema.decodeUnknownSync(Schema.Struct({ model: Schema.String }))
|
||||
|
||||
const addPlugin = Effect.fn(function* (origin: string, interval: Duration.Input = "1 hour") {
|
||||
const plugin = yield* Plugin.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* make(origin, interval).effect(host)
|
||||
})
|
||||
|
||||
function eventually<A>(
|
||||
effect: Effect.Effect<A>,
|
||||
predicate: (value: A) => boolean,
|
||||
remaining = 3000,
|
||||
): Effect.Effect<A, Error> {
|
||||
return Effect.gen(function* () {
|
||||
const value = yield* effect
|
||||
if (predicate(value)) return value
|
||||
if (remaining === 0) return yield* Effect.fail(new Error("Timed out waiting for value"))
|
||||
yield* Effect.promise(() => Bun.sleep(1))
|
||||
return yield* eventually(effect, predicate, remaining - 1)
|
||||
})
|
||||
}
|
||||
|
||||
describe("OllamaPlugin", () => {
|
||||
it.live("discovers local completion models and native metadata", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const requests: Array<{ method: string; path: string; model?: string }> = []
|
||||
return {
|
||||
requests,
|
||||
server: Bun.serve({
|
||||
port: 0,
|
||||
fetch: async (request) => {
|
||||
const path = new URL(request.url).pathname
|
||||
if (request.method === "GET") {
|
||||
requests.push({ method: request.method, path })
|
||||
return Response.json({
|
||||
models: [
|
||||
summary("gemma3:4b", "gemma-digest", "gemma3"),
|
||||
summary("nomic-embed", "embed-digest"),
|
||||
summary("removed-model", "removed-digest"),
|
||||
],
|
||||
})
|
||||
}
|
||||
const body = decodeShowRequest(await request.json())
|
||||
requests.push({ method: request.method, path, model: body.model })
|
||||
if (body.model === "removed-model") return new Response("Not found", { status: 404 })
|
||||
return Response.json(
|
||||
body.model === "gemma3:4b"
|
||||
? {
|
||||
capabilities: ["completion", "tools", "vision"],
|
||||
model_info: { "gemma3.context_length": 131_072 },
|
||||
}
|
||||
: show({ family: "nomic-bert", capabilities: ["embedding"], context: 8192 }),
|
||||
)
|
||||
},
|
||||
}),
|
||||
}
|
||||
}),
|
||||
({ requests, server }) =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = Provider.ID.make("ollama")
|
||||
expect(OllamaPlugin.id).toBe("opencode.provider.ollama")
|
||||
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.ollama")
|
||||
yield* addPlugin(server.url.origin)
|
||||
const model = yield* eventually(
|
||||
catalog.model.get(providerID, Model.ID.make("gemma3:4b")),
|
||||
(item) => item !== undefined,
|
||||
)
|
||||
|
||||
expect(yield* catalog.provider.get(providerID)).toEqual({
|
||||
id: providerID,
|
||||
name: "Ollama",
|
||||
activation: "enabled",
|
||||
package: "@opencode-ai/ai/providers/openai-compatible",
|
||||
settings: { baseURL: `${server.url.origin}/v1`, provider: "ollama", apiKey: "" },
|
||||
})
|
||||
expect(model).toMatchObject({
|
||||
modelID: "gemma3:4b",
|
||||
name: "gemma3:4b",
|
||||
family: "gemma3",
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
limit: { context: 131_072, output: 0 },
|
||||
})
|
||||
expect(yield* catalog.model.get(providerID, Model.ID.make("nomic-embed"))).toBeUndefined()
|
||||
expect(requests).toContainEqual({ method: "GET", path: "/api/tags" })
|
||||
expect(requests).toContainEqual({ method: "POST", path: "/api/show", model: "gemma3:4b" })
|
||||
expect(requests).toContainEqual({ method: "POST", path: "/api/show", model: "nomic-embed" })
|
||||
expect(requests).toContainEqual({ method: "POST", path: "/api/show", model: "removed-model" })
|
||||
}),
|
||||
({ server }) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("refreshes changed digests and retains inventory through transient failures", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const state = { digest: "digest-1", context: 32_768, fail: false }
|
||||
const requests = { tags: 0, show: 0 }
|
||||
return {
|
||||
state,
|
||||
requests,
|
||||
server: Bun.serve({
|
||||
port: 0,
|
||||
fetch: async (request) => {
|
||||
if (request.method === "GET") {
|
||||
requests.tags++
|
||||
if (state.fail) return new Response("unavailable", { status: 503 })
|
||||
return Response.json({ models: [summary("qwen3:8b", state.digest, "qwen3")] })
|
||||
}
|
||||
decodeShowRequest(await request.json())
|
||||
requests.show++
|
||||
return Response.json(
|
||||
show({ family: "qwen3", capabilities: ["completion", "tools"], context: state.context }),
|
||||
)
|
||||
},
|
||||
}),
|
||||
}
|
||||
}),
|
||||
({ state, requests, server }) =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = Provider.ID.make("ollama")
|
||||
const modelID = Model.ID.make("qwen3:8b")
|
||||
yield* addPlugin(server.url.origin, "5 millis")
|
||||
yield* eventually(catalog.model.get(providerID, modelID), (model) => model?.limit.context === 32_768)
|
||||
yield* eventually(
|
||||
Effect.sync(() => requests.tags),
|
||||
(count) => count >= 2,
|
||||
)
|
||||
expect(requests.show).toBe(1)
|
||||
|
||||
state.digest = "digest-2"
|
||||
state.context = 65_536
|
||||
yield* eventually(catalog.model.get(providerID, modelID), (model) => model?.limit.context === 65_536)
|
||||
expect(requests.show).toBe(2)
|
||||
|
||||
state.fail = true
|
||||
yield* Effect.promise(() => Bun.sleep(30))
|
||||
expect((yield* catalog.model.get(providerID, modelID))?.limit.context).toBe(65_536)
|
||||
}),
|
||||
({ server }) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("replaces and restores the same-ID Models.dev provider", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const models = [summary("discovered-model", "digest")]
|
||||
return {
|
||||
models,
|
||||
server: Bun.serve({
|
||||
port: 0,
|
||||
fetch: async (request) => {
|
||||
if (request.method === "GET") return Response.json({ models })
|
||||
decodeShowRequest(await request.json())
|
||||
return Response.json(show({ capabilities: ["completion"], context: 32_768 }))
|
||||
},
|
||||
}),
|
||||
}
|
||||
}),
|
||||
({ models, server }) =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const providerID = Provider.ID.make("ollama")
|
||||
yield* integrations.transform((draft) => {
|
||||
draft.update(Integration.ID.make("ollama"), (integration) => {
|
||||
integration.name = "Ollama"
|
||||
})
|
||||
draft.method.update({
|
||||
integrationID: Integration.ID.make("ollama"),
|
||||
method: { type: "env", names: ["OLLAMA_API_KEY"] },
|
||||
})
|
||||
})
|
||||
yield* catalog.transform((draft) => {
|
||||
draft.provider.update(providerID, (provider) => {
|
||||
provider.name = "Ollama"
|
||||
provider.package = "aisdk:@ai-sdk/openai-compatible"
|
||||
provider.integrationID = Integration.ID.make("ollama")
|
||||
})
|
||||
draft.model.update(providerID, Model.ID.make("static-model"), () => {})
|
||||
})
|
||||
|
||||
yield* addPlugin(server.url.origin, "5 millis")
|
||||
yield* eventually(
|
||||
catalog.model.get(providerID, Model.ID.make("discovered-model")),
|
||||
(model) => model !== undefined,
|
||||
)
|
||||
expect(yield* integrations.get(Integration.ID.make("ollama"))).toBeUndefined()
|
||||
expect((yield* catalog.provider.get(providerID))?.activation).toBe("enabled")
|
||||
expect(yield* catalog.model.get(providerID, Model.ID.make("static-model"))).toBeUndefined()
|
||||
|
||||
models.splice(0)
|
||||
yield* eventually(
|
||||
catalog.model.get(providerID, Model.ID.make("static-model")),
|
||||
(model) => model !== undefined,
|
||||
)
|
||||
expect(yield* catalog.model.get(providerID, Model.ID.make("discovered-model"))).toBeUndefined()
|
||||
expect(yield* integrations.get(Integration.ID.make("ollama"))).toBeDefined()
|
||||
expect((yield* catalog.provider.get(providerID))?.activation).toBe("auto")
|
||||
expect((yield* catalog.provider.get(providerID))?.integrationID).toBe(Integration.ID.make("ollama"))
|
||||
}),
|
||||
({ server }) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"reloads layered endpoint and bearer authentication settings",
|
||||
() =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const requests: Array<{ authorization: string | null; method: string; path: string }> = []
|
||||
return {
|
||||
requests,
|
||||
initial: Bun.serve({
|
||||
port: 0,
|
||||
fetch: async (request) => {
|
||||
if (request.method === "GET")
|
||||
return Response.json({ models: [summary("initial-model", "initial-digest")] })
|
||||
decodeShowRequest(await request.json())
|
||||
return Response.json(show({ capabilities: ["completion"], context: 4096 }))
|
||||
},
|
||||
}),
|
||||
configured: Bun.serve({
|
||||
port: 0,
|
||||
fetch: async (request) => {
|
||||
requests.push({
|
||||
authorization: request.headers.get("authorization"),
|
||||
method: request.method,
|
||||
path: new URL(request.url).pathname,
|
||||
})
|
||||
if (request.method === "GET")
|
||||
return Response.json({ models: [summary("configured-model", "configured-digest")] })
|
||||
decodeShowRequest(await request.json())
|
||||
return Response.json(show({ capabilities: ["completion", "vision"], context: 65_536 }))
|
||||
},
|
||||
}),
|
||||
}
|
||||
}),
|
||||
({ requests, initial, configured }) =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const config = yield* Config.Test
|
||||
const providerID = Provider.ID.make("ollama")
|
||||
yield* addPlugin(initial.url.origin)
|
||||
yield* eventually(
|
||||
catalog.model.get(providerID, Model.ID.make("initial-model")),
|
||||
(model) => model !== undefined,
|
||||
)
|
||||
|
||||
const baseURL = `${configured.url.origin}/proxy/v1`
|
||||
yield* config.setEntries([configuration({ baseURL, apiKey: "old" }), configuration({ apiKey: "secret" })])
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
yield* eventually(
|
||||
catalog.model.get(providerID, Model.ID.make("configured-model")),
|
||||
(model) => model !== undefined,
|
||||
)
|
||||
expect(requests).toContainEqual({ authorization: "Bearer secret", method: "GET", path: "/proxy/api/tags" })
|
||||
expect(requests).toContainEqual({ authorization: "Bearer secret", method: "POST", path: "/proxy/api/show" })
|
||||
expect(yield* catalog.model.get(providerID, Model.ID.make("initial-model"))).toBeUndefined()
|
||||
expect((yield* catalog.provider.get(providerID))?.settings).toEqual({
|
||||
baseURL,
|
||||
provider: "ollama",
|
||||
apiKey: "secret",
|
||||
})
|
||||
|
||||
requests.splice(0)
|
||||
yield* config.setEntries([configuration({ baseURL, apiKey: "secret" }), configuration({ apiKey: null })])
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
yield* eventually(catalog.provider.get(providerID), (provider) => provider?.settings?.apiKey === "")
|
||||
expect(requests).toContainEqual({ authorization: null, method: "GET", path: "/proxy/api/tags" })
|
||||
expect(requests).toContainEqual({ authorization: null, method: "POST", path: "/proxy/api/show" })
|
||||
}),
|
||||
({ initial, configured }) => Effect.promise(() => Promise.all([initial.stop(true), configured.stop(true)])),
|
||||
),
|
||||
10_000,
|
||||
)
|
||||
})
|
||||
|
||||
function summary(model: string, digest: string, family = "llama") {
|
||||
return {
|
||||
name: model,
|
||||
model,
|
||||
modified_at: "2026-01-01T00:00:00Z",
|
||||
size: 1_000_000,
|
||||
digest,
|
||||
details: {
|
||||
format: "gguf",
|
||||
family,
|
||||
families: [family],
|
||||
parameter_size: "8B",
|
||||
quantization_level: "Q4_K_M",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function show(input: { family?: string; capabilities: string[]; context: number }) {
|
||||
const family = input.family ?? "llama"
|
||||
return {
|
||||
parameters: "temperature 0.7",
|
||||
details: {
|
||||
parent_model: "",
|
||||
format: "gguf",
|
||||
family,
|
||||
families: [family],
|
||||
parameter_size: "8B",
|
||||
quantization_level: "Q4_K_M",
|
||||
},
|
||||
capabilities: input.capabilities,
|
||||
model_info: {
|
||||
"general.architecture": family,
|
||||
[`${family}.context_length`]: input.context,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function configuration(settings: Record<string, string | null>) {
|
||||
return new Document({
|
||||
type: "document",
|
||||
info: decode({ providers: { ollama: { settings } } }),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
||||
import { make, VLLMPlugin } from "@opencode-ai/core/plugin/provider/vllm"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { Document, Event, Info } from "@opencode-ai/schema/config"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Duration, Effect, Layer, Schema } from "effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(Layer.merge(PluginTestLayer, Config.testLayer()))
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
const addPlugin = Effect.fn(function* (origin: string, interval: Duration.Input = "1 hour") {
|
||||
const plugin = yield* Plugin.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* make(origin, interval).effect(host)
|
||||
})
|
||||
|
||||
function eventually<A>(
|
||||
effect: Effect.Effect<A>,
|
||||
predicate: (value: A) => boolean,
|
||||
remaining = 3000,
|
||||
): Effect.Effect<A, Error> {
|
||||
return Effect.gen(function* () {
|
||||
const value = yield* effect
|
||||
if (predicate(value)) return value
|
||||
if (remaining === 0) return yield* Effect.fail(new Error("Timed out waiting for value"))
|
||||
yield* Effect.promise(() => Bun.sleep(1))
|
||||
return yield* eventually(effect, predicate, remaining - 1)
|
||||
})
|
||||
}
|
||||
|
||||
const remoteModel = (id: string, max_model_len = 32_768, owned_by = "vllm") => ({
|
||||
id,
|
||||
object: "model",
|
||||
created: 1,
|
||||
owned_by,
|
||||
root: id,
|
||||
parent: null,
|
||||
max_model_len,
|
||||
permission: [],
|
||||
})
|
||||
|
||||
describe("VLLMPlugin", () => {
|
||||
it.live("waits for readiness and discovers official vLLM model metadata", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const state = { healthy: false, models: 0 }
|
||||
return {
|
||||
state,
|
||||
server: Bun.serve({
|
||||
port: 0,
|
||||
fetch: (request) => {
|
||||
const path = new URL(request.url).pathname
|
||||
if (path === "/health") return new Response(null, { status: state.healthy ? 200 : 503 })
|
||||
state.models++
|
||||
return Response.json({
|
||||
object: "list",
|
||||
data: [remoteModel("Qwen/Qwen3-Coder", 65_536), remoteModel("foreign-model", 4096, "other")],
|
||||
})
|
||||
},
|
||||
}),
|
||||
}
|
||||
}),
|
||||
({ state, server }) =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = Provider.ID.make("vllm")
|
||||
expect(VLLMPlugin.id).toBe("opencode.provider.vllm")
|
||||
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.vllm")
|
||||
yield* addPlugin(server.url.origin, "5 millis")
|
||||
yield* Effect.promise(() => Bun.sleep(20))
|
||||
expect(yield* catalog.provider.get(providerID)).toBeUndefined()
|
||||
expect(state.models).toBe(0)
|
||||
|
||||
state.healthy = true
|
||||
const model = yield* eventually(
|
||||
catalog.model.get(providerID, Model.ID.make("Qwen/Qwen3-Coder")),
|
||||
(item) => item !== undefined,
|
||||
)
|
||||
expect(yield* catalog.provider.get(providerID)).toEqual({
|
||||
id: providerID,
|
||||
name: "vLLM",
|
||||
package: "@opencode-ai/ai/providers/openai-compatible",
|
||||
settings: { baseURL: `${server.url.origin}/v1`, provider: "vllm", apiKey: "" },
|
||||
activation: "enabled",
|
||||
})
|
||||
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(providerID)
|
||||
expect(model).toMatchObject({
|
||||
modelID: "Qwen/Qwen3-Coder",
|
||||
name: "Qwen/Qwen3-Coder",
|
||||
capabilities: { tools: false, input: ["text"], output: ["text"] },
|
||||
limit: { context: 65_536, output: 0 },
|
||||
})
|
||||
expect(yield* catalog.model.get(providerID, Model.ID.make("foreign-model"))).toBeUndefined()
|
||||
}),
|
||||
({ server }) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("refreshes inventory while retaining the last success through transient failures", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const state = { failing: false, models: [remoteModel("first-model")] }
|
||||
return {
|
||||
state,
|
||||
server: Bun.serve({
|
||||
port: 0,
|
||||
fetch: (request) => {
|
||||
if (state.failing) return new Response(null, { status: 503 })
|
||||
if (new URL(request.url).pathname === "/health") return new Response()
|
||||
return Response.json({ object: "list", data: state.models })
|
||||
},
|
||||
}),
|
||||
}
|
||||
}),
|
||||
({ state, server }) =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = Provider.ID.make("vllm")
|
||||
yield* addPlugin(server.url.origin, "5 millis")
|
||||
yield* eventually(catalog.model.get(providerID, Model.ID.make("first-model")), (model) => model !== undefined)
|
||||
|
||||
state.failing = true
|
||||
state.models = [remoteModel("second-model")]
|
||||
yield* Effect.promise(() => Bun.sleep(30))
|
||||
expect(yield* catalog.model.get(providerID, Model.ID.make("first-model"))).toBeDefined()
|
||||
expect(yield* catalog.model.get(providerID, Model.ID.make("second-model"))).toBeUndefined()
|
||||
|
||||
state.failing = false
|
||||
yield* eventually(
|
||||
catalog.model.get(providerID, Model.ID.make("second-model")),
|
||||
(model) => model !== undefined,
|
||||
)
|
||||
expect(yield* catalog.model.get(providerID, Model.ID.make("first-model"))).toBeUndefined()
|
||||
}),
|
||||
({ server }) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("replaces and restores same-ID Models.dev entries after an empty success", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const models = [remoteModel("discovered-model")]
|
||||
return {
|
||||
models,
|
||||
server: Bun.serve({
|
||||
port: 0,
|
||||
fetch: (request) =>
|
||||
new URL(request.url).pathname === "/health"
|
||||
? new Response()
|
||||
: Response.json({ object: "list", data: models }),
|
||||
}),
|
||||
}
|
||||
}),
|
||||
({ models, server }) =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const providerID = Provider.ID.make("vllm")
|
||||
yield* integrations.transform((draft) => {
|
||||
draft.update(Integration.ID.make("vllm"), (integration) => {
|
||||
integration.name = "vLLM"
|
||||
})
|
||||
draft.method.update({
|
||||
integrationID: Integration.ID.make("vllm"),
|
||||
method: { type: "env", names: ["VLLM_API_KEY"] },
|
||||
})
|
||||
})
|
||||
yield* catalog.transform((draft) => {
|
||||
draft.provider.update(providerID, (provider) => {
|
||||
provider.name = "vLLM"
|
||||
provider.package = "aisdk:@ai-sdk/openai-compatible"
|
||||
provider.integrationID = Integration.ID.make("vllm")
|
||||
provider.activation = "auto"
|
||||
})
|
||||
draft.model.update(providerID, Model.ID.make("static-model"), () => {})
|
||||
})
|
||||
|
||||
yield* addPlugin(server.url.origin, "5 millis")
|
||||
yield* eventually(
|
||||
catalog.model.get(providerID, Model.ID.make("discovered-model")),
|
||||
(model) => model !== undefined,
|
||||
)
|
||||
expect(yield* integrations.get(Integration.ID.make("vllm"))).toBeUndefined()
|
||||
expect((yield* catalog.provider.get(providerID))?.integrationID).toBeUndefined()
|
||||
expect((yield* catalog.provider.get(providerID))?.activation).toBe("enabled")
|
||||
expect(yield* catalog.model.get(providerID, Model.ID.make("static-model"))).toBeUndefined()
|
||||
|
||||
models.splice(0)
|
||||
yield* eventually(
|
||||
catalog.model.get(providerID, Model.ID.make("static-model")),
|
||||
(model) => model !== undefined,
|
||||
)
|
||||
expect(yield* catalog.model.get(providerID, Model.ID.make("discovered-model"))).toBeUndefined()
|
||||
expect(yield* integrations.get(Integration.ID.make("vllm"))).toBeDefined()
|
||||
expect((yield* catalog.provider.get(providerID))?.integrationID).toBe(Integration.ID.make("vllm"))
|
||||
expect((yield* catalog.provider.get(providerID))?.activation).toBe("auto")
|
||||
}),
|
||||
({ server }) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"reloads layered custom endpoint and bearer authentication settings",
|
||||
() =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const requests: Array<{ authorization: string | null; path: string }> = []
|
||||
return {
|
||||
requests,
|
||||
initial: Bun.serve({
|
||||
port: 0,
|
||||
fetch: (request) =>
|
||||
new URL(request.url).pathname === "/health"
|
||||
? new Response()
|
||||
: Response.json({ object: "list", data: [remoteModel("initial-model")] }),
|
||||
}),
|
||||
configured: Bun.serve({
|
||||
port: 0,
|
||||
fetch: (request) => {
|
||||
requests.push({
|
||||
authorization: request.headers.get("authorization"),
|
||||
path: new URL(request.url).pathname,
|
||||
})
|
||||
if (new URL(request.url).pathname === "/proxy/health") return new Response()
|
||||
return Response.json({ object: "list", data: [remoteModel("configured-model")] })
|
||||
},
|
||||
}),
|
||||
}
|
||||
}),
|
||||
({ requests, initial, configured }) =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const config = yield* Config.Test
|
||||
const providerID = Provider.ID.make("vllm")
|
||||
yield* addPlugin(initial.url.origin)
|
||||
yield* eventually(
|
||||
catalog.model.get(providerID, Model.ID.make("initial-model")),
|
||||
(model) => model !== undefined,
|
||||
)
|
||||
|
||||
const baseURL = `${configured.url.origin}/proxy/v1`
|
||||
yield* config.setEntries([configuration({ baseURL }), configuration({ apiKey: "secret" })])
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
yield* eventually(
|
||||
catalog.model.get(providerID, Model.ID.make("configured-model")),
|
||||
(model) => model !== undefined,
|
||||
)
|
||||
|
||||
expect(requests).toContainEqual({ authorization: "Bearer secret", path: "/proxy/health" })
|
||||
expect(requests).toContainEqual({ authorization: "Bearer secret", path: "/proxy/v1/models" })
|
||||
expect(yield* catalog.model.get(providerID, Model.ID.make("initial-model"))).toBeUndefined()
|
||||
expect((yield* catalog.provider.get(providerID))?.settings).toEqual({
|
||||
baseURL,
|
||||
provider: "vllm",
|
||||
apiKey: "secret",
|
||||
})
|
||||
|
||||
requests.splice(0)
|
||||
yield* config.setEntries([configuration({ baseURL }), configuration({ apiKey: "next-secret" })])
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
yield* eventually(
|
||||
catalog.provider.get(providerID),
|
||||
(provider) => provider?.settings?.apiKey === "next-secret",
|
||||
)
|
||||
expect(requests).toContainEqual({ authorization: "Bearer next-secret", path: "/proxy/health" })
|
||||
expect(requests).toContainEqual({ authorization: "Bearer next-secret", path: "/proxy/v1/models" })
|
||||
}),
|
||||
({ initial, configured }) => Effect.promise(() => Promise.all([initial.stop(true), configured.stop(true)])),
|
||||
),
|
||||
10_000,
|
||||
)
|
||||
})
|
||||
|
||||
function configuration(settings: { baseURL?: string; apiKey?: string }) {
|
||||
return new Document({
|
||||
type: "document",
|
||||
info: decode({ providers: { vllm: { settings } } }),
|
||||
})
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
import { beforeEach, describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Exit, Layer, Stream } from "effect"
|
||||
import { Effect, Exit, Layer } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Document, Info } from "@opencode-ai/schema/config"
|
||||
import { ConfigMedia } from "@opencode-ai/schema/config/media"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
@@ -90,7 +88,7 @@ const permission = permissionLayer({
|
||||
),
|
||||
})
|
||||
const config = Config.testLayer()
|
||||
const imageLayer = AppNodeBuilder.build(Image.node, [[Config.node, config]])
|
||||
const imageLayer = AppNodeBuilder.build(Image.node)
|
||||
const testFileSystem = Layer.effect(
|
||||
FSUtil.Service,
|
||||
FSUtil.Service.use((fs) =>
|
||||
@@ -130,10 +128,9 @@ const mutation = Layer.succeed(
|
||||
},
|
||||
}),
|
||||
)
|
||||
const unavailableImage = Layer.succeed(
|
||||
Image.Service,
|
||||
Image.Service.of({ normalize: () => Effect.fail(new Image.ResizerUnavailableError()) }),
|
||||
)
|
||||
const unavailableImage = Layer.mock(Image.Service, {
|
||||
normalize: () => Effect.fail(new Image.ResizerUnavailableError()),
|
||||
})
|
||||
const readLayer = (imageLayer: Layer.Layer<Image.Service>) =>
|
||||
Layer.mergeAll(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, readToolNode]), [
|
||||
@@ -146,8 +143,9 @@ const readLayer = (imageLayer: Layer.Layer<Image.Service>) =>
|
||||
[Location.node, locationLayer],
|
||||
[Global.node, Global.layerWith({ data: Global.Path.data })],
|
||||
]),
|
||||
// Merge by reference so Config.Test resolves to the memoized instance.
|
||||
// Merge by reference so Config.Test and Image.Service resolve to the memoized instances.
|
||||
config,
|
||||
imageLayer,
|
||||
)
|
||||
const it = testEffect(readLayer(imageLayer))
|
||||
const itWithoutResizer = testEffect(readLayer(unavailableImage))
|
||||
@@ -384,17 +382,8 @@ describe("ReadTool", () => {
|
||||
encoding: "base64",
|
||||
mime: "image/png",
|
||||
}
|
||||
const configTest = yield* Config.Test
|
||||
yield* configTest.setEntries([
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Info({
|
||||
media: new ConfigMedia.Info({
|
||||
image: new ConfigMedia.Image({ auto_resize: false, max_width: 4 }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
])
|
||||
const image = yield* Image.Service
|
||||
yield* image.transform((draft) => draft.configure({ autoResize: false, maxWidth: 4 }))
|
||||
const registry = yield* Tool.Service
|
||||
|
||||
expect(
|
||||
@@ -427,15 +416,8 @@ describe("ReadTool", () => {
|
||||
encoding: "base64",
|
||||
mime: "image/png",
|
||||
}
|
||||
const configTest = yield* Config.Test
|
||||
yield* configTest.setEntries([
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Info({
|
||||
media: new ConfigMedia.Info({ image: new ConfigMedia.Image({ max_width: 4 }) }),
|
||||
}),
|
||||
}),
|
||||
])
|
||||
const image = yield* Image.Service
|
||||
yield* image.transform((draft) => draft.configure({ maxWidth: 4 }))
|
||||
const registry = yield* Tool.Service
|
||||
const result = yield* executeTool(registry, {
|
||||
sessionID,
|
||||
@@ -466,17 +448,8 @@ describe("ReadTool", () => {
|
||||
encoding: "base64",
|
||||
mime: "image/png",
|
||||
}
|
||||
const configTest = yield* Config.Test
|
||||
yield* configTest.setEntries([
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Info({
|
||||
media: new ConfigMedia.Info({
|
||||
image: new ConfigMedia.Image({ max_base64_bytes: 1 }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
])
|
||||
const image = yield* Image.Service
|
||||
yield* image.transform((draft) => draft.configure({ maxBase64Bytes: 1 }))
|
||||
const registry = yield* Tool.Service
|
||||
|
||||
expect(
|
||||
|
||||
@@ -9,7 +9,6 @@ import { app, BrowserWindow } from "electron"
|
||||
|
||||
import { Deferred, Effect, Fiber } from "effect"
|
||||
import contextMenu from "electron-context-menu"
|
||||
import type { DesktopRecentProject } from "@opencode-ai/app/desktop-menu"
|
||||
|
||||
import type { ServerReadyData } from "../preload/types"
|
||||
import { checkAppExists, resolveAppPath } from "./apps"
|
||||
@@ -237,7 +236,6 @@ const main = Effect.gen(function* () {
|
||||
registerRendererProtocol()
|
||||
setDockIcon()
|
||||
const updater = setupAutoUpdater(() => stopWslServers())
|
||||
const recentProjects = new Map<number, DesktopRecentProject[]>()
|
||||
const menuDeps = {
|
||||
trigger: (id: string) => {
|
||||
const win = getLastFocusedWindow()
|
||||
@@ -245,10 +243,6 @@ const main = Effect.gen(function* () {
|
||||
},
|
||||
checkForUpdates: () => void showUpdaterDialog(updater),
|
||||
relaunch,
|
||||
recentProjects: () => {
|
||||
const win = getLastFocusedWindow()
|
||||
return win ? (recentProjects.get(win.webContents.id) ?? []) : []
|
||||
},
|
||||
}
|
||||
registerIpcHandlers({
|
||||
killSidecar: () => undefined,
|
||||
@@ -279,17 +273,12 @@ const main = Effect.gen(function* () {
|
||||
setNativeTranslations: (bundle) => {
|
||||
if (setNativeTranslations(bundle)) createMenu(menuDeps)
|
||||
},
|
||||
setRecentProjects: (webContentsID, projects) => {
|
||||
recentProjects.set(webContentsID, projects)
|
||||
createMenu(menuDeps)
|
||||
},
|
||||
})
|
||||
registerUpdaterIpc(updater)
|
||||
void updater.start()
|
||||
const updateTimer = setInterval(() => void updater.check(), 10 * 60 * 1000)
|
||||
updateTimer.unref()
|
||||
app.once("will-quit", () => clearInterval(updateTimer))
|
||||
app.on("browser-window-focus", () => createMenu(menuDeps))
|
||||
yield* Effect.promise(() => startNetLog()).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.sync(() => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { stat } from "node:fs/promises"
|
||||
import { basename, join } from "node:path"
|
||||
import { app, BrowserWindow, clipboard, dialog, ipcMain, shell } from "electron"
|
||||
import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
|
||||
import type { DesktopMenuAction, DesktopRecentProject } from "@opencode-ai/app/desktop-menu"
|
||||
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
|
||||
import { parseDesktopNativeBundle, type DesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native"
|
||||
|
||||
import type { FatalRendererError, ServerReadyData, TitlebarTheme } from "../preload/types"
|
||||
@@ -49,7 +49,6 @@ type Deps = {
|
||||
exportDebugLogs: () => Promise<string>
|
||||
recordFatalRendererError: (error: FatalRendererError) => Promise<void> | void
|
||||
setNativeTranslations: (bundle: DesktopNativeBundle) => void
|
||||
setRecentProjects: (webContentsID: number, projects: DesktopRecentProject[]) => void
|
||||
}
|
||||
|
||||
export function registerIpcHandlers(deps: Deps) {
|
||||
@@ -93,20 +92,6 @@ export function registerIpcHandlers(deps: Deps) {
|
||||
if (!bundle) throw new Error("Invalid native translation bundle")
|
||||
deps.setNativeTranslations(bundle)
|
||||
})
|
||||
ipcMain.handle("set-recent-projects", (event: IpcMainInvokeEvent, value: unknown) => {
|
||||
if (event.senderFrame !== event.sender.mainFrame) throw new Error("Invalid recent projects sender")
|
||||
if (!Array.isArray(value)) throw new Error("Invalid recent projects")
|
||||
const projects = value.filter((item): item is DesktopRecentProject => {
|
||||
if (!item || typeof item !== "object") return false
|
||||
const project = item as Record<string, unknown>
|
||||
return (
|
||||
typeof project.command === "string" &&
|
||||
typeof project.label === "string" &&
|
||||
(project.server === undefined || typeof project.server === "string")
|
||||
)
|
||||
})
|
||||
deps.setRecentProjects(event.sender.id, projects.slice(0, 50))
|
||||
})
|
||||
ipcMain.handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => {
|
||||
try {
|
||||
const store = getStore(name)
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
DESKTOP_MENU,
|
||||
desktopMenuVisible,
|
||||
type DesktopMenuEntry,
|
||||
type DesktopRecentProject,
|
||||
type DesktopMenuRole,
|
||||
} from "@opencode-ai/app/desktop-menu"
|
||||
|
||||
@@ -17,7 +16,6 @@ type Deps = {
|
||||
trigger: (id: string) => void
|
||||
checkForUpdates: () => void
|
||||
relaunch: () => void
|
||||
recentProjects: () => DesktopRecentProject[]
|
||||
}
|
||||
|
||||
export function createMenu(deps: Deps) {
|
||||
@@ -46,30 +44,6 @@ function nativeItem(entry: DesktopMenuEntry, deps: Deps): MenuItemConstructorOpt
|
||||
enabled: entry.enabled === "updater" ? UPDATER_ENABLED : undefined,
|
||||
}
|
||||
|
||||
if (entry.dynamic === "recentProjects") {
|
||||
const projects = deps.recentProjects()
|
||||
const servers = new Map<string, DesktopRecentProject[]>()
|
||||
projects.forEach((project) => {
|
||||
if (!project.server) return
|
||||
servers.set(project.server, [...(servers.get(project.server) ?? []), project])
|
||||
})
|
||||
item.submenu = servers.size
|
||||
? [...servers.entries()].flatMap(([server, entries], index) => [
|
||||
...(index > 0 ? ([{ type: "separator" as const }] satisfies MenuItemConstructorOptions[]) : []),
|
||||
{ label: server, enabled: false },
|
||||
...entries.slice(0, 5).map((project) => ({
|
||||
label: project.label,
|
||||
click: () => deps.trigger(project.command),
|
||||
})),
|
||||
])
|
||||
: projects.slice(0, 5).map((project) => ({
|
||||
label: project.label,
|
||||
click: () => deps.trigger(project.command),
|
||||
}))
|
||||
item.enabled = item.submenu.length > 0
|
||||
return item
|
||||
}
|
||||
|
||||
if (entry.command) {
|
||||
const command = entry.command
|
||||
item.click = () => deps.trigger(command)
|
||||
|
||||
@@ -84,7 +84,6 @@ const api: ElectronAPI = {
|
||||
ipcRenderer.on("menu-command", handler)
|
||||
return () => ipcRenderer.removeListener("menu-command", handler)
|
||||
},
|
||||
setRecentProjects: (projects) => ipcRenderer.invoke("set-recent-projects", projects),
|
||||
onDeepLink: (cb) => {
|
||||
const handler = (_: unknown, urls: string[]) => cb(urls)
|
||||
ipcRenderer.on("deep-link", handler)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DesktopMenuAction, DesktopRecentProject } from "@opencode-ai/app/desktop-menu"
|
||||
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
|
||||
import type { WslServersPlatform } from "@opencode-ai/app/wsl/types"
|
||||
import type { UpdaterState } from "@opencode-ai/app/updater"
|
||||
import type { DesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native"
|
||||
@@ -71,7 +71,6 @@ export type ElectronAPI = {
|
||||
|
||||
getWindowID: () => Promise<string>
|
||||
onMenuCommand: (cb: (id: string) => void) => () => void
|
||||
setRecentProjects: (projects: DesktopRecentProject[]) => Promise<void>
|
||||
onDeepLink: (cb: (urls: string[]) => void) => () => void
|
||||
|
||||
openDirectoryPicker: (opts?: {
|
||||
|
||||
@@ -15,9 +15,7 @@ import {
|
||||
useCommand,
|
||||
useWslServers,
|
||||
useLanguage,
|
||||
useGlobal,
|
||||
} from "@opencode-ai/app"
|
||||
import { desktopRecentProjectCommand } from "@opencode-ai/app/desktop-menu"
|
||||
import type { UpdaterState } from "@opencode-ai/app/updater"
|
||||
import * as Sentry from "@sentry/solid"
|
||||
import type { AsyncStorage } from "@solid-primitives/storage"
|
||||
@@ -362,7 +360,6 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) {
|
||||
|
||||
function DesktopEffects() {
|
||||
const cmd = useCommand()
|
||||
const global = useGlobal()
|
||||
menuTrigger = (id) => cmd.trigger(id)
|
||||
|
||||
const theme = useTheme()
|
||||
@@ -376,22 +373,6 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) {
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const servers = global.servers.list()
|
||||
const multiple = servers.length > 1
|
||||
const projects = servers.flatMap((server) =>
|
||||
global
|
||||
.ensureServerCtx(server)
|
||||
.projects.recent()
|
||||
.map((project) => ({
|
||||
command: desktopRecentProjectCommand(ServerConnection.key(server), project.worktree),
|
||||
label: project.name ?? project.worktree.split(/[\\/]/).filter(Boolean).at(-1) ?? project.worktree,
|
||||
server: multiple ? (server.displayName ?? new URL(server.http.url).host) : undefined,
|
||||
})),
|
||||
)
|
||||
void window.api.setRecentProjects(projects)
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
NEW_SESSION_TAB_TITLE,
|
||||
sessionTabComplete,
|
||||
sessionTabDetail,
|
||||
sessionTabShortcutLabel,
|
||||
sessionTabNumberLabel,
|
||||
seedSessionTabMotion,
|
||||
sessionTabOverflowWidth,
|
||||
type SessionTab,
|
||||
@@ -426,7 +426,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
const value = session()
|
||||
return value ? data.project.get(value.projectID) : undefined
|
||||
})
|
||||
const numberWidth = () => 2
|
||||
const numberWidth = () => Math.max(2, String(items().length).length)
|
||||
const restingTitleWidth = () => Math.max(1, width() - numberWidth() - 2)
|
||||
const hoveredTitleWidth = () => Math.max(1, restingTitleWidth() - 1)
|
||||
const titleWidth = () => (hovered() === tab.sessionID ? hoveredTitleWidth() : restingTitleWidth())
|
||||
@@ -657,14 +657,14 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
backgroundColor={pulseBackground()}
|
||||
onLevel={setSweepLevel}
|
||||
/>
|
||||
<box zIndex={1} width="100%" flexDirection="row" paddingLeft={1} paddingRight={1}>
|
||||
<box zIndex={1} width="100%" flexDirection="row" paddingRight={1}>
|
||||
<text
|
||||
width={numberWidth()}
|
||||
width={numberWidth() + 1}
|
||||
fg={numberColor()}
|
||||
selectable={false}
|
||||
attributes={selected() ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
{sessionTabShortcutLabel(index())}
|
||||
{sessionTabNumberLabel(index()).padStart(numberWidth())}
|
||||
</text>
|
||||
<text
|
||||
width={titleWidth()}
|
||||
@@ -1040,8 +1040,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
const glows = () => !selected() && (status().attention || (!status().busy && status().unread !== undefined))
|
||||
const title = () => tab.title ?? "Untitled session"
|
||||
const tabNumber = createMemo(() => items().findIndex((item) => item.sessionID === tab.sessionID) + 1)
|
||||
// Shortcut labels stay one cell wide: 1-9, 0 for ten, then a neutral dot.
|
||||
const numberWidth = () => 2
|
||||
const numberWidth = () => Math.max(2, String(items().length).length)
|
||||
// Hovering reveals the close mark, so the title's right bound shifts left of it.
|
||||
const restingTitleWidth = () => Math.max(1, width() - 1 - numberWidth())
|
||||
const hoveredTitleWidth = () => Math.max(1, restingTitleWidth() - 2)
|
||||
@@ -1141,11 +1140,8 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
onLevel={setSweepLevel}
|
||||
/>
|
||||
<box zIndex={1} width="100%" flexDirection="row">
|
||||
<text width={1} selectable={false}>
|
||||
{" "}
|
||||
</text>
|
||||
<text width={numberWidth()} fg={numberColor()} selectable={false} attributes={bold()}>
|
||||
{tab === NEW_SESSION_TAB ? "+" : sessionTabShortcutLabel(tabNumber() - 1)}
|
||||
<text width={numberWidth() + 1} fg={numberColor()} selectable={false} attributes={bold()}>
|
||||
{(tab === NEW_SESSION_TAB ? "+" : sessionTabNumberLabel(tabNumber() - 1)).padStart(numberWidth())}
|
||||
</text>
|
||||
<text
|
||||
width={availableTitleWidth()}
|
||||
|
||||
@@ -7,10 +7,8 @@ export type SessionTabUnread = "activity" | "error"
|
||||
|
||||
export const NEW_SESSION_TAB_TITLE = "New session"
|
||||
|
||||
export function sessionTabShortcutLabel(index: number) {
|
||||
if (index >= 0 && index < 9) return String(index + 1)
|
||||
if (index === 9) return "0"
|
||||
return "·"
|
||||
export function sessionTabNumberLabel(index: number) {
|
||||
return String(index + 1)
|
||||
}
|
||||
|
||||
export function sessionTabDetail(
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
sessionTabComplete,
|
||||
sessionTabDetail,
|
||||
sessionTabOverflowWidth,
|
||||
sessionTabShortcutLabel,
|
||||
sessionTabNumberLabel,
|
||||
} from "../../src/context/session-tabs-model"
|
||||
|
||||
describe("session tabs", () => {
|
||||
@@ -25,8 +25,8 @@ describe("session tabs", () => {
|
||||
expect(sessionTabDetail("opencode", undefined, "main", true)).toBe("opencode")
|
||||
})
|
||||
|
||||
test("labels direct shortcut tabs and marks unbound tabs with a dot", () => {
|
||||
expect(Array.from({ length: 12 }, (_, index) => sessionTabShortcutLabel(index))).toEqual([
|
||||
test("labels tabs by ordinal", () => {
|
||||
expect(Array.from({ length: 12 }, (_, index) => sessionTabNumberLabel(index))).toEqual([
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
@@ -36,9 +36,9 @@ describe("session tabs", () => {
|
||||
"7",
|
||||
"8",
|
||||
"9",
|
||||
"0",
|
||||
"·",
|
||||
"·",
|
||||
"10",
|
||||
"11",
|
||||
"12",
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -152,6 +152,43 @@ provider and model configuration. An unknown variant fails model resolution inst
|
||||
|
||||
### Local models
|
||||
|
||||
#### Ollama
|
||||
|
||||
OpenCode automatically discovers language models from an Ollama server listening on its default address,
|
||||
`http://127.0.0.1:11434`. Discovered models use the `ollama` provider ID and Ollama's model name:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"model": "ollama/gemma3:4b",
|
||||
}
|
||||
```
|
||||
|
||||
OpenCode refreshes the inventory in the background and reads context, vision, and tool-use capabilities from Ollama.
|
||||
Embedding-only models are excluded because they cannot drive a session. Disable discovery with
|
||||
`"plugins": ["-opencode.provider.ollama"]`.
|
||||
|
||||
For a different host or port, configure Ollama's OpenAI-compatible base URL. Models are still discovered through the
|
||||
native Ollama API at the same path prefix:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"providers": {
|
||||
"ollama": {
|
||||
"settings": {
|
||||
"baseURL": "http://127.0.0.1:5678/v1",
|
||||
"apiKey": "{env:OLLAMA_API_KEY}",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Omit `apiKey` when the Ollama endpoint does not require bearer authentication.
|
||||
|
||||
#### LM Studio
|
||||
|
||||
OpenCode automatically discovers language models from an unauthenticated LM Studio server listening on its default
|
||||
address, `http://127.0.0.1:1234`. Discovered models use the `lmstudio` provider ID and LM Studio's model key:
|
||||
|
||||
@@ -184,6 +221,43 @@ For a different host or port, configure the OpenAI-compatible base URL. Models a
|
||||
|
||||
Omit `apiKey` when LM Studio authentication is disabled.
|
||||
|
||||
#### vLLM
|
||||
|
||||
OpenCode automatically discovers models from a vLLM server listening on its default address, `http://127.0.0.1:8000`.
|
||||
Discovered models use the `vllm` provider ID and the model ID reported by vLLM:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"model": "vllm/Qwen/Qwen3-Coder-30B-A3B-Instruct",
|
||||
}
|
||||
```
|
||||
|
||||
OpenCode checks vLLM's `/health` endpoint and refreshes `/v1/models` in the background. It uses the reported
|
||||
`max_model_len` as the context limit and only includes model cards owned by `vllm`. Discovered vLLM models advertise
|
||||
text input and output, but not vision or tools. Tool calling is conservative because vLLM enables it with server-level
|
||||
flags such as `--enable-auto-tool-choice` and `--tool-call-parser`, which model discovery does not report. Disable
|
||||
discovery with `"plugins": ["-opencode.provider.vllm"]`.
|
||||
|
||||
For a different endpoint or an authenticated server, configure its OpenAI-compatible base URL:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"providers": {
|
||||
"vllm": {
|
||||
"settings": {
|
||||
"baseURL": "http://127.0.0.1:9000/v1",
|
||||
"apiKey": "{env:VLLM_API_KEY}",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Omit `apiKey` when authentication is disabled. Path-prefixed proxy URLs are supported; for example,
|
||||
`https://example.com/vllm/v1` checks `/vllm/health` and discovers `/vllm/v1/models`.
|
||||
|
||||
For an OpenAI-compatible server, define a provider package, endpoint, and at least one model:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
|
||||
Reference in New Issue
Block a user