mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-18 13:49:25 -04:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d33039614f | |||
| 01ce44bd13 | |||
| 647ae85a04 | |||
| a1e018668c | |||
| d5451cdabe |
@@ -8,7 +8,7 @@ import { File } from "@opencode-ai/session-ui/file"
|
|||||||
import { Font } from "@opencode-ai/ui/font"
|
import { Font } from "@opencode-ai/ui/font"
|
||||||
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
||||||
import { MetaProvider } from "@solidjs/meta"
|
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 { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||||
import {
|
import {
|
||||||
type Component,
|
type Component,
|
||||||
@@ -37,8 +37,6 @@ import { SettingsProvider } from "@/context/settings"
|
|||||||
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
|
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
|
||||||
import { SDKProvider } from "@/context/sdk"
|
import { SDKProvider } from "@/context/sdk"
|
||||||
import { WslServersProvider } from "@/wsl/context"
|
import { WslServersProvider } from "@/wsl/context"
|
||||||
import { desktopRecentProjectCommand } from "@/desktop-menu"
|
|
||||||
import { displayName } from "@/pages/layout/helpers"
|
|
||||||
import { DirectoryDataProvider } from "@/pages/directory-layout"
|
import { DirectoryDataProvider } from "@/pages/directory-layout"
|
||||||
import Layout from "@/pages/layout"
|
import Layout from "@/pages/layout"
|
||||||
import { ErrorPage } from "./pages/error"
|
import { ErrorPage } from "./pages/error"
|
||||||
@@ -184,8 +182,6 @@ function DesktopCommands() {
|
|||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const global = useGlobal()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
|
|
||||||
command.register("desktop", () => {
|
command.register("desktop", () => {
|
||||||
const commands: CommandOption[] = []
|
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
|
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 { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
|
|
||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
import {
|
import { DESKTOP_MENU, desktopMenuVisible, type DesktopMenuAction, type DesktopMenuEntry } from "@/desktop-menu"
|
||||||
DESKTOP_MENU,
|
|
||||||
desktopMenuVisible,
|
|
||||||
desktopRecentProjectCommand,
|
|
||||||
type DesktopMenuAction,
|
|
||||||
type DesktopMenuEntry,
|
|
||||||
} from "@/desktop-menu"
|
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useLanguage } from "@/context/language"
|
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: {
|
export function WindowsAppMenu(props: {
|
||||||
command: ReturnType<typeof useCommand>
|
command: ReturnType<typeof useCommand>
|
||||||
@@ -26,7 +17,6 @@ export function WindowsAppMenu(props: {
|
|||||||
}) {
|
}) {
|
||||||
let lastFocused: HTMLElement | undefined
|
let lastFocused: HTMLElement | undefined
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const global = useGlobal()
|
|
||||||
|
|
||||||
const rememberFocus = () => {
|
const rememberFocus = () => {
|
||||||
const active = document.activeElement
|
const active = document.activeElement
|
||||||
@@ -100,49 +90,6 @@ export function WindowsAppMenu(props: {
|
|||||||
{(entry) => {
|
{(entry) => {
|
||||||
// Static menu data: an early return keeps the union narrowing a Show fallback would lose.
|
// Static menu data: an early return keeps the union narrowing a Show fallback would lose.
|
||||||
if (entry.type === "separator") return <DropdownMenu.Separator />
|
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 (
|
return (
|
||||||
<DesktopMenuItem
|
<DesktopMenuItem
|
||||||
label={entry.labelKey ? language.t(entry.labelKey) : ""}
|
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 { createStore } from "solid-js/store"
|
||||||
import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServers } from "./servers"
|
import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServers } from "./servers"
|
||||||
import { pathKey } from "@/utils/path-key"
|
import { pathKey } from "@/utils/path-key"
|
||||||
import { knownProjectWorktrees } from "./project-suggestions"
|
|
||||||
import { useServerHealth } from "@/utils/server-health"
|
import { useServerHealth } from "@/utils/server-health"
|
||||||
import { createServerSdkContext } from "./server-sdk"
|
import { createServerSdkContext } from "./server-sdk"
|
||||||
import { createServerSyncContext } from "./server-sync"
|
import { createServerSyncContext } from "./server-sync"
|
||||||
@@ -130,17 +129,6 @@ function createServerController(
|
|||||||
.slice(0, RECENTLY_CLOSED_DISPLAY_LIMIT)
|
.slice(0, RECENTLY_CLOSED_DISPLAY_LIMIT)
|
||||||
.map((worktree) => enrich({ worktree, expanded: false }))
|
.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 =
|
const isLocal =
|
||||||
(conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url))
|
(conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url))
|
||||||
@@ -153,8 +141,6 @@ function createServerController(
|
|||||||
...projects,
|
...projects,
|
||||||
list: projectsList,
|
list: projectsList,
|
||||||
recentlyClosed: recentlyClosedList,
|
recentlyClosed: recentlyClosedList,
|
||||||
known: knownProjectsList,
|
|
||||||
recent: recentProjectsList,
|
|
||||||
},
|
},
|
||||||
permission,
|
permission,
|
||||||
notification,
|
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 { describe, expect, test } from "bun:test"
|
||||||
import { DESKTOP_MENU, desktopRecentProjectCommand } from "./desktop-menu"
|
import { DESKTOP_MENU } from "./desktop-menu"
|
||||||
|
|
||||||
describe("desktop menu", () => {
|
describe("desktop menu", () => {
|
||||||
test("exports logs through the desktop command registry", () => {
|
test("exports logs through the desktop command registry", () => {
|
||||||
@@ -20,19 +20,4 @@ describe("desktop menu", () => {
|
|||||||
expect(windowMenu?.labelKey).toBe("desktop.menu.window")
|
expect(windowMenu?.labelKey).toBe("desktop.menu.window")
|
||||||
expect(roleItems.length).toBeGreaterThan(0)
|
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"
|
type: "item"
|
||||||
labelKey?: DesktopNativeKey
|
labelKey?: DesktopNativeKey
|
||||||
command?: string
|
command?: string
|
||||||
dynamic?: "recentProjects"
|
|
||||||
action?: DesktopMenuAction
|
action?: DesktopMenuAction
|
||||||
role?: DesktopMenuRole
|
role?: DesktopMenuRole
|
||||||
href?: string
|
href?: string
|
||||||
@@ -113,11 +112,6 @@ export const DESKTOP_MENU: DesktopMenu[] = [
|
|||||||
command: "project.open",
|
command: "project.open",
|
||||||
accelerator: { macos: "Cmd+O" },
|
accelerator: { macos: "Cmd+O" },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
type: "item",
|
|
||||||
labelKey: "desktop.menu.openRecentProjects",
|
|
||||||
dynamic: "recentProjects",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
type: "item",
|
type: "item",
|
||||||
labelKey: "desktop.menu.settings",
|
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) {
|
export function desktopMenuVisible(item: { platforms?: DesktopMenuPlatform[] }, platform: DesktopMenuPlatform) {
|
||||||
return !item.platforms || item.platforms.includes(platform)
|
return !item.platforms || item.platforms.includes(platform)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -242,7 +242,6 @@ export const DESKTOP_NATIVE_ENGLISH = {
|
|||||||
"desktop.menu.exportLogs": "Export Logs...",
|
"desktop.menu.exportLogs": "Export Logs...",
|
||||||
"desktop.menu.newSession": "New Session",
|
"desktop.menu.newSession": "New Session",
|
||||||
"desktop.menu.openProject": "Open Project...",
|
"desktop.menu.openProject": "Open Project...",
|
||||||
"desktop.menu.openRecentProjects": "Open Recent Projects",
|
|
||||||
"desktop.menu.newWindow": "New Window",
|
"desktop.menu.newWindow": "New Window",
|
||||||
"desktop.menu.closeWindow": "Close Window",
|
"desktop.menu.closeWindow": "Close Window",
|
||||||
"desktop.menu.undo": "Undo",
|
"desktop.menu.undo": "Undo",
|
||||||
|
|||||||
@@ -637,7 +637,7 @@ export const dict = {
|
|||||||
"home.title": "Home",
|
"home.title": "Home",
|
||||||
"home.projects": "Projects",
|
"home.projects": "Projects",
|
||||||
"home.project.add": "Add project",
|
"home.project.add": "Add project",
|
||||||
"home.recentlyClosed": "Recent projects",
|
"home.recentlyClosed": "Recently closed",
|
||||||
"home.server.collapse": "Collapse server projects",
|
"home.server.collapse": "Collapse server projects",
|
||||||
"home.server.expand": "Expand server projects",
|
"home.server.expand": "Expand server projects",
|
||||||
"home.sessions.search.placeholder": "Search sessions",
|
"home.sessions.search.placeholder": "Search sessions",
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
export { AppBaseProviders, AppInterface } from "./app"
|
export { AppBaseProviders, AppInterface } from "./app"
|
||||||
export { useLayout } from "./context/layout"
|
export { useLayout } from "./context/layout"
|
||||||
export { useGlobal } from "./context/global"
|
|
||||||
export { useServerSDK } from "./context/server-sdk"
|
export { useServerSDK } from "./context/server-sdk"
|
||||||
export { useServerSync } from "./context/server-sync"
|
export { useServerSync } from "./context/server-sync"
|
||||||
export { useServers as useServers } from "./context/servers"
|
export { useServers as useServers } from "./context/servers"
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export function createHomeController() {
|
|||||||
const focusedServerCtx = useServerCtx(focusedServer)
|
const focusedServerCtx = useServerCtx(focusedServer)
|
||||||
const focusedSync = () => focusedServerCtx()?.sync
|
const focusedSync = () => focusedServerCtx()?.sync
|
||||||
const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? [])
|
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 selectedProject = createMemo(() => projects().find((project) => project.worktree === selection().directory))
|
||||||
const newSessionProject = createMemo(
|
const newSessionProject = createMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -60,11 +62,11 @@ export function createHomeController() {
|
|||||||
},
|
},
|
||||||
project: {
|
project: {
|
||||||
list: projects,
|
list: projects,
|
||||||
|
recentlyClosed,
|
||||||
|
homedir,
|
||||||
selected: selectedProject,
|
selected: selectedProject,
|
||||||
newSession: newSessionProject,
|
newSession: newSessionProject,
|
||||||
forServer: (conn: ServerConnection.Any) => global.ensureServerCtx(conn).projects.list(),
|
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) => {
|
select: (conn: ServerConnection.Any, directory: string) => {
|
||||||
const key = ServerConnection.key(conn)
|
const key = ServerConnection.key(conn)
|
||||||
if (global.servers.health[key]?.healthy === false) return
|
if (global.servers.health[key]?.healthy === false) return
|
||||||
|
|||||||
@@ -68,8 +68,8 @@ export function createHomeProjectsController(home: HomeController) {
|
|||||||
},
|
},
|
||||||
project: {
|
project: {
|
||||||
list: home.project.list,
|
list: home.project.list,
|
||||||
recentForServer: home.project.recentForServer,
|
recentlyClosed: home.project.recentlyClosed,
|
||||||
homedirForServer: home.project.homedirForServer,
|
homedir: home.project.homedir,
|
||||||
select: home.project.select,
|
select: home.project.select,
|
||||||
add: home.project.add,
|
add: home.project.add,
|
||||||
openNewSession: home.project.openProjectNewSession,
|
openNewSession: home.project.openProjectNewSession,
|
||||||
|
|||||||
@@ -31,13 +31,11 @@ export type HomeProjectsViewProps = {
|
|||||||
language: ReturnType<typeof useLanguage>
|
language: ReturnType<typeof useLanguage>
|
||||||
servers: ServerConnection.Any[]
|
servers: ServerConnection.Any[]
|
||||||
projects: LocalProject[]
|
projects: LocalProject[]
|
||||||
|
recentlyClosed: LocalProject[]
|
||||||
selection: HomeProjectSelection
|
selection: HomeProjectSelection
|
||||||
|
homedir: string
|
||||||
serverHealth: (server: ServerConnection.Any) => ServerHealth | undefined
|
serverHealth: (server: ServerConnection.Any) => ServerHealth | undefined
|
||||||
projectsForServer: (server: ServerConnection.Any) => LocalProject[]
|
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
|
collapsed: (server: ServerConnection.Any) => boolean
|
||||||
canDefaultServer: boolean
|
canDefaultServer: boolean
|
||||||
defaultServerKey: ServerConnection.Key | null | undefined
|
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="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>
|
<div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
|
||||||
<Show
|
<Show when={props.servers.length === 1 && !(props.projects.length === 0 && props.recentlyClosed.length > 0)}>
|
||||||
when={
|
|
||||||
props.servers.length === 1 &&
|
|
||||||
!(
|
|
||||||
props.projects.length === 0 &&
|
|
||||||
props.showRecentForServer(props.servers[0]) &&
|
|
||||||
props.recentForServer(props.servers[0]).length > 0
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<TooltipV2 placement="bottom" value={props.language.t("home.project.add")}>
|
<TooltipV2 placement="bottom" value={props.language.t("home.project.add")}>
|
||||||
<IconButtonV2
|
<IconButtonV2
|
||||||
data-action="home-add-project"
|
data-action="home-add-project"
|
||||||
@@ -113,16 +102,8 @@ export function HomeProjectsView(props: HomeProjectsViewProps) {
|
|||||||
fallback={
|
fallback={
|
||||||
<div class="pr-3">
|
<div class="pr-3">
|
||||||
<Show
|
<Show
|
||||||
when={!props.showRecentForServer(props.servers[0]) && props.projects.length > 0}
|
when={props.projects.length > 0}
|
||||||
fallback={
|
fallback={<HomeProjectEmpty {...props} server={props.servers[0]} items={props.recentlyClosed} />}
|
||||||
<HomeProjectEmpty
|
|
||||||
{...props}
|
|
||||||
{...contextMenuProps}
|
|
||||||
server={props.servers[0]}
|
|
||||||
projects={props.projects}
|
|
||||||
items={props.showRecentForServer(props.servers[0]) ? props.recentForServer(props.servers[0]) : []}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<HomeProjectList {...props} {...contextMenuProps} server={props.servers[0]} items={props.projects} />
|
<HomeProjectList {...props} {...contextMenuProps} server={props.servers[0]} items={props.projects} />
|
||||||
</Show>
|
</Show>
|
||||||
@@ -133,11 +114,8 @@ export function HomeProjectsView(props: HomeProjectsViewProps) {
|
|||||||
<For each={props.servers}>
|
<For each={props.servers}>
|
||||||
{(item) => {
|
{(item) => {
|
||||||
const projects = () => props.projectsForServer(item)
|
const projects = () => props.projectsForServer(item)
|
||||||
const recent = () => props.recentForServer(item)
|
|
||||||
const healthy = () => !!props.serverHealth(item)?.healthy
|
const healthy = () => !!props.serverHealth(item)?.healthy
|
||||||
const hasProjects = () => projects().length > 0
|
const hasProjects = () => projects().length > 0
|
||||||
const showRecent = () => props.showRecentForServer(item)
|
|
||||||
const hasChildren = () => hasProjects() || (showRecent() && recent().length > 0)
|
|
||||||
const collapsed = () => props.collapsed(item)
|
const collapsed = () => props.collapsed(item)
|
||||||
return (
|
return (
|
||||||
<div class="flex min-w-0 flex-col gap-1">
|
<div class="flex min-w-0 flex-col gap-1">
|
||||||
@@ -147,25 +125,11 @@ export function HomeProjectsView(props: HomeProjectsViewProps) {
|
|||||||
{...contextMenuProps}
|
{...contextMenuProps}
|
||||||
selected={props.selection.server === ServerConnection.key(item) && !props.selection.directory}
|
selected={props.selection.server === ServerConnection.key(item) && !props.selection.directory}
|
||||||
collapsed={collapsed()}
|
collapsed={collapsed()}
|
||||||
hasChildren={hasChildren()}
|
|
||||||
health={props.serverHealth(item)}
|
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" />
|
<div class="mx-3 h-px bg-v2-border-border-base" />
|
||||||
<Show
|
<HomeProjectList {...props} {...contextMenuProps} server={item} items={projects()} />
|
||||||
when={!showRecent() && hasProjects()}
|
|
||||||
fallback={
|
|
||||||
<HomeProjectEmpty
|
|
||||||
{...props}
|
|
||||||
{...contextMenuProps}
|
|
||||||
server={item}
|
|
||||||
projects={projects()}
|
|
||||||
items={showRecent() ? recent() : []}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<HomeProjectList {...props} {...contextMenuProps} server={item} items={projects()} />
|
|
||||||
</Show>
|
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -229,11 +193,10 @@ function HomeServerRow(props: {
|
|||||||
server: ServerConnection.Any
|
server: ServerConnection.Any
|
||||||
selected: boolean
|
selected: boolean
|
||||||
collapsed: boolean
|
collapsed: boolean
|
||||||
hasChildren: boolean
|
|
||||||
health: ServerHealth | undefined
|
health: ServerHealth | undefined
|
||||||
}) {
|
}) {
|
||||||
const healthy = () => !!props.health?.healthy
|
const healthy = () => !!props.health?.healthy
|
||||||
const canToggle = () => healthy() && props.hasChildren
|
const canToggle = () => healthy() && props.projectsForServer(props.server).length > 0
|
||||||
const contextMenuID = () => serverContextMenuID(props.server)
|
const contextMenuID = () => serverContextMenuID(props.server)
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
const id = contextMenuID()
|
const id = contextMenuID()
|
||||||
@@ -416,55 +379,37 @@ function HomeProjectSlot(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function HomeProjectEmpty(
|
function HomeProjectEmpty(
|
||||||
props: HomeProjectsViewProps &
|
props: HomeProjectsViewProps & {
|
||||||
HomeProjectsContextMenuProps & {
|
server: ServerConnection.Any
|
||||||
server: ServerConnection.Any
|
items: LocalProject[]
|
||||||
projects: LocalProject[]
|
},
|
||||||
items: LocalProject[]
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
const unreachable = () => props.serverHealth(props.server)?.healthy === false
|
const unreachable = () => props.serverHealth(props.server)?.healthy === false
|
||||||
return (
|
return (
|
||||||
<div class="flex min-w-0 flex-col gap-1">
|
<div class="flex min-w-0 flex-col gap-1">
|
||||||
<Show when={props.projects.length > 0}>
|
<HomeProjectNavButton
|
||||||
<HomeProjectList {...props} server={props.server} items={props.projects} />
|
type="button"
|
||||||
</Show>
|
data-action="home-add-project-row"
|
||||||
<Show when={props.projects.length === 0}>
|
class="disabled:opacity-60 [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted"
|
||||||
<HomeProjectNavButton
|
disabled={unreachable()}
|
||||||
type="button"
|
onClick={() => props.onChooseProject(props.server)}
|
||||||
data-action="home-add-project-row"
|
>
|
||||||
class="disabled:opacity-60 [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted"
|
<IconV2 name="folder-add-left" size="small" />
|
||||||
disabled={unreachable()}
|
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("home.project.add")}</span>
|
||||||
onClick={() => props.onChooseProject(props.server)}
|
</HomeProjectNavButton>
|
||||||
>
|
|
||||||
<IconV2 name="folder-add-left" size="small" />
|
|
||||||
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("home.project.add")}</span>
|
|
||||||
</HomeProjectNavButton>
|
|
||||||
</Show>
|
|
||||||
<Show when={props.items.length > 0}>
|
<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>
|
<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>
|
</div>
|
||||||
<For each={props.items}>
|
<For each={props.items}>
|
||||||
{(project) => <HomeSuggestedProjectRow {...props} project={project} server={props.server} />}
|
{(project) => <HomeRecentlyClosedRow {...props} project={project} server={props.server} />}
|
||||||
</For>
|
</For>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function HomeSuggestedProjectRow(
|
function HomeRecentlyClosedRow(
|
||||||
props: HomeProjectsViewProps & {
|
props: HomeProjectsViewProps & {
|
||||||
project: LocalProject
|
project: LocalProject
|
||||||
server: ServerConnection.Any
|
server: ServerConnection.Any
|
||||||
@@ -472,44 +417,24 @@ function HomeSuggestedProjectRow(
|
|||||||
) {
|
) {
|
||||||
const unreachable = () => props.serverHealth(props.server)?.healthy === false
|
const unreachable = () => props.serverHealth(props.server)?.healthy === false
|
||||||
const path = () => {
|
const path = () => {
|
||||||
const home = props.homedirForServer(props.server)
|
const home = props.homedir
|
||||||
const worktree = props.project.worktree
|
const worktree = props.project.worktree
|
||||||
if (home && (worktree === home || worktree.startsWith(`${home}/`))) return `~${worktree.slice(home.length)}`
|
if (home && (worktree === home || worktree.startsWith(`${home}/`))) return `~${worktree.slice(home.length)}`
|
||||||
return worktree
|
return worktree
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div class="group/project relative flex h-7 min-w-0 items-center rounded-[6px]">
|
<TooltipV2 placement="right" value={path()}>
|
||||||
<TooltipV2 class="w-full" placement="right" value={path()}>
|
<HomeProjectNavButton
|
||||||
<HomeProjectNavButton
|
type="button"
|
||||||
type="button"
|
data-component="home-recently-closed-row"
|
||||||
data-component="home-recent-project-row"
|
class="disabled:opacity-60"
|
||||||
class="pr-10 disabled:opacity-60"
|
disabled={unreachable()}
|
||||||
disabled={unreachable()}
|
onClick={() => props.onAddProjects(props.server, [props.project.worktree])}
|
||||||
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="bottom" value={props.language.t("home.project.add")}>
|
<HomeProjectAvatar project={props.project} outline />
|
||||||
<IconButtonV2
|
<span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
|
||||||
data-action="home-add-recent-project"
|
</HomeProjectNavButton>
|
||||||
variant="ghost-muted"
|
</TooltipV2>
|
||||||
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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,33 +1,18 @@
|
|||||||
import type { HomeProjectsController } from "./home-projects-controller"
|
import type { HomeProjectsController } from "./home-projects-controller"
|
||||||
import { HomeProjectsView } from "./home-projects-view"
|
import { HomeProjectsView } from "./home-projects-view"
|
||||||
import type { HomeScrollController } from "./home-scroll-controller"
|
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 }) {
|
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 (
|
return (
|
||||||
<HomeProjectsView
|
<HomeProjectsView
|
||||||
language={props.projects.copy.language}
|
language={props.projects.copy.language}
|
||||||
servers={props.projects.server.list()}
|
servers={props.projects.server.list()}
|
||||||
projects={props.projects.project.list()}
|
projects={props.projects.project.list()}
|
||||||
|
recentlyClosed={props.projects.project.recentlyClosed()}
|
||||||
selection={props.projects.selection.value()}
|
selection={props.projects.selection.value()}
|
||||||
|
homedir={props.projects.project.homedir()}
|
||||||
serverHealth={props.projects.server.health}
|
serverHealth={props.projects.server.health}
|
||||||
projectsForServer={props.projects.server.projects}
|
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}
|
collapsed={props.projects.server.collapsed}
|
||||||
canDefaultServer={props.projects.server.canDefault()}
|
canDefaultServer={props.projects.server.canDefault()}
|
||||||
defaultServerKey={props.projects.server.defaultKey()}
|
defaultServerKey={props.projects.server.defaultKey()}
|
||||||
|
|||||||
@@ -75,8 +75,7 @@ export function createClientConnection(initialApi: OpenCodeClient, options: Clie
|
|||||||
if (signal.aborted) return { error: undefined, connectedAt }
|
if (signal.aborted) return { error: undefined, connectedAt }
|
||||||
if (first.done)
|
if (first.done)
|
||||||
return {
|
return {
|
||||||
error:
|
error: request.signal.reason instanceof Error ? request.signal.reason : new Error("Event stream disconnected"),
|
||||||
request.signal.reason instanceof Error ? request.signal.reason : new Error("Event stream disconnected"),
|
|
||||||
connectedAt,
|
connectedAt,
|
||||||
}
|
}
|
||||||
if (first.value.type !== "server.connected")
|
if (first.value.type !== "server.connected")
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import { GoogleVertexPlugin } from "./provider/google-vertex.js"
|
|||||||
import { GroqPlugin } from "./provider/groq.js"
|
import { GroqPlugin } from "./provider/groq.js"
|
||||||
import { KiloPlugin } from "./provider/kilo.js"
|
import { KiloPlugin } from "./provider/kilo.js"
|
||||||
import { LLMGatewayPlugin } from "./provider/llmgateway.js"
|
import { LLMGatewayPlugin } from "./provider/llmgateway.js"
|
||||||
import { LMStudioPlugin } from "./provider/lmstudio.js"
|
|
||||||
import { MistralPlugin } from "./provider/mistral.js"
|
import { MistralPlugin } from "./provider/mistral.js"
|
||||||
import { NvidiaPlugin } from "./provider/nvidia.js"
|
import { NvidiaPlugin } from "./provider/nvidia.js"
|
||||||
import { OpenAIPlugin } from "./provider/openai.js"
|
import { OpenAIPlugin } from "./provider/openai.js"
|
||||||
@@ -49,7 +48,6 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
|
|||||||
GroqPlugin,
|
GroqPlugin,
|
||||||
KiloPlugin,
|
KiloPlugin,
|
||||||
LLMGatewayPlugin,
|
LLMGatewayPlugin,
|
||||||
LMStudioPlugin,
|
|
||||||
MistralPlugin,
|
MistralPlugin,
|
||||||
NvidiaPlugin,
|
NvidiaPlugin,
|
||||||
OpencodePlugin,
|
OpencodePlugin,
|
||||||
|
|||||||
@@ -1,174 +0,0 @@
|
|||||||
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 = "lmstudio"
|
|
||||||
|
|
||||||
const RemoteModel = Schema.Struct({
|
|
||||||
type: Schema.Literals(["llm", "embedding"]),
|
|
||||||
key: Schema.String,
|
|
||||||
display_name: Schema.String,
|
|
||||||
architecture: Schema.NullOr(Schema.String).pipe(Schema.optional),
|
|
||||||
loaded_instances: Schema.Array(
|
|
||||||
Schema.Struct({
|
|
||||||
config: Schema.Struct({ context_length: Schema.Int }),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
max_context_length: Schema.Int,
|
|
||||||
capabilities: Schema.Struct({
|
|
||||||
vision: Schema.Boolean,
|
|
||||||
trained_for_tool_use: Schema.Boolean,
|
|
||||||
}).pipe(Schema.optional),
|
|
||||||
})
|
|
||||||
|
|
||||||
const Response = Schema.Struct({ models: 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:1234", interval: Duration.Input = "30 seconds") {
|
|
||||||
return define({
|
|
||||||
id: "opencode.provider.lmstudio",
|
|
||||||
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 = "LM Studio"
|
|
||||||
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.key, (model) => {
|
|
||||||
model.modelID = Model.ID.make(item.key)
|
|
||||||
model.name = item.display_name || item.key
|
|
||||||
model.family = item.architecture ? Model.Family.make(item.architecture) : undefined
|
|
||||||
model.capabilities = {
|
|
||||||
tools: item.capabilities?.trained_for_tool_use ?? false,
|
|
||||||
input: ["text", ...(item.capabilities?.vision ? ["image"] : [])],
|
|
||||||
output: ["text"],
|
|
||||||
}
|
|
||||||
model.limit = {
|
|
||||||
context:
|
|
||||||
item.loaded_instances.length === 0
|
|
||||||
? item.max_context_length
|
|
||||||
: Math.min(...item.loaded_instances.map((instance) => instance.config.context_length)),
|
|
||||||
output: 0,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const discover = Effect.fn("LMStudioPlugin.discover")(function* () {
|
|
||||||
const current = source.current
|
|
||||||
if (!current.endpoint) return undefined
|
|
||||||
return yield* discoveryLock.withPermit(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const cached = discovery.get(current.endpoint)
|
|
||||||
if (cached && cached.apiKey === current.apiKey && Date.now() - cached.checked < Duration.toMillis(interval))
|
|
||||||
return { source: current, models: cached.models }
|
|
||||||
discovery.set(current.endpoint, {
|
|
||||||
checked: Date.now(),
|
|
||||||
apiKey: current.apiKey,
|
|
||||||
models: cached && cached.apiKey === current.apiKey ? cached.models : undefined,
|
|
||||||
})
|
|
||||||
const request = current.apiKey
|
|
||||||
? HttpClientRequest.get(current.endpoint).pipe(
|
|
||||||
HttpClientRequest.acceptJson,
|
|
||||||
HttpClientRequest.bearerToken(current.apiKey),
|
|
||||||
)
|
|
||||||
: HttpClientRequest.get(current.endpoint).pipe(HttpClientRequest.acceptJson)
|
|
||||||
const response = yield* http
|
|
||||||
.execute(request)
|
|
||||||
.pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(Response)), Effect.timeout("1 second"))
|
|
||||||
const models = response.models
|
|
||||||
.filter((model) => model.type === "llm" && model.key.length > 0)
|
|
||||||
.toSorted((a, b) => a.key.localeCompare(b.key))
|
|
||||||
discovery.set(current.endpoint, { checked: Date.now(), apiKey: current.apiKey, models })
|
|
||||||
return { source: current, models }
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const refresh = Effect.fn("LMStudioPlugin.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("LMStudioPlugin.reload")(function* () {
|
|
||||||
const next = configured(yield* config.entries(), origin)
|
|
||||||
if (
|
|
||||||
next.baseURL === source.current.baseURL &&
|
|
||||||
next.apiKey === source.current.apiKey &&
|
|
||||||
next.endpoint === source.current.endpoint
|
|
||||||
)
|
|
||||||
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 LMStudioPlugin = 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/v1/models`
|
|
||||||
url.search = ""
|
|
||||||
url.hash = ""
|
|
||||||
return { baseURL, apiKey, endpoint: url.toString() }
|
|
||||||
}
|
|
||||||
@@ -1,341 +0,0 @@
|
|||||||
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 { LMStudioPlugin, make } from "@opencode-ai/core/plugin/provider/lmstudio"
|
|
||||||
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 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("LMStudioPlugin", () => {
|
|
||||||
it.effect("is registered as a built-in provider plugin", () =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
expect(LMStudioPlugin.id).toBe("opencode.provider.lmstudio")
|
|
||||||
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.lmstudio")
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("discovers local language models with their capabilities and effective context", () =>
|
|
||||||
Effect.acquireUseRelease(
|
|
||||||
Effect.sync(() =>
|
|
||||||
Bun.serve({
|
|
||||||
port: 0,
|
|
||||||
fetch: () =>
|
|
||||||
Response.json({
|
|
||||||
models: [
|
|
||||||
{
|
|
||||||
type: "llm",
|
|
||||||
key: "google/gemma-4-26b-a4b",
|
|
||||||
display_name: "Gemma 4 26B A4B",
|
|
||||||
architecture: "gemma4",
|
|
||||||
loaded_instances: [{ config: { context_length: 32_768 } }, { config: { context_length: 16_384 } }],
|
|
||||||
max_context_length: 262_144,
|
|
||||||
capabilities: { vision: true, trained_for_tool_use: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "llm",
|
|
||||||
key: "deepseek-r1",
|
|
||||||
display_name: "DeepSeek R1",
|
|
||||||
architecture: "deepseek",
|
|
||||||
loaded_instances: [],
|
|
||||||
max_context_length: 131_072,
|
|
||||||
capabilities: { vision: false, trained_for_tool_use: false },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "embedding",
|
|
||||||
key: "nomic-embed",
|
|
||||||
display_name: "Nomic Embed",
|
|
||||||
loaded_instances: [],
|
|
||||||
max_context_length: 2048,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
(server) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const catalog = yield* Catalog.Service
|
|
||||||
yield* addPlugin(server.url.origin)
|
|
||||||
const providerID = Provider.ID.make("lmstudio")
|
|
||||||
const gemma = yield* eventually(
|
|
||||||
catalog.model.get(providerID, Model.ID.make("google/gemma-4-26b-a4b")),
|
|
||||||
(model) => model !== undefined,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(yield* catalog.provider.get(providerID)).toEqual({
|
|
||||||
id: providerID,
|
|
||||||
name: "LM Studio",
|
|
||||||
activation: "enabled",
|
|
||||||
package: "@opencode-ai/ai/providers/openai-compatible",
|
|
||||||
settings: { baseURL: `${server.url.origin}/v1`, provider: "lmstudio", apiKey: "" },
|
|
||||||
})
|
|
||||||
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(providerID)
|
|
||||||
expect(gemma).toMatchObject({
|
|
||||||
family: "gemma4",
|
|
||||||
name: "Gemma 4 26B A4B",
|
|
||||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
|
||||||
limit: { context: 16_384, output: 0 },
|
|
||||||
})
|
|
||||||
expect(yield* catalog.model.get(providerID, Model.ID.make("deepseek-r1"))).toMatchObject({
|
|
||||||
capabilities: { tools: false, input: ["text"], output: ["text"] },
|
|
||||||
limit: { context: 131_072, output: 0 },
|
|
||||||
})
|
|
||||||
expect(yield* catalog.model.get(providerID, Model.ID.make("nomic-embed"))).toBeUndefined()
|
|
||||||
}),
|
|
||||||
(server) => Effect.promise(() => server.stop(true)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("refreshes the catalog when LM Studio models change", () =>
|
|
||||||
Effect.acquireUseRelease(
|
|
||||||
Effect.sync(() => {
|
|
||||||
const models: Array<Record<string, unknown>> = []
|
|
||||||
return {
|
|
||||||
models,
|
|
||||||
server: Bun.serve({ port: 0, fetch: () => Response.json({ models }) }),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
({ models, server }) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const catalog = yield* Catalog.Service
|
|
||||||
const providerID = Provider.ID.make("lmstudio")
|
|
||||||
yield* addPlugin(server.url.origin, "5 millis")
|
|
||||||
expect(yield* catalog.provider.get(providerID)).toBeUndefined()
|
|
||||||
|
|
||||||
models.push({
|
|
||||||
type: "llm",
|
|
||||||
key: "qwen/qwen3-coder",
|
|
||||||
display_name: "Qwen 3 Coder",
|
|
||||||
architecture: "qwen3",
|
|
||||||
loaded_instances: [],
|
|
||||||
max_context_length: 65_536,
|
|
||||||
capabilities: { vision: false, trained_for_tool_use: true },
|
|
||||||
})
|
|
||||||
expect(
|
|
||||||
yield* eventually(
|
|
||||||
catalog.model.get(providerID, Model.ID.make("qwen/qwen3-coder")),
|
|
||||||
(model) => model !== undefined,
|
|
||||||
),
|
|
||||||
).toMatchObject({ name: "Qwen 3 Coder" })
|
|
||||||
|
|
||||||
models.splice(0)
|
|
||||||
yield* eventually(catalog.provider.get(providerID), (provider) => provider === undefined)
|
|
||||||
}),
|
|
||||||
({ server }) => Effect.promise(() => server.stop(true)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live(
|
|
||||||
"discovers from configured endpoints with bearer authentication",
|
|
||||||
() =>
|
|
||||||
Effect.acquireUseRelease(
|
|
||||||
Effect.sync(() => {
|
|
||||||
const requests: Array<{ authorization: string | null; path: string }> = []
|
|
||||||
const model = (key: string) => ({
|
|
||||||
type: "llm",
|
|
||||||
key,
|
|
||||||
display_name: key,
|
|
||||||
loaded_instances: [],
|
|
||||||
max_context_length: 32_768,
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
requests,
|
|
||||||
initial: Bun.serve({ port: 0, fetch: () => Response.json({ models: [model("initial-model")] }) }),
|
|
||||||
configured: Bun.serve({
|
|
||||||
port: 0,
|
|
||||||
fetch: (request) => {
|
|
||||||
requests.push({
|
|
||||||
authorization: request.headers.get("authorization"),
|
|
||||||
path: new URL(request.url).pathname,
|
|
||||||
})
|
|
||||||
return Response.json({ models: [model("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("lmstudio")
|
|
||||||
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, "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/api/v1/models" })
|
|
||||||
expect(yield* catalog.model.get(providerID, Model.ID.make("initial-model"))).toBeUndefined()
|
|
||||||
expect((yield* catalog.provider.get(providerID))?.settings).toEqual({
|
|
||||||
baseURL,
|
|
||||||
provider: "lmstudio",
|
|
||||||
apiKey: "secret",
|
|
||||||
})
|
|
||||||
|
|
||||||
requests.splice(0)
|
|
||||||
yield* config.setEntries([configuration(baseURL, "secret"), configuration(baseURL, null)])
|
|
||||||
yield* bus.publish(Event.Updated, {})
|
|
||||||
yield* eventually(catalog.provider.get(providerID), (provider) => provider?.settings?.apiKey === "")
|
|
||||||
expect(requests).toContainEqual({ authorization: null, path: "/proxy/api/v1/models" })
|
|
||||||
}),
|
|
||||||
({ initial, configured }) => Effect.promise(() => Promise.all([initial.stop(true), configured.stop(true)])),
|
|
||||||
),
|
|
||||||
10_000,
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("shares discovery requests across plugin instances", () =>
|
|
||||||
Effect.acquireUseRelease(
|
|
||||||
Effect.sync(() => {
|
|
||||||
const requests = { count: 0 }
|
|
||||||
return {
|
|
||||||
requests,
|
|
||||||
server: Bun.serve({
|
|
||||||
port: 0,
|
|
||||||
fetch: () => {
|
|
||||||
requests.count++
|
|
||||||
return Response.json({
|
|
||||||
models: [
|
|
||||||
{
|
|
||||||
type: "llm",
|
|
||||||
key: "shared-model",
|
|
||||||
display_name: "Shared Model",
|
|
||||||
loaded_instances: [],
|
|
||||||
max_context_length: 32_768,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
({ requests, server }) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const catalog = yield* Catalog.Service
|
|
||||||
yield* addPlugin(server.url.origin)
|
|
||||||
yield* addPlugin(server.url.origin)
|
|
||||||
yield* eventually(
|
|
||||||
catalog.model.get(Provider.ID.make("lmstudio"), Model.ID.make("shared-model")),
|
|
||||||
(model) => model !== undefined,
|
|
||||||
)
|
|
||||||
expect(requests.count).toBe(1)
|
|
||||||
}),
|
|
||||||
({ server }) => Effect.promise(() => server.stop(true)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("replaces the credential-gated Models.dev catalog when discovery succeeds", () =>
|
|
||||||
Effect.acquireUseRelease(
|
|
||||||
Effect.sync(() => {
|
|
||||||
const models = [
|
|
||||||
{
|
|
||||||
type: "llm",
|
|
||||||
key: "discovered-model",
|
|
||||||
display_name: "Discovered Model",
|
|
||||||
loaded_instances: [],
|
|
||||||
max_context_length: 32_768,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
return { models, server: Bun.serve({ port: 0, fetch: () => Response.json({ models }) }) }
|
|
||||||
}),
|
|
||||||
({ models, server }) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const catalog = yield* Catalog.Service
|
|
||||||
const integrations = yield* Integration.Service
|
|
||||||
const providerID = Provider.ID.make("lmstudio")
|
|
||||||
yield* integrations.transform((draft) => {
|
|
||||||
draft.update(Integration.ID.make("lmstudio"), (integration) => {
|
|
||||||
integration.name = "LMStudio"
|
|
||||||
})
|
|
||||||
draft.method.update({
|
|
||||||
integrationID: Integration.ID.make("lmstudio"),
|
|
||||||
method: { type: "env", names: ["LMSTUDIO_API_KEY"] },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
yield* catalog.transform((draft) => {
|
|
||||||
draft.provider.update(providerID, (provider) => {
|
|
||||||
provider.name = "LMStudio"
|
|
||||||
provider.package = "aisdk:@ai-sdk/openai-compatible"
|
|
||||||
provider.integrationID = Integration.ID.make("lmstudio")
|
|
||||||
})
|
|
||||||
draft.model.update(providerID, Model.ID.make("static-model"), () => {})
|
|
||||||
})
|
|
||||||
|
|
||||||
expect((yield* catalog.provider.available()).map((provider) => provider.id)).not.toContain(providerID)
|
|
||||||
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("lmstudio"))).toBeUndefined()
|
|
||||||
expect((yield* catalog.provider.get(providerID))?.integrationID).toBeUndefined()
|
|
||||||
expect(yield* catalog.model.get(providerID, Model.ID.make("static-model"))).toBeUndefined()
|
|
||||||
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(providerID)
|
|
||||||
|
|
||||||
yield* integrations.transform((draft) => {
|
|
||||||
draft.update(Integration.ID.make("lmstudio"), (integration) => {
|
|
||||||
integration.name = "Configured LM Studio"
|
|
||||||
})
|
|
||||||
draft.method.update({ integrationID: Integration.ID.make("lmstudio"), method: { type: "key" } })
|
|
||||||
})
|
|
||||||
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(providerID)
|
|
||||||
|
|
||||||
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("lmstudio"))).toBeDefined()
|
|
||||||
expect((yield* catalog.provider.get(providerID))?.integrationID).toBe(Integration.ID.make("lmstudio"))
|
|
||||||
}),
|
|
||||||
({ server }) => Effect.promise(() => server.stop(true)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
function configuration(baseURL: string, apiKey: string | null) {
|
|
||||||
return new Document({
|
|
||||||
type: "document",
|
|
||||||
info: decode({ providers: { lmstudio: { settings: { baseURL, apiKey } } } }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,6 @@ import { app, BrowserWindow } from "electron"
|
|||||||
|
|
||||||
import { Deferred, Effect, Fiber } from "effect"
|
import { Deferred, Effect, Fiber } from "effect"
|
||||||
import contextMenu from "electron-context-menu"
|
import contextMenu from "electron-context-menu"
|
||||||
import type { DesktopRecentProject } from "@opencode-ai/app/desktop-menu"
|
|
||||||
|
|
||||||
import type { ServerReadyData } from "../preload/types"
|
import type { ServerReadyData } from "../preload/types"
|
||||||
import { checkAppExists, resolveAppPath } from "./apps"
|
import { checkAppExists, resolveAppPath } from "./apps"
|
||||||
@@ -237,7 +236,6 @@ const main = Effect.gen(function* () {
|
|||||||
registerRendererProtocol()
|
registerRendererProtocol()
|
||||||
setDockIcon()
|
setDockIcon()
|
||||||
const updater = setupAutoUpdater(() => stopWslServers())
|
const updater = setupAutoUpdater(() => stopWslServers())
|
||||||
const recentProjects = new Map<number, DesktopRecentProject[]>()
|
|
||||||
const menuDeps = {
|
const menuDeps = {
|
||||||
trigger: (id: string) => {
|
trigger: (id: string) => {
|
||||||
const win = getLastFocusedWindow()
|
const win = getLastFocusedWindow()
|
||||||
@@ -245,10 +243,6 @@ const main = Effect.gen(function* () {
|
|||||||
},
|
},
|
||||||
checkForUpdates: () => void showUpdaterDialog(updater),
|
checkForUpdates: () => void showUpdaterDialog(updater),
|
||||||
relaunch,
|
relaunch,
|
||||||
recentProjects: () => {
|
|
||||||
const win = getLastFocusedWindow()
|
|
||||||
return win ? (recentProjects.get(win.webContents.id) ?? []) : []
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
registerIpcHandlers({
|
registerIpcHandlers({
|
||||||
killSidecar: () => undefined,
|
killSidecar: () => undefined,
|
||||||
@@ -279,17 +273,12 @@ const main = Effect.gen(function* () {
|
|||||||
setNativeTranslations: (bundle) => {
|
setNativeTranslations: (bundle) => {
|
||||||
if (setNativeTranslations(bundle)) createMenu(menuDeps)
|
if (setNativeTranslations(bundle)) createMenu(menuDeps)
|
||||||
},
|
},
|
||||||
setRecentProjects: (webContentsID, projects) => {
|
|
||||||
recentProjects.set(webContentsID, projects)
|
|
||||||
createMenu(menuDeps)
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
registerUpdaterIpc(updater)
|
registerUpdaterIpc(updater)
|
||||||
void updater.start()
|
void updater.start()
|
||||||
const updateTimer = setInterval(() => void updater.check(), 10 * 60 * 1000)
|
const updateTimer = setInterval(() => void updater.check(), 10 * 60 * 1000)
|
||||||
updateTimer.unref()
|
updateTimer.unref()
|
||||||
app.once("will-quit", () => clearInterval(updateTimer))
|
app.once("will-quit", () => clearInterval(updateTimer))
|
||||||
app.on("browser-window-focus", () => createMenu(menuDeps))
|
|
||||||
yield* Effect.promise(() => startNetLog()).pipe(
|
yield* Effect.promise(() => startNetLog()).pipe(
|
||||||
Effect.catch((error) =>
|
Effect.catch((error) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { stat } from "node:fs/promises"
|
|||||||
import { basename, join } from "node:path"
|
import { basename, join } from "node:path"
|
||||||
import { app, BrowserWindow, clipboard, dialog, ipcMain, shell } from "electron"
|
import { app, BrowserWindow, clipboard, dialog, ipcMain, shell } from "electron"
|
||||||
import type { IpcMainEvent, IpcMainInvokeEvent } 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 { parseDesktopNativeBundle, type DesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native"
|
||||||
|
|
||||||
import type { FatalRendererError, ServerReadyData, TitlebarTheme } from "../preload/types"
|
import type { FatalRendererError, ServerReadyData, TitlebarTheme } from "../preload/types"
|
||||||
@@ -49,7 +49,6 @@ type Deps = {
|
|||||||
exportDebugLogs: () => Promise<string>
|
exportDebugLogs: () => Promise<string>
|
||||||
recordFatalRendererError: (error: FatalRendererError) => Promise<void> | void
|
recordFatalRendererError: (error: FatalRendererError) => Promise<void> | void
|
||||||
setNativeTranslations: (bundle: DesktopNativeBundle) => void
|
setNativeTranslations: (bundle: DesktopNativeBundle) => void
|
||||||
setRecentProjects: (webContentsID: number, projects: DesktopRecentProject[]) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerIpcHandlers(deps: Deps) {
|
export function registerIpcHandlers(deps: Deps) {
|
||||||
@@ -93,20 +92,6 @@ export function registerIpcHandlers(deps: Deps) {
|
|||||||
if (!bundle) throw new Error("Invalid native translation bundle")
|
if (!bundle) throw new Error("Invalid native translation bundle")
|
||||||
deps.setNativeTranslations(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) => {
|
ipcMain.handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => {
|
||||||
try {
|
try {
|
||||||
const store = getStore(name)
|
const store = getStore(name)
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
DESKTOP_MENU,
|
DESKTOP_MENU,
|
||||||
desktopMenuVisible,
|
desktopMenuVisible,
|
||||||
type DesktopMenuEntry,
|
type DesktopMenuEntry,
|
||||||
type DesktopRecentProject,
|
|
||||||
type DesktopMenuRole,
|
type DesktopMenuRole,
|
||||||
} from "@opencode-ai/app/desktop-menu"
|
} from "@opencode-ai/app/desktop-menu"
|
||||||
|
|
||||||
@@ -17,7 +16,6 @@ type Deps = {
|
|||||||
trigger: (id: string) => void
|
trigger: (id: string) => void
|
||||||
checkForUpdates: () => void
|
checkForUpdates: () => void
|
||||||
relaunch: () => void
|
relaunch: () => void
|
||||||
recentProjects: () => DesktopRecentProject[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createMenu(deps: Deps) {
|
export function createMenu(deps: Deps) {
|
||||||
@@ -46,30 +44,6 @@ function nativeItem(entry: DesktopMenuEntry, deps: Deps): MenuItemConstructorOpt
|
|||||||
enabled: entry.enabled === "updater" ? UPDATER_ENABLED : undefined,
|
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) {
|
if (entry.command) {
|
||||||
const command = entry.command
|
const command = entry.command
|
||||||
item.click = () => deps.trigger(command)
|
item.click = () => deps.trigger(command)
|
||||||
|
|||||||
@@ -84,7 +84,6 @@ const api: ElectronAPI = {
|
|||||||
ipcRenderer.on("menu-command", handler)
|
ipcRenderer.on("menu-command", handler)
|
||||||
return () => ipcRenderer.removeListener("menu-command", handler)
|
return () => ipcRenderer.removeListener("menu-command", handler)
|
||||||
},
|
},
|
||||||
setRecentProjects: (projects) => ipcRenderer.invoke("set-recent-projects", projects),
|
|
||||||
onDeepLink: (cb) => {
|
onDeepLink: (cb) => {
|
||||||
const handler = (_: unknown, urls: string[]) => cb(urls)
|
const handler = (_: unknown, urls: string[]) => cb(urls)
|
||||||
ipcRenderer.on("deep-link", handler)
|
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 { WslServersPlatform } from "@opencode-ai/app/wsl/types"
|
||||||
import type { UpdaterState } from "@opencode-ai/app/updater"
|
import type { UpdaterState } from "@opencode-ai/app/updater"
|
||||||
import type { DesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native"
|
import type { DesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native"
|
||||||
@@ -71,7 +71,6 @@ export type ElectronAPI = {
|
|||||||
|
|
||||||
getWindowID: () => Promise<string>
|
getWindowID: () => Promise<string>
|
||||||
onMenuCommand: (cb: (id: string) => void) => () => void
|
onMenuCommand: (cb: (id: string) => void) => () => void
|
||||||
setRecentProjects: (projects: DesktopRecentProject[]) => Promise<void>
|
|
||||||
onDeepLink: (cb: (urls: string[]) => void) => () => void
|
onDeepLink: (cb: (urls: string[]) => void) => () => void
|
||||||
|
|
||||||
openDirectoryPicker: (opts?: {
|
openDirectoryPicker: (opts?: {
|
||||||
|
|||||||
@@ -15,9 +15,7 @@ import {
|
|||||||
useCommand,
|
useCommand,
|
||||||
useWslServers,
|
useWslServers,
|
||||||
useLanguage,
|
useLanguage,
|
||||||
useGlobal,
|
|
||||||
} from "@opencode-ai/app"
|
} from "@opencode-ai/app"
|
||||||
import { desktopRecentProjectCommand } from "@opencode-ai/app/desktop-menu"
|
|
||||||
import type { UpdaterState } from "@opencode-ai/app/updater"
|
import type { UpdaterState } from "@opencode-ai/app/updater"
|
||||||
import * as Sentry from "@sentry/solid"
|
import * as Sentry from "@sentry/solid"
|
||||||
import type { AsyncStorage } from "@solid-primitives/storage"
|
import type { AsyncStorage } from "@solid-primitives/storage"
|
||||||
@@ -362,7 +360,6 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) {
|
|||||||
|
|
||||||
function DesktopEffects() {
|
function DesktopEffects() {
|
||||||
const cmd = useCommand()
|
const cmd = useCommand()
|
||||||
const global = useGlobal()
|
|
||||||
menuTrigger = (id) => cmd.trigger(id)
|
menuTrigger = (id) => cmd.trigger(id)
|
||||||
|
|
||||||
const theme = useTheme()
|
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
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,13 @@ type Experiment = {
|
|||||||
// In-flight features anyone can opt into. Each entry is temporary: an
|
// In-flight features anyone can opt into. Each entry is temporary: an
|
||||||
// experiment either graduates (delete the entry, make the behavior
|
// experiment either graduates (delete the entry, make the behavior
|
||||||
// unconditional) or dies (delete the entry and the branch it gated).
|
// unconditional) or dies (delete the entry and the branch it gated).
|
||||||
export const experiments: Experiment[] = []
|
export const experiments: Experiment[] = [
|
||||||
|
{
|
||||||
|
id: "tab_scroll",
|
||||||
|
title: "Remember tab scroll",
|
||||||
|
description: "Keep each open tab's reading position and show a shortcut back to the bottom.",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
export function DialogExperiments() {
|
export function DialogExperiments() {
|
||||||
const config = useConfig()
|
const config = useConfig()
|
||||||
|
|||||||
@@ -87,6 +87,11 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
|||||||
renderer.off("blur", onBlur)
|
renderer.off("blur", onBlur)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (config.experimental?.tab_scroll === true) return
|
||||||
|
scrollAnchors.clear()
|
||||||
|
})
|
||||||
|
|
||||||
function state() {
|
function state() {
|
||||||
if (config.tabs.scope === "cwd") return store.cwd[paths.cwd] ?? fallback
|
if (config.tabs.scope === "cwd") return store.cwd[paths.cwd] ?? fallback
|
||||||
return store.global
|
return store.global
|
||||||
|
|||||||
@@ -429,6 +429,7 @@ export function Session(props: { verticalTabsWidth: number }) {
|
|||||||
return scroll.scrollTop < Math.max(0, scroll.scrollHeight - scroll.viewport.height) - 1
|
return scroll.scrollTop < Math.max(0, scroll.scrollHeight - scroll.viewport.height) - 1
|
||||||
}
|
}
|
||||||
function updateAwayFromBottom() {
|
function updateAwayFromBottom() {
|
||||||
|
if (config.experimental?.tab_scroll !== true) return
|
||||||
if (awayTimer) clearTimeout(awayTimer)
|
if (awayTimer) clearTimeout(awayTimer)
|
||||||
awayTimer = setTimeout(() => {
|
awayTimer = setTimeout(() => {
|
||||||
awayTimer = undefined
|
awayTimer = undefined
|
||||||
@@ -439,7 +440,7 @@ export function Session(props: { verticalTabsWidth: number }) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
function saveScrollAnchor() {
|
function saveScrollAnchor() {
|
||||||
if (!isAwayFromBottom()) {
|
if (config.experimental?.tab_scroll !== true || !isAwayFromBottom()) {
|
||||||
sessionTabs.setScrollAnchor(sessionID, undefined)
|
sessionTabs.setScrollAnchor(sessionID, undefined)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -456,7 +457,7 @@ export function Session(props: { verticalTabsWidth: number }) {
|
|||||||
else sessionTabs.setScrollAnchor(sessionID, undefined)
|
else sessionTabs.setScrollAnchor(sessionID, undefined)
|
||||||
}
|
}
|
||||||
function restoreScrollPosition() {
|
function restoreScrollPosition() {
|
||||||
const anchor = sessionTabs.scrollAnchor(sessionID)
|
const anchor = config.experimental?.tab_scroll === true ? sessionTabs.scrollAnchor(sessionID) : undefined
|
||||||
const index = anchor ? boundaries().indexOf(anchor.messageID) : -1
|
const index = anchor ? boundaries().indexOf(anchor.messageID) : -1
|
||||||
if (!anchor || index === -1) {
|
if (!anchor || index === -1) {
|
||||||
scroll.scrollTo(scroll.scrollHeight)
|
scroll.scrollTo(scroll.scrollHeight)
|
||||||
@@ -1194,21 +1195,15 @@ export function Session(props: { verticalTabsWidth: number }) {
|
|||||||
</scrollbox>
|
</scrollbox>
|
||||||
</box>
|
</box>
|
||||||
<box height={1} flexShrink={0} flexDirection="row" justifyContent="flex-end">
|
<box height={1} flexShrink={0} flexDirection="row" justifyContent="flex-end">
|
||||||
<Show when={awayFromBottom()}>
|
<Show when={config.experimental?.tab_scroll === true && awayFromBottom()}>
|
||||||
<box
|
<text
|
||||||
paddingLeft={1}
|
fg={latestHovered() ? theme.text.default : theme.text.subdued}
|
||||||
paddingRight={1}
|
|
||||||
backgroundColor={
|
|
||||||
latestHovered() ? theme.background.action.primary.focused : theme.background.action.primary.default
|
|
||||||
}
|
|
||||||
onMouseOver={() => setLatestHovered(true)}
|
onMouseOver={() => setLatestHovered(true)}
|
||||||
onMouseOut={() => setLatestHovered(false)}
|
onMouseOut={() => setLatestHovered(false)}
|
||||||
onMouseUp={toBottom}
|
onMouseUp={toBottom}
|
||||||
>
|
>
|
||||||
<text fg={latestHovered() ? theme.text.action.primary.focused : theme.text.action.primary.default}>
|
Latest ↓
|
||||||
Jump to latest ↓
|
</text>
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
<box flexShrink={0}>
|
<box flexShrink={0}>
|
||||||
|
|||||||
@@ -239,23 +239,6 @@ test("stores session tabs for the current working directory by default", async (
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("keeps scroll anchors for open session tabs", async () => {
|
|
||||||
const setup = await renderSessionTabs("first")
|
|
||||||
|
|
||||||
try {
|
|
||||||
await wait(() => setup.tabs.current() === "first")
|
|
||||||
setup.tabs.setScrollAnchor("first", { messageID: "msg_1", screenY: -3 })
|
|
||||||
|
|
||||||
expect(setup.tabs.scrollAnchor("first")).toEqual({ messageID: "msg_1", screenY: -3 })
|
|
||||||
|
|
||||||
setup.tabs.close("first")
|
|
||||||
await wait(() => setup.tabs.tabs().every((tab) => tab.sessionID !== "first"))
|
|
||||||
expect(setup.tabs.scrollAnchor("first")).toBeUndefined()
|
|
||||||
} finally {
|
|
||||||
await setup.destroy()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("only the foreground TUI mutates unread state", async () => {
|
test("only the foreground TUI mutates unread state", async () => {
|
||||||
await using temporary = await tmpdir()
|
await using temporary = await tmpdir()
|
||||||
let foreground: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
|
let foreground: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="animated-number-digit"] {
|
[data-slot="animated-number-digit"] {
|
||||||
display: inline-grid;
|
display: inline-block;
|
||||||
width: 1ch;
|
width: 1ch;
|
||||||
height: 1em;
|
height: 1em;
|
||||||
line-height: 1em;
|
line-height: 1em;
|
||||||
@@ -41,12 +41,19 @@
|
|||||||
mask-repeat: no-repeat;
|
mask-repeat: no-repeat;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="animated-number-static"],
|
|
||||||
[data-slot="animated-number-strip"] {
|
[data-slot="animated-number-strip"] {
|
||||||
grid-area: 1 / 1;
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transform: translateY(calc(var(--animated-number-offset, 10) * -1em));
|
||||||
|
transition-property: transform;
|
||||||
|
transition-duration: var(--animated-number-duration, 560ms);
|
||||||
|
transition-timing-function: var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="animated-number-strip"][data-animating="false"] {
|
||||||
|
transition-duration: 0ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="animated-number-static"],
|
|
||||||
[data-slot="animated-number-cell"] {
|
[data-slot="animated-number-cell"] {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -55,24 +62,6 @@
|
|||||||
height: 1em;
|
height: 1em;
|
||||||
line-height: 1em;
|
line-height: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="animated-number-digit"][data-animating="true"] [data-slot="animated-number-static"] {
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="animated-number-strip"] {
|
|
||||||
display: inline-flex;
|
|
||||||
flex-direction: column;
|
|
||||||
margin-top: calc(var(--animated-number-offset, 10) * -1em);
|
|
||||||
transition-property: margin-top;
|
|
||||||
transition-duration: var(--animated-number-duration, 560ms);
|
|
||||||
transition-timing-function: var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="animated-number-digit"][data-animating="false"] [data-slot="animated-number-strip"] {
|
|
||||||
transition-duration: 0ms;
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
@@ -82,12 +71,5 @@
|
|||||||
|
|
||||||
[data-component="animated-number"] [data-slot="animated-number-strip"] {
|
[data-component="animated-number"] [data-slot="animated-number-strip"] {
|
||||||
transition-duration: 0ms;
|
transition-duration: 0ms;
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="animated-number"]
|
|
||||||
[data-slot="animated-number-digit"][data-animating]
|
|
||||||
[data-slot="animated-number-static"] {
|
|
||||||
visibility: visible;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,10 +43,10 @@ function Digit(props: { value: number; direction: 1 | -1 }) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span data-slot="animated-number-digit" data-animating={animating() ? "true" : "false"}>
|
<span data-slot="animated-number-digit">
|
||||||
<span data-slot="animated-number-static">{props.value}</span>
|
|
||||||
<span
|
<span
|
||||||
data-slot="animated-number-strip"
|
data-slot="animated-number-strip"
|
||||||
|
data-animating={animating() ? "true" : "false"}
|
||||||
onTransitionEnd={() => {
|
onTransitionEnd={() => {
|
||||||
setState("animating", false)
|
setState("animating", false)
|
||||||
setState("step", (value) => normalize(value) + 10)
|
setState("step", (value) => normalize(value) + 10)
|
||||||
|
|||||||
@@ -152,38 +152,6 @@ provider and model configuration. An unknown variant fails model resolution inst
|
|||||||
|
|
||||||
### Local models
|
### Local models
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
```jsonc title="opencode.jsonc"
|
|
||||||
{
|
|
||||||
"$schema": "https://opencode.ai/config.json",
|
|
||||||
"model": "lmstudio/google/gemma-4-26b-a4b",
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
OpenCode refreshes the inventory in the background and reads context, vision, and tool-use capabilities from LM
|
|
||||||
Studio. Embedding models are excluded because they cannot drive a session. Disable discovery with
|
|
||||||
`"plugins": ["-opencode.provider.lmstudio"]`.
|
|
||||||
|
|
||||||
For a different host or port, configure the OpenAI-compatible base URL. Models are still discovered automatically:
|
|
||||||
|
|
||||||
```jsonc title="opencode.jsonc"
|
|
||||||
{
|
|
||||||
"$schema": "https://opencode.ai/config.json",
|
|
||||||
"providers": {
|
|
||||||
"lmstudio": {
|
|
||||||
"settings": {
|
|
||||||
"baseURL": "http://127.0.0.1:5678/v1",
|
|
||||||
"apiKey": "{env:LMSTUDIO_API_KEY}",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Omit `apiKey` when LM Studio authentication is disabled.
|
|
||||||
|
|
||||||
For an OpenAI-compatible server, define a provider package, endpoint, and at least one model:
|
For an OpenAI-compatible server, define a provider package, endpoint, and at least one model:
|
||||||
|
|
||||||
```jsonc title="opencode.jsonc"
|
```jsonc title="opencode.jsonc"
|
||||||
@@ -193,7 +161,7 @@ For an OpenAI-compatible server, define a provider package, endpoint, and at lea
|
|||||||
"providers": {
|
"providers": {
|
||||||
"local": {
|
"local": {
|
||||||
"name": "Local server",
|
"name": "Local server",
|
||||||
"package": "@opencode-ai/ai/providers/openai-compatible",
|
"package": "aisdk:@ai-sdk/openai-compatible",
|
||||||
"settings": {
|
"settings": {
|
||||||
"baseURL": "http://127.0.0.1:1234/v1",
|
"baseURL": "http://127.0.0.1:1234/v1",
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user