Compare commits

..

8 Commits

Author SHA1 Message Date
Aiden Cline 02e2ac0499 fix(desktop): group recent projects by server 2026-08-17 10:22:48 +00:00
Aiden Cline 2fac40101d fix(desktop): use native recent projects menu 2026-08-17 10:13:37 +00:00
Aiden Cline e392a7d3c0 feat(desktop): open recent projects from file menu 2026-08-17 10:00:13 +00:00
Aiden Cline 72f9ee4ce3 fix(app): collapse home add action 2026-08-17 09:42:11 +00:00
Aiden Cline 690f93e6a4 feat(app): dismiss recent home projects 2026-08-17 09:37:39 +00:00
Aiden Cline 5e9ab77efb fix(app): show added home projects 2026-08-17 09:36:03 +00:00
Aiden Cline d6b2e154ef fix(app): keep recent project picker open 2026-08-17 09:32:01 +00:00
Aiden Cline 5deb0b4fc8 fix(app): suggest server projects on home 2026-08-17 09:20:05 +00:00
53 changed files with 530 additions and 1544 deletions
+16 -26
View File
@@ -283,27 +283,21 @@ const AnthropicStreamDelta = Schema.Struct({
stop_sequence: optionalNull(Schema.String),
})
const AnthropicEvent = Schema.StructWithRest(
Schema.Struct({
type: Schema.String,
index: Schema.optional(Schema.Number),
message: Schema.optional(Schema.Struct({ usage: Schema.optional(AnthropicUsage) })),
content_block: Schema.optional(AnthropicStreamBlock),
delta: Schema.optional(AnthropicStreamDelta),
usage: Schema.optional(AnthropicUsage),
// `type` and `message` are both required per Anthropic's spec, but
// OpenAI-compatible proxies and gateway translations occasionally drop one
// or the other; mark them optional so a partial payload still parses and
// the parser can fall back to whichever field is populated.
error: Schema.optional(
Schema.StructWithRest(
Schema.Struct({ type: Schema.optional(Schema.String), message: Schema.optional(Schema.String) }),
[Schema.Record(Schema.String, Schema.Unknown)],
),
),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
const AnthropicEvent = Schema.Struct({
type: Schema.String,
index: Schema.optional(Schema.Number),
message: Schema.optional(Schema.Struct({ usage: Schema.optional(AnthropicUsage) })),
content_block: Schema.optional(AnthropicStreamBlock),
delta: Schema.optional(AnthropicStreamDelta),
usage: Schema.optional(AnthropicUsage),
// `type` and `message` are both required per Anthropic's spec, but
// OpenAI-compatible proxies and gateway translations occasionally drop one
// or the other; mark them optional so a partial payload still parses and
// the parser can fall back to whichever field is populated.
error: Schema.optional(
Schema.Struct({ type: Schema.optional(Schema.String), message: Schema.optional(Schema.String) }),
),
})
type AnthropicEvent = Schema.Schema.Type<typeof AnthropicEvent>
interface ParserState {
@@ -989,11 +983,7 @@ const onError = (event: AnthropicEvent) =>
new AIError({
module: ADAPTER,
method: "stream",
reason: classifyProviderFailure({
message: providerErrorMessage(event),
code: event.error?.type,
data: Schema.decodeUnknownSync(Schema.Json)(event),
}),
reason: classifyProviderFailure({ message: providerErrorMessage(event), code: event.error?.type }),
})
const step = (state: ParserState, event: AnthropicEvent) => {
+58 -65
View File
@@ -155,75 +155,69 @@ const BedrockUsageSchema = Schema.Struct({
})
type BedrockUsageSchema = Schema.Schema.Type<typeof BedrockUsageSchema>
const BedrockStreamException = Schema.StructWithRest(
Schema.Struct({
message: Schema.optional(Schema.String),
originalMessage: Schema.optional(Schema.String),
originalStatusCode: Schema.optional(Schema.Number),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
const BedrockStreamException = Schema.Struct({
message: Schema.optional(Schema.String),
originalMessage: Schema.optional(Schema.String),
originalStatusCode: Schema.optional(Schema.Number),
})
// Streaming event shape — the AWS event stream wraps each JSON payload by its
// `:event-type` header (e.g. `messageStart`, `contentBlockDelta`). We
// reconstruct that wrapping in `decodeFrames` below so the event schema can
// stay a plain discriminated record.
const BedrockEvent = Schema.StructWithRest(
Schema.Struct({
messageStart: Schema.optional(Schema.Struct({ role: Schema.String })),
contentBlockStart: Schema.optional(
Schema.Struct({
contentBlockIndex: Schema.Number,
start: Schema.optional(
Schema.Struct({
toolUse: Schema.optional(Schema.Struct({ toolUseId: Schema.String, name: Schema.String })),
}),
),
}),
),
contentBlockDelta: Schema.optional(
Schema.Struct({
contentBlockIndex: Schema.Number,
delta: Schema.optional(
Schema.Struct({
text: Schema.optional(Schema.String),
toolUse: Schema.optional(Schema.Struct({ input: Schema.String })),
reasoningContent: Schema.optional(
Schema.Struct({
text: Schema.optional(Schema.String),
signature: Schema.optional(Schema.String),
// Blob fields in Bedrock's JSON event stream are base64 strings.
redactedContent: Schema.optional(Schema.String),
// Vercel's Bedrock provider exposes the same delta under
// Anthropic's shorter `data` spelling.
data: Schema.optional(Schema.String),
}),
),
}),
),
}),
),
contentBlockStop: Schema.optional(Schema.Struct({ contentBlockIndex: Schema.Number })),
messageStop: Schema.optional(
Schema.Struct({
stopReason: Schema.String,
additionalModelResponseFields: Schema.optional(Schema.Unknown),
}),
),
metadata: Schema.optional(
Schema.Struct({
usage: Schema.optional(BedrockUsageSchema),
metrics: Schema.optional(Schema.Unknown),
}),
),
internalServerException: Schema.optional(BedrockStreamException),
modelStreamErrorException: Schema.optional(BedrockStreamException),
validationException: Schema.optional(BedrockStreamException),
throttlingException: Schema.optional(BedrockStreamException),
serviceUnavailableException: Schema.optional(BedrockStreamException),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
const BedrockEvent = Schema.Struct({
messageStart: Schema.optional(Schema.Struct({ role: Schema.String })),
contentBlockStart: Schema.optional(
Schema.Struct({
contentBlockIndex: Schema.Number,
start: Schema.optional(
Schema.Struct({
toolUse: Schema.optional(Schema.Struct({ toolUseId: Schema.String, name: Schema.String })),
}),
),
}),
),
contentBlockDelta: Schema.optional(
Schema.Struct({
contentBlockIndex: Schema.Number,
delta: Schema.optional(
Schema.Struct({
text: Schema.optional(Schema.String),
toolUse: Schema.optional(Schema.Struct({ input: Schema.String })),
reasoningContent: Schema.optional(
Schema.Struct({
text: Schema.optional(Schema.String),
signature: Schema.optional(Schema.String),
// Blob fields in Bedrock's JSON event stream are base64 strings.
redactedContent: Schema.optional(Schema.String),
// Vercel's Bedrock provider exposes the same delta under
// Anthropic's shorter `data` spelling.
data: Schema.optional(Schema.String),
}),
),
}),
),
}),
),
contentBlockStop: Schema.optional(Schema.Struct({ contentBlockIndex: Schema.Number })),
messageStop: Schema.optional(
Schema.Struct({
stopReason: Schema.String,
additionalModelResponseFields: Schema.optional(Schema.Unknown),
}),
),
metadata: Schema.optional(
Schema.Struct({
usage: Schema.optional(BedrockUsageSchema),
metrics: Schema.optional(Schema.Unknown),
}),
),
internalServerException: Schema.optional(BedrockStreamException),
modelStreamErrorException: Schema.optional(BedrockStreamException),
validationException: Schema.optional(BedrockStreamException),
throttlingException: Schema.optional(BedrockStreamException),
serviceUnavailableException: Schema.optional(BedrockStreamException),
})
type BedrockEvent = Schema.Schema.Type<typeof BedrockEvent>
// =============================================================================
@@ -672,7 +666,6 @@ const step = (state: ParserState, event: BedrockEvent) =>
reason: classifyProviderFailure({
message: exception[1]?.message ?? exception[1]?.originalMessage ?? "Bedrock Converse stream error",
code: exception[0],
data: Schema.decodeUnknownSync(Schema.Json)(event),
}),
})
}
+1 -6
View File
@@ -1012,12 +1012,7 @@ export const providerFailure = (id: string, event: Event, fallback: string) => {
return new AIError({
module: id,
method: "stream",
reason: classifyProviderFailure({
message,
code,
status,
data: Schema.decodeUnknownSync(Schema.Json)(event),
}),
reason: classifyProviderFailure({ message, code, status }),
})
}
+9 -16
View File
@@ -209,22 +209,16 @@ const OpenAIChatChoice = Schema.Struct({
native_finish_reason: optionalNull(Schema.String),
})
const OpenAIChatError = Schema.StructWithRest(
Schema.Struct({
code: optionalNull(Schema.Union([Schema.String, Schema.Number])),
message: Schema.String,
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
const OpenAIChatError = Schema.Struct({
code: optionalNull(Schema.Union([Schema.String, Schema.Number])),
message: Schema.String,
})
export const OpenAIChatEvent = Schema.StructWithRest(
Schema.Struct({
choices: optionalNull(Schema.Array(OpenAIChatChoice)),
usage: optionalNull(OpenAIChatUsage),
error: optionalNull(OpenAIChatError),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
export const OpenAIChatEvent = Schema.Struct({
choices: optionalNull(Schema.Array(OpenAIChatChoice)),
usage: optionalNull(OpenAIChatUsage),
error: optionalNull(OpenAIChatError),
})
export type OpenAIChatEvent = Schema.Schema.Type<typeof OpenAIChatEvent>
type OpenAIChatRequestMessage = LLMRequest["messages"][number]
@@ -693,7 +687,6 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
message: event.error.message,
code: event.error.code === undefined || event.error.code === null ? undefined : String(event.error.code),
status: typeof event.error.code === "number" ? event.error.code : undefined,
data: Schema.decodeUnknownSync(Schema.Json)(event),
}),
})
const events: LLMEvent[] = []
+1 -7
View File
@@ -77,7 +77,6 @@ const CONTENT_POLICY_TEXT = /content[-_\s]?policy|content_filter|safety/i
export interface ProviderFailure {
readonly message: string
readonly data: typeof Schema.Json.Type
readonly status?: number | undefined
readonly code?: string | undefined
readonly retryAfterMs?: number | undefined
@@ -94,12 +93,7 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
.filter((code): code is string => code !== undefined)
.map((code) => code.toLowerCase())
const text = body || input.message
const common = {
message: input.message,
data: input.data,
providerMetadata: input.providerMetadata,
http: input.http,
}
const common = { message: input.message, providerMetadata: input.providerMetadata, http: input.http }
const clientScoped = input.status === undefined || (input.status >= 400 && input.status < 500)
if (
-3
View File
@@ -173,7 +173,6 @@ const statusError =
reason: classifyProviderFailure({
status: response.status,
message: providerMessage(response.status, body),
data: body ?? null,
retryAfterMs: retryAfter,
rateLimit,
http: responseHttp({
@@ -194,7 +193,6 @@ const statusError =
// request headers are empty.
export const classifyHttpFailure = (input: {
readonly message: string
readonly data: typeof Schema.Json.Type
readonly url: string
readonly status?: number | undefined
readonly code?: string | undefined
@@ -207,7 +205,6 @@ export const classifyHttpFailure = (input: {
const details = responseBody(input.responseBody)
return classifyProviderFailure({
message: input.message,
data: input.data,
status: input.status,
code: input.code,
retryAfterMs: retryAfter,
-7
View File
@@ -35,7 +35,6 @@ export class HttpContext extends Schema.Class<HttpContext>("AI.HttpContext")({
export class InvalidRequestReason extends Schema.Class<InvalidRequestReason>("AI.Error.InvalidRequest")({
_tag: Schema.tag("InvalidRequest"),
message: Schema.String,
data: Schema.optional(Schema.Json),
parameter: Schema.optional(Schema.String),
classification: Schema.optional(ProviderFailureClassification),
providerMetadata: Schema.optional(ProviderMetadata),
@@ -56,7 +55,6 @@ export class NoRouteReason extends Schema.Class<NoRouteReason>("AI.Error.NoRoute
export class AuthenticationReason extends Schema.Class<AuthenticationReason>("AI.Error.Authentication")({
_tag: Schema.tag("Authentication"),
message: Schema.String,
data: Schema.optional(Schema.Json),
kind: Schema.Literals(["missing", "invalid", "expired", "insufficient-permissions", "unknown"]),
providerMetadata: Schema.optional(ProviderMetadata),
http: Schema.optional(HttpContext),
@@ -65,7 +63,6 @@ export class AuthenticationReason extends Schema.Class<AuthenticationReason>("AI
export class RateLimitReason extends Schema.Class<RateLimitReason>("AI.Error.RateLimit")({
_tag: Schema.tag("RateLimit"),
message: Schema.String,
data: Schema.optional(Schema.Json),
retryAfterMs: Schema.optional(Schema.Number),
rateLimit: Schema.optional(HttpRateLimitDetails),
providerMetadata: Schema.optional(ProviderMetadata),
@@ -75,7 +72,6 @@ export class RateLimitReason extends Schema.Class<RateLimitReason>("AI.Error.Rat
export class QuotaExceededReason extends Schema.Class<QuotaExceededReason>("AI.Error.QuotaExceeded")({
_tag: Schema.tag("QuotaExceeded"),
message: Schema.String,
data: Schema.optional(Schema.Json),
providerMetadata: Schema.optional(ProviderMetadata),
http: Schema.optional(HttpContext),
}) {}
@@ -83,7 +79,6 @@ export class QuotaExceededReason extends Schema.Class<QuotaExceededReason>("AI.E
export class ContentPolicyReason extends Schema.Class<ContentPolicyReason>("AI.Error.ContentPolicy")({
_tag: Schema.tag("ContentPolicy"),
message: Schema.String,
data: Schema.optional(Schema.Json),
providerMetadata: Schema.optional(ProviderMetadata),
http: Schema.optional(HttpContext),
}) {}
@@ -91,7 +86,6 @@ export class ContentPolicyReason extends Schema.Class<ContentPolicyReason>("AI.E
export class ProviderInternalReason extends Schema.Class<ProviderInternalReason>("AI.Error.ProviderInternal")({
_tag: Schema.tag("ProviderInternal"),
message: Schema.String,
data: Schema.optional(Schema.Json),
status: Schema.optional(Schema.Number),
retryAfterMs: Schema.optional(Schema.Number),
providerMetadata: Schema.optional(ProviderMetadata),
@@ -135,7 +129,6 @@ export class InvalidProviderOutputReason extends Schema.Class<InvalidProviderOut
export class UnknownProviderReason extends Schema.Class<UnknownProviderReason>("AI.Error.UnknownProvider")({
_tag: Schema.tag("UnknownProvider"),
message: Schema.String,
data: Schema.optional(Schema.Json),
status: Schema.optional(Schema.Number),
providerMetadata: Schema.optional(ProviderMetadata),
http: Schema.optional(HttpContext),
-1
View File
@@ -216,7 +216,6 @@ export type Finish = Schema.Schema.Type<typeof Finish>
export const ProviderErrorEvent = Schema.Struct({
type: Schema.tag("provider-error"),
message: Schema.String,
data: Schema.Json,
classification: Schema.optional(ProviderFailureClassification),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ProviderError" })
@@ -1031,24 +1031,12 @@ describe("Anthropic Messages route", () => {
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
type: "error",
error: { type: "overloaded_error", message: "Overloaded", request_id: "req_123" },
}),
),
fixedResponse(sseEvents({ type: "error", error: { type: "overloaded_error", message: "Overloaded" } })),
),
Effect.flip,
)
expect(error.reason).toMatchObject({
_tag: "ProviderInternal",
message: "overloaded_error: Overloaded",
data: {
type: "error",
error: { type: "overloaded_error", message: "Overloaded", request_id: "req_123" },
},
})
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", message: "overloaded_error: Overloaded" })
}),
)
@@ -714,15 +714,11 @@ describe("Bedrock Converse route", () => {
Effect.gen(function* () {
const body = concat([
eventFrame("messageStart", { role: "assistant" }),
exceptionFrame("throttlingException", { message: "Slow down", requestId: "req_123" }),
exceptionFrame("throttlingException", { message: "Slow down" }),
])
const error = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)), Effect.flip)
expect(error.reason).toMatchObject({
_tag: "RateLimit",
message: "Slow down",
data: { throttlingException: { message: "Slow down", requestId: "req_123" } },
})
expect(error.reason).toMatchObject({ _tag: "RateLimit", message: "Slow down" })
}),
)
@@ -2592,25 +2592,13 @@ describe("OpenAI Responses route", () => {
message: "Something went wrong",
param: null,
sequence_number: 1,
diagnostic: { region: "us-east" },
}),
),
),
Effect.flip,
)
expect(error.reason).toMatchObject({
_tag: "UnknownProvider",
message: "Something went wrong",
data: {
type: "error",
code: null,
message: "Something went wrong",
param: null,
sequence_number: 1,
diagnostic: { region: "us-east" },
},
})
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "Something went wrong" })
}),
)
+2 -5
View File
@@ -253,17 +253,14 @@ describe("OpenRouter", () => {
Effect.provide(
fixedResponse(
sseEvents({
error: { code: 502, message: "Provider disconnected", upstream: "openai" },
error: { code: 502, message: "Provider disconnected" },
}),
),
),
Effect.flip,
)
expect(error.reason).toMatchObject({
_tag: "ProviderInternal",
data: { error: { code: 502, message: "Provider disconnected", upstream: "openai" } },
})
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
expect(error.message).toContain("Provider disconnected")
}),
)
+27 -1
View File
@@ -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, useParams, useSearchParams } from "@solidjs/router"
import { type BaseRouterProps, Navigate, Route, Router, useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import {
type Component,
@@ -37,6 +37,8 @@ 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"
@@ -182,6 +184,8 @@ function DesktopCommands() {
const command = useCommand()
const language = useLanguage()
const platform = usePlatform()
const global = useGlobal()
const navigate = useNavigate()
command.register("desktop", () => {
const commands: CommandOption[] = []
@@ -195,6 +199,28 @@ 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,9 +6,18 @@ 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, type DesktopMenuAction, type DesktopMenuEntry } from "@/desktop-menu"
import {
DESKTOP_MENU,
desktopMenuVisible,
desktopRecentProjectCommand,
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>
@@ -17,6 +26,7 @@ export function WindowsAppMenu(props: {
}) {
let lastFocused: HTMLElement | undefined
const language = useLanguage()
const global = useGlobal()
const rememberFocus = () => {
const active = document.activeElement
@@ -90,6 +100,49 @@ 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) : ""}
+14
View File
@@ -3,6 +3,7 @@ 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"
@@ -129,6 +130,17 @@ 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))
@@ -141,6 +153,8 @@ function createServerController(
...projects,
list: projectsList,
recentlyClosed: recentlyClosedList,
known: knownProjectsList,
recent: recentProjectsList,
},
permission,
notification,
@@ -0,0 +1,24 @@
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"])
})
@@ -0,0 +1,11 @@
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)
}
+16 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { DESKTOP_MENU } from "./desktop-menu"
import { DESKTOP_MENU, desktopRecentProjectCommand } from "./desktop-menu"
describe("desktop menu", () => {
test("exports logs through the desktop command registry", () => {
@@ -20,4 +20,19 @@ 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"),
)
})
})
+16
View File
@@ -48,6 +48,7 @@ export type DesktopMenuItem = {
type: "item"
labelKey?: DesktopNativeKey
command?: string
dynamic?: "recentProjects"
action?: DesktopMenuAction
role?: DesktopMenuRole
href?: string
@@ -112,6 +113,11 @@ 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",
@@ -297,6 +303,16 @@ 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)
}
+1
View File
@@ -242,6 +242,7 @@ 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",
+1 -1
View File
@@ -637,7 +637,7 @@ export const dict = {
"home.title": "Home",
"home.projects": "Projects",
"home.project.add": "Add project",
"home.recentlyClosed": "Recently closed",
"home.recentlyClosed": "Recent projects",
"home.server.collapse": "Collapse server projects",
"home.server.expand": "Expand server projects",
"home.sessions.search.placeholder": "Search sessions",
+1
View File
@@ -1,5 +1,6 @@
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,8 +18,6 @@ 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(
() =>
@@ -62,11 +60,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,
recentlyClosed: home.project.recentlyClosed,
homedir: home.project.homedir,
recentForServer: home.project.recentForServer,
homedirForServer: home.project.homedirForServer,
select: home.project.select,
add: home.project.add,
openNewSession: home.project.openProjectNewSession,
@@ -31,11 +31,13 @@ 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
@@ -81,7 +83,16 @@ 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.recentlyClosed.length > 0)}>
<Show
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")}>
<IconButtonV2
data-action="home-add-project"
@@ -102,8 +113,16 @@ export function HomeProjectsView(props: HomeProjectsViewProps) {
fallback={
<div class="pr-3">
<Show
when={props.projects.length > 0}
fallback={<HomeProjectEmpty {...props} server={props.servers[0]} items={props.recentlyClosed} />}
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]) : []}
/>
}
>
<HomeProjectList {...props} {...contextMenuProps} server={props.servers[0]} items={props.projects} />
</Show>
@@ -114,8 +133,11 @@ 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">
@@ -125,11 +147,25 @@ 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() && hasProjects() && !collapsed()}>
<Show when={healthy() && hasChildren() && !collapsed()}>
<div class="mx-3 h-px bg-v2-border-border-base" />
<HomeProjectList {...props} {...contextMenuProps} server={item} items={projects()} />
<Show
when={!showRecent() && hasProjects()}
fallback={
<HomeProjectEmpty
{...props}
{...contextMenuProps}
server={item}
projects={projects()}
items={showRecent() ? recent() : []}
/>
}
>
<HomeProjectList {...props} {...contextMenuProps} server={item} items={projects()} />
</Show>
</Show>
</div>
)
@@ -193,10 +229,11 @@ function HomeServerRow(props: {
server: ServerConnection.Any
selected: boolean
collapsed: boolean
hasChildren: boolean
health: ServerHealth | undefined
}) {
const healthy = () => !!props.health?.healthy
const canToggle = () => healthy() && props.projectsForServer(props.server).length > 0
const canToggle = () => healthy() && props.hasChildren
const contextMenuID = () => serverContextMenuID(props.server)
onCleanup(() => {
const id = contextMenuID()
@@ -379,37 +416,55 @@ function HomeProjectSlot(
}
function HomeProjectEmpty(
props: HomeProjectsViewProps & {
server: ServerConnection.Any
items: LocalProject[]
},
props: HomeProjectsViewProps &
HomeProjectsContextMenuProps & {
server: ServerConnection.Any
projects: LocalProject[]
items: LocalProject[]
},
) {
const unreachable = () => props.serverHealth(props.server)?.healthy === false
return (
<div class="flex min-w-0 flex-col gap-1">
<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.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>
<Show when={props.items.length > 0}>
<div class="mt-3 flex h-7 min-w-0 shrink-0 items-center pl-1.5 pr-3">
<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="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) => <HomeRecentlyClosedRow {...props} project={project} server={props.server} />}
{(project) => <HomeSuggestedProjectRow {...props} project={project} server={props.server} />}
</For>
</Show>
</div>
)
}
function HomeRecentlyClosedRow(
function HomeSuggestedProjectRow(
props: HomeProjectsViewProps & {
project: LocalProject
server: ServerConnection.Any
@@ -417,24 +472,44 @@ function HomeRecentlyClosedRow(
) {
const unreachable = () => props.serverHealth(props.server)?.healthy === false
const path = () => {
const home = props.homedir
const home = props.homedirForServer(props.server)
const worktree = props.project.worktree
if (home && (worktree === home || worktree.startsWith(`${home}/`))) return `~${worktree.slice(home.length)}`
return worktree
}
return (
<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])}
<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
`}
>
<HomeProjectAvatar project={props.project} outline />
<span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
</HomeProjectNavButton>
</TooltipV2>
<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>
)
}
+17 -2
View File
@@ -1,18 +1,33 @@
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()}
+5 -30
View File
@@ -478,12 +478,7 @@ export type Endpoint5_31Output =
readonly location?: Location.Ref | undefined
readonly data: {
readonly sessionID: Session.ID
readonly error: {
readonly type: string
readonly message: string
readonly status?: number | undefined
readonly data?: Schema.Json | undefined
}
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
}
}
| {
@@ -610,12 +605,7 @@ export type Endpoint5_31Output =
readonly data: {
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly error: {
readonly type: string
readonly message: string
readonly status?: number | undefined
readonly data?: Schema.Json | undefined
}
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly cost?: (number & Brand.Brand<"Money.USD">) | undefined
readonly tokens?:
| {
@@ -777,12 +767,7 @@ export type Endpoint5_31Output =
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly id: string
readonly error: {
readonly type: string
readonly message: string
readonly status?: number | undefined
readonly data?: Schema.Json | undefined
}
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly content?:
| readonly [
(
@@ -822,12 +807,7 @@ export type Endpoint5_31Output =
readonly assistantMessageID: SessionMessage.ID
readonly attempt: number
readonly at: number
readonly error: {
readonly type: string
readonly message: string
readonly status?: number | undefined
readonly data?: Schema.Json | undefined
}
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
}
}
| {
@@ -868,12 +848,7 @@ export type Endpoint5_31Output =
readonly data: {
readonly sessionID: Session.ID
readonly reason: "auto" | "manual"
readonly error: {
readonly type: string
readonly message: string
readonly status?: number | undefined
readonly data?: Schema.Json | undefined
}
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly inputID?: SessionMessage.ID | undefined
}
}
+13 -73
View File
@@ -104,7 +104,7 @@ export type ToolTextContent = { type: "text"; text: string }
export type ToolFileContent = { type: "file"; uri: string; mime: string; name?: string | null }
export type SessionStructuredError = { type: string; message: string; status?: number; data?: JsonValue }
export type SessionStructuredError = { type: string; message: string; status?: number }
export type SessionMessageCompactionRunning = {
type: "compaction"
@@ -2647,12 +2647,7 @@ export type SessionImportInput = {
| {
readonly status: "error"
readonly input: { readonly [x: string]: JsonValue }
readonly error: {
readonly type: string
readonly message: string
readonly status?: number
readonly data?: JsonValue
}
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
readonly content?: readonly [
(
| { readonly type: "text"; readonly text: string }
@@ -2687,21 +2682,11 @@ export type SessionImportInput = {
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly error?: {
readonly type: string
readonly message: string
readonly status?: number
readonly data?: JsonValue
}
readonly error?: { readonly type: string; readonly message: string; readonly status?: number }
readonly retry?: {
readonly attempt: number
readonly at: number
readonly error: {
readonly type: string
readonly message: string
readonly status?: number
readonly data?: JsonValue
}
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
}
}
| (
@@ -2732,12 +2717,7 @@ export type SessionImportInput = {
readonly time: { readonly created: number }
readonly status: "failed"
readonly reason: "auto" | "manual"
readonly error: {
readonly type: string
readonly message: string
readonly status?: number
readonly data?: JsonValue
}
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
}
)
>
@@ -2934,12 +2914,7 @@ export type SessionImportInput = {
| {
readonly status: "error"
readonly input: { readonly [x: string]: JsonValue }
readonly error: {
readonly type: string
readonly message: string
readonly status?: number
readonly data?: JsonValue
}
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
readonly content?: readonly [
(
| { readonly type: "text"; readonly text: string }
@@ -2974,21 +2949,11 @@ export type SessionImportInput = {
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly error?: {
readonly type: string
readonly message: string
readonly status?: number
readonly data?: JsonValue
}
readonly error?: { readonly type: string; readonly message: string; readonly status?: number }
readonly retry?: {
readonly attempt: number
readonly at: number
readonly error: {
readonly type: string
readonly message: string
readonly status?: number
readonly data?: JsonValue
}
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
}
}
| (
@@ -3019,12 +2984,7 @@ export type SessionImportInput = {
readonly time: { readonly created: number }
readonly status: "failed"
readonly reason: "auto" | "manual"
readonly error: {
readonly type: string
readonly message: string
readonly status?: number
readonly data?: JsonValue
}
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
}
)
>
@@ -3221,12 +3181,7 @@ export type SessionImportInput = {
| {
readonly status: "error"
readonly input: { readonly [x: string]: JsonValue }
readonly error: {
readonly type: string
readonly message: string
readonly status?: number
readonly data?: JsonValue
}
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
readonly content?: readonly [
(
| { readonly type: "text"; readonly text: string }
@@ -3261,21 +3216,11 @@ export type SessionImportInput = {
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly error?: {
readonly type: string
readonly message: string
readonly status?: number
readonly data?: JsonValue
}
readonly error?: { readonly type: string; readonly message: string; readonly status?: number }
readonly retry?: {
readonly attempt: number
readonly at: number
readonly error: {
readonly type: string
readonly message: string
readonly status?: number
readonly data?: JsonValue
}
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
}
}
| (
@@ -3306,12 +3251,7 @@ export type SessionImportInput = {
readonly time: { readonly created: number }
readonly status: "failed"
readonly reason: "auto" | "manual"
readonly error: {
readonly type: string
readonly message: string
readonly status?: number
readonly data?: JsonValue
}
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
}
)
>
+1 -5
View File
@@ -780,10 +780,7 @@ function llmError(method: string, error: unknown) {
? new InvalidProviderOutputReason({ message: error.message })
: APICallError.isInstance(error)
? apiCallErrorReason(error)
: new UnknownProviderReason({
message: unknownErrorMessage(error),
data: Schema.decodeUnknownSync(Schema.Json)(jsonValue(error)),
})
: new UnknownProviderReason({ message: unknownErrorMessage(error) })
return new AIError({
module: "AISDK",
method,
@@ -795,7 +792,6 @@ function apiCallErrorReason(error: APICallError) {
const details = providerErrorDetails(error)
const reason = RequestExecutor.classifyHttpFailure({
message: details.message,
data: Schema.decodeUnknownSync(Schema.Json)(jsonValue(error.data ?? error.responseBody ?? null)),
url: error.url,
status: error.statusCode,
code: details.code,
-4
View File
@@ -18,7 +18,6 @@ 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"
@@ -29,7 +28,6 @@ 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"
@@ -54,7 +52,6 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
LMStudioPlugin,
MistralPlugin,
NvidiaPlugin,
OllamaPlugin,
OpencodePlugin,
SnowflakeCortexPlugin,
OpenAICompatiblePlugin,
@@ -65,7 +62,6 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
TogetherAIPlugin,
VercelPlugin,
VenicePlugin,
VLLMPlugin,
XAIPlugin,
ZenmuxPlugin,
DynamicProviderPlugin,
-233
View File
@@ -1,233 +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 = "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() }
}
-162
View File
@@ -1,162 +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 = "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() }
}
+3 -38
View File
@@ -29,41 +29,10 @@ 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.
## [CLI](https://opencode.ai/v2/docs/cli)
## [Configuration](https://opencode.ai/v2/docs/config)
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:
OpenCode configuration uses JSON or JSONC. Include the published schema so the
user's editor can validate fields and provide autocomplete:
```jsonc
{
@@ -86,10 +55,6 @@ 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
@@ -528,7 +528,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
return
case "provider-error":
providerFailed = true
yield* failAssistant({ type: "provider.unknown", message: event.message, data: event.data })
yield* failAssistant({ type: "provider.unknown", message: event.message })
return
}
})
@@ -61,11 +61,5 @@ export function toSessionError(cause: unknown): SessionError.Error {
function providerError(type: string, reason: AIError["reason"]): SessionError.Error {
const status =
("http" in reason ? reason.http?.response?.status : undefined) ?? ("status" in reason ? reason.status : undefined)
const data = "data" in reason ? reason.data : reason._tag === "InvalidProviderOutput" ? reason.raw : undefined
return {
type,
message: reason.message,
...(status === undefined ? {} : { status }),
...(data === undefined ? {} : { data }),
}
return { type, message: reason.message, ...(status === undefined ? {} : { status }) }
}
-2
View File
@@ -487,7 +487,6 @@ it.effect("derives status and code when the AI SDK error message is empty", () =
expect(projected.type).toBe("provider.invalid-request")
expect(projected.status).toBe(404)
expect(projected.message).not.toBe("")
expect(projected.data).toEqual({ error: { message: "", code: "not_found" } })
}),
)
@@ -505,7 +504,6 @@ it.effect("preserves complete HTTP context on AI SDK call errors", () =>
expect(http?.response?.status).toBe(404)
expect(http?.response?.headers["authorization"]).toBe("Bearer secret-token")
expect(http?.body).toBe('{"error":{"message":"","code":"not_found"}}')
expect(error.reason).toMatchObject({ data: '{"error":{"message":"","code":"not_found"}}' })
}),
)
@@ -1,342 +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 { 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 } } }),
})
}
@@ -1,289 +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 { 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 } } }),
})
}
-18
View File
@@ -61,24 +61,6 @@ describe("toSessionError", () => {
).type,
).toBe("provider.no-route")
expect(toSessionError(llm(new UnknownProviderReason({ message: "unknown" }))).type).toBe("provider.unknown")
expect(toSessionError(llm(new InvalidProviderOutputReason({ message: "malformed", raw: "not-json" })))).toEqual({
type: "provider.invalid-output",
message: "malformed",
data: "not-json",
})
})
test("preserves provider error data", () => {
const data = {
type: "error",
sequence_number: 2,
error: { type: "server_error", code: null, message: null },
}
expect(toSessionError(llm(new UnknownProviderReason({ message: "stream error", data })))).toEqual({
type: "provider.unknown",
message: "stream error",
data,
})
})
test("preserves the permission rejection type without exposing internal fields", () => {
+18 -23
View File
@@ -925,7 +925,7 @@ describe("SessionRunnerLLM", () => {
yield* admit(session, "Second prompt")
const titleFailed = yield* Deferred.make<void>()
yield* TestLLM.push(
Stream.make(LLMEvent.providerError({ message: "Title provider unavailable", data: {} })).pipe(
Stream.make(LLMEvent.providerError({ message: "Title provider unavailable" })).pipe(
Stream.ensuring(Deferred.succeed(titleFailed, undefined)),
),
TestLLM.text("Recovered", "text-recovered"),
@@ -2179,7 +2179,7 @@ describe("SessionRunnerLLM", () => {
yield* TestLLM.push(TestLLM.text("Earlier answer", "text-manual-provider-history"))
yield* runPrompt(session, "Earlier question")
yield* TestLLM.push([LLMEvent.providerError({ message: "summary unavailable", data: {} })])
yield* TestLLM.push([LLMEvent.providerError({ message: "summary unavailable" })])
const compaction = yield* session.compact({ sessionID })
yield* session.resume(sessionID)
@@ -2337,7 +2337,7 @@ describe("SessionRunnerLLM", () => {
currentModel = compactModel
requests.length = 0
yield* TestLLM.push(
[LLMEvent.providerError({ message: "Unsupported parameter: max_output_tokens", data: {} })],
[LLMEvent.providerError({ message: "Unsupported parameter: max_output_tokens" })],
TestLLM.text("Must not run", "text-after-failed-compaction"),
)
yield* admit(session, "Recent exact request ".repeat(180))
@@ -2362,7 +2362,7 @@ describe("SessionRunnerLLM", () => {
yield* TestLLM.push(
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.providerError({ message: "prompt too long", data: {}, classification: "context-overflow" }),
LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
],
TestLLM.text("## Objective\n- Recover overflow", "text-summary"),
TestLLM.text("Recovered", "text-final"),
@@ -2389,7 +2389,7 @@ describe("SessionRunnerLLM", () => {
const session = yield* setupOverflowRecovery
currentModel = model
yield* TestLLM.push(
[LLMEvent.providerError({ message: "prompt too long", data: {}, classification: "context-overflow" })],
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
TestLLM.text("## Objective\n- Recover unknown limit", "text-summary-unknown-limit"),
TestLLM.text("Recovered", "text-final-unknown-limit"),
)
@@ -2408,7 +2408,7 @@ describe("SessionRunnerLLM", () => {
const session = yield* setupOverflowRecovery
currentModel = undersizedContextModel
yield* TestLLM.push(
[LLMEvent.providerError({ message: "prompt too long", data: {}, classification: "context-overflow" })],
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
TestLLM.text("## Objective\n- Recover undersized limit", "text-summary-undersized-limit"),
TestLLM.text("Recovered", "text-final-undersized-limit"),
)
@@ -2427,7 +2427,7 @@ describe("SessionRunnerLLM", () => {
const session = yield* setupOverflowRecovery
const overflow = () => [
LLMEvent.stepStart({ index: 0 }),
LLMEvent.providerError({ message: "prompt too long", data: {}, classification: "context-overflow" }),
LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
]
yield* TestLLM.push(overflow(), TestLLM.text("## Objective\n- Recover once", "text-summary"), overflow())
yield* admit(session, "Continue")
@@ -2474,8 +2474,8 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
const session = yield* setupOverflowRecovery
yield* TestLLM.push(
[LLMEvent.providerError({ message: "prompt too long", data: {}, classification: "context-overflow" })],
[LLMEvent.providerError({ message: "summary unavailable", data: {} })],
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
[LLMEvent.providerError({ message: "summary unavailable" })],
)
yield* admit(session, "Continue")
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long")
@@ -2502,7 +2502,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
const session = yield* setupOverflowRecovery
yield* TestLLM.push(
[LLMEvent.providerError({ message: "prompt too long", data: {}, classification: "context-overflow" })],
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
TestLLM.text("## Objective\n- Interrupted", "text-summary"),
)
const first = yield* TestLLM.gate
@@ -4078,10 +4078,9 @@ describe("SessionRunnerLLM", () => {
it.effect("projects provider errors as terminal assistant step failures", () =>
Effect.gen(function* () {
const session = yield* setup
const data = { type: "error", error: { type: "server_error" } }
yield* TestLLM.push([
LLMEvent.stepStart({ index: 0 }),
LLMEvent.providerError({ message: "Provider unavailable", data }),
LLMEvent.providerError({ message: "Provider unavailable" }),
])
expect((yield* runPrompt(session, "Fail durably").pipe(Effect.flip)).message).toBe("Provider unavailable")
@@ -4089,11 +4088,7 @@ describe("SessionRunnerLLM", () => {
expect(requests).toHaveLength(1)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Fail durably" },
{
type: "assistant",
finish: "error",
error: { type: "provider.unknown", message: "Provider unavailable", data },
},
{ type: "assistant", finish: "error", error: { type: "provider.unknown", message: "Provider unavailable" } },
])
}),
)
@@ -4101,7 +4096,7 @@ describe("SessionRunnerLLM", () => {
it.effect("projects provider errors emitted before assistant step start", () =>
Effect.gen(function* () {
const session = yield* setup
yield* TestLLM.push([LLMEvent.providerError({ message: "Provider unavailable", data: {} })])
yield* TestLLM.push([LLMEvent.providerError({ message: "Provider unavailable" })])
expect((yield* runPrompt(session, "Fail before step").pipe(Effect.flip)).message).toBe("Provider unavailable")
@@ -4188,7 +4183,7 @@ describe("SessionRunnerLLM", () => {
LLMEvent.textStart({ id: "text-partial" }),
LLMEvent.textDelta({ id: "text-partial", text: "Partial" }),
LLMEvent.textEnd({ id: "text-partial" }),
LLMEvent.providerError({ message: "prompt too long", data: {}, classification: "context-overflow" }),
LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
])
expect((yield* runPrompt(session, "Fail after output").pipe(Effect.flip)).message).toBe("prompt too long")
@@ -4963,7 +4958,7 @@ describe("SessionRunnerLLM", () => {
yield* TestLLM.push([
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-before-provider-error", name: "echo", input: { text: "settled" } }),
LLMEvent.providerError({ message: "Provider unavailable", data: {} }),
LLMEvent.providerError({ message: "Provider unavailable" }),
])
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
@@ -4990,7 +4985,7 @@ describe("SessionRunnerLLM", () => {
yield* TestLLM.push([
LLMEvent.stepStart({ index: 0 }),
hostedCall("call-hosted-provider-error", "effect"),
LLMEvent.providerError({ message: "Provider unavailable", data: {} }),
LLMEvent.providerError({ message: "Provider unavailable" }),
])
expect((yield* runPrompt(session, "Fail hosted tool durably").pipe(Effect.flip)).message).toBe(
@@ -5022,7 +5017,7 @@ describe("SessionRunnerLLM", () => {
yield* TestLLM.push([
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-defect-provider-error", name: "defect", input: {} }),
LLMEvent.providerError({ message: "Provider unavailable", data: {} }),
LLMEvent.providerError({ message: "Provider unavailable" }),
])
expect((yield* runPrompt(session, "Defect while provider fails").pipe(Effect.flip)).message).toBe(
@@ -5049,7 +5044,7 @@ describe("SessionRunnerLLM", () => {
yield* TestLLM.push([
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-store-provider-error", name: "storefail", input: {} }),
LLMEvent.providerError({ message: "Provider unavailable", data: {} }),
LLMEvent.providerError({ message: "Provider unavailable" }),
])
expect(yield* session.resume(sessionID).pipe(Effect.exit)).toMatchObject({
+1 -1
View File
@@ -310,7 +310,7 @@ it.effect("retries after a failed title request", () =>
yield* insertSession(sessionID)
yield* prompt(sessionID, "Retry this title")
const title = yield* SessionTitle.Service
titleStream = () => Stream.make(LLMEvent.providerError({ message: "Provider unavailable", data: {} }))
titleStream = () => Stream.make(LLMEvent.providerError({ message: "Provider unavailable" }))
yield* title.generateForFirstPrompt(sessionID)
titleStream = successfulTitle
+11
View File
@@ -9,6 +9,7 @@ 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"
@@ -236,6 +237,7 @@ 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()
@@ -243,6 +245,10 @@ const main = Effect.gen(function* () {
},
checkForUpdates: () => void showUpdaterDialog(updater),
relaunch,
recentProjects: () => {
const win = getLastFocusedWindow()
return win ? (recentProjects.get(win.webContents.id) ?? []) : []
},
}
registerIpcHandlers({
killSidecar: () => undefined,
@@ -273,12 +279,17 @@ 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(() => {
+16 -1
View File
@@ -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 } from "@opencode-ai/app/desktop-menu"
import type { DesktopMenuAction, DesktopRecentProject } 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,6 +49,7 @@ 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) {
@@ -92,6 +93,20 @@ 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)
+26
View File
@@ -4,6 +4,7 @@ import {
DESKTOP_MENU,
desktopMenuVisible,
type DesktopMenuEntry,
type DesktopRecentProject,
type DesktopMenuRole,
} from "@opencode-ai/app/desktop-menu"
@@ -16,6 +17,7 @@ type Deps = {
trigger: (id: string) => void
checkForUpdates: () => void
relaunch: () => void
recentProjects: () => DesktopRecentProject[]
}
export function createMenu(deps: Deps) {
@@ -44,6 +46,30 @@ 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)
+1
View File
@@ -84,6 +84,7 @@ 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)
+2 -1
View File
@@ -1,4 +1,4 @@
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
import type { DesktopMenuAction, DesktopRecentProject } 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,6 +71,7 @@ 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?: {
+19
View File
@@ -15,7 +15,9 @@ 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"
@@ -360,6 +362,7 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) {
function DesktopEffects() {
const cmd = useCommand()
const global = useGlobal()
menuTrigger = (id) => cmd.trigger(id)
const theme = useTheme()
@@ -373,6 +376,22 @@ 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
}
-1
View File
@@ -8,5 +8,4 @@ export const Error = Schema.Struct({
type: Schema.String,
message: Schema.String,
status: Schema.Int.check(Schema.isBetween({ minimum: 100, maximum: 599 })).pipe(optional),
data: Schema.Json.pipe(optional),
}).annotate({ identifier: "Session.StructuredError" })
@@ -12,11 +12,6 @@ describe("SessionError", () => {
const values: SessionError.Error[] = [
{ type: "provider.rate-limit", message: "Slow down" },
{ type: "provider.auth", message: "Authentication failed" },
{
type: "provider.unknown",
message: "Stream error",
data: { type: "error", error: { type: "server_error" } },
},
{ type: "provider.future-condition", message: "A future provider failure" },
{ type: "unknown", message: "Unexpected" },
]
+12 -8
View File
@@ -23,7 +23,7 @@ import {
NEW_SESSION_TAB_TITLE,
sessionTabComplete,
sessionTabDetail,
sessionTabNumberLabel,
sessionTabShortcutLabel,
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 = () => Math.max(2, String(items().length).length)
const numberWidth = () => 2
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" paddingRight={1}>
<box zIndex={1} width="100%" flexDirection="row" paddingLeft={1} paddingRight={1}>
<text
width={numberWidth() + 1}
width={numberWidth()}
fg={numberColor()}
selectable={false}
attributes={selected() ? TextAttributes.BOLD : undefined}
>
{sessionTabNumberLabel(index()).padStart(numberWidth())}
{sessionTabShortcutLabel(index())}
</text>
<text
width={titleWidth()}
@@ -1040,7 +1040,8 @@ 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)
const numberWidth = () => Math.max(2, String(items().length).length)
// Shortcut labels stay one cell wide: 1-9, 0 for ten, then a neutral dot.
const numberWidth = () => 2
// 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)
@@ -1140,8 +1141,11 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
onLevel={setSweepLevel}
/>
<box zIndex={1} width="100%" flexDirection="row">
<text width={numberWidth() + 1} fg={numberColor()} selectable={false} attributes={bold()}>
{(tab === NEW_SESSION_TAB ? "+" : sessionTabNumberLabel(tabNumber() - 1)).padStart(numberWidth())}
<text width={1} selectable={false}>
{" "}
</text>
<text width={numberWidth()} fg={numberColor()} selectable={false} attributes={bold()}>
{tab === NEW_SESSION_TAB ? "+" : sessionTabShortcutLabel(tabNumber() - 1)}
</text>
<text
width={availableTitleWidth()}
@@ -7,8 +7,10 @@ export type SessionTabUnread = "activity" | "error"
export const NEW_SESSION_TAB_TITLE = "New session"
export function sessionTabNumberLabel(index: number) {
return String(index + 1)
export function sessionTabShortcutLabel(index: number) {
if (index >= 0 && index < 9) return String(index + 1)
if (index === 9) return "0"
return "·"
}
export function sessionTabDetail(
@@ -13,7 +13,7 @@ import {
sessionTabComplete,
sessionTabDetail,
sessionTabOverflowWidth,
sessionTabNumberLabel,
sessionTabShortcutLabel,
} 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 tabs by ordinal", () => {
expect(Array.from({ length: 12 }, (_, index) => sessionTabNumberLabel(index))).toEqual([
test("labels direct shortcut tabs and marks unbound tabs with a dot", () => {
expect(Array.from({ length: 12 }, (_, index) => sessionTabShortcutLabel(index))).toEqual([
"1",
"2",
"3",
@@ -36,9 +36,9 @@ describe("session tabs", () => {
"7",
"8",
"9",
"10",
"11",
"12",
"0",
"·",
"·",
])
})
@@ -152,43 +152,6 @@ 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:
@@ -221,43 +184,6 @@ 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"