Compare commits

..

5 Commits

Author SHA1 Message Date
Brendan Allan d33039614f refactor(app): use shared client connection (#43001) 2026-08-17 15:39:20 +08:00
Brendan Allan 01ce44bd13 remove test changes 2026-08-17 15:23:40 +08:00
Brendan Allan 647ae85a04 rename to CreateData from CreateServerData 2026-08-17 15:07:31 +08:00
Brendan Allan a1e018668c refactor(client): remove event coalescing 2026-08-17 14:52:48 +08:00
Brendan Allan d5451cdabe refactor(client): share Solid server data 2026-08-17 14:40:46 +08:00
37 changed files with 244 additions and 2250 deletions
+1 -2
View File
@@ -75,8 +75,7 @@ export function createClientConnection(initialApi: OpenCodeClient, options: Clie
if (signal.aborted) return { error: undefined, connectedAt }
if (first.done)
return {
error:
request.signal.reason instanceof Error ? request.signal.reason : new Error("Event stream disconnected"),
error: request.signal.reason instanceof Error ? request.signal.reason : new Error("Event stream disconnected"),
connectedAt,
}
if (first.value.type !== "server.connected")
@@ -1,68 +0,0 @@
export * as ConfigFormatterPlugin from "./formatter.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Npm } from "@opencode-ai/util/npm"
import { AppProcess } from "@opencode-ai/util/process"
import { Effect, Stream } from "effect"
import { Config } from "../../config.js"
import { Formatter } from "../../formatter.js"
import { make, type Info } from "../../formatter/builtins.js"
import { Location } from "../../location.js"
export const Plugin = define({
id: "opencode.config.formatter",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const formatter = yield* Formatter.Service
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const location = yield* Location.Service
const npm = yield* Npm.Service
const processes = yield* AppProcess.Service
const loaded = { entries: yield* config.entries() }
yield* formatter.transform((draft) => {
const configured = Config.latest(loaded.entries, "formatter")
if (!configured) return
const builtIns = make({
directory: location.directory,
worktree: location.project.directory,
fs,
npm,
processes,
bin: global.bin,
})
builtIns.forEach(draft.set)
if (configured === true) return
for (const [name, entry] of Object.entries(configured)) {
if (entry.disabled) {
draft.remove(name)
continue
}
const builtIn = builtIns.find((formatter) => formatter.name === name)
const current: Info = {
name,
extensions: entry.extensions ?? builtIn?.extensions ?? [],
environment: { ...builtIn?.environment, ...entry.environment },
enabled:
builtIn && !entry.command ? builtIn.enabled : Effect.succeed(entry.command ? [...entry.command] : false),
}
draft.set(current)
}
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() =>
config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(formatter.reload()),
),
),
Effect.forkScoped({ startImmediately: true }),
)
}),
})
@@ -1,43 +1,10 @@
import { Effect } from "effect"
import { sql } from "drizzle-orm"
import type { DatabaseMigration } from "../migration.js"
const previousV2Marker = "20260730195856_optional_session_title"
const migration: DatabaseMigration.Migration = {
id: "20260804233008_loose_psylocke",
up(tx) {
return Effect.gen(function* () {
// This marker identifies the completed pre-split V2 lineage. Its V2 tables
// are canonical, so rename them in place instead of replaying the V1 squash.
if (yield* tx.get(sql`SELECT id FROM migration WHERE id = ${previousV2Marker}`)) {
const v1Only = yield* tx.get(sql`
SELECT 1
FROM message
WHERE NOT EXISTS (
SELECT 1 FROM session_message WHERE session_message.session_id = message.session_id
)
LIMIT 1
`)
if (v1Only) return yield* Effect.die(new Error("Previous V2 database contains V1-only session history"))
yield* tx.run(`DROP INDEX IF EXISTS \`session_project_idx\`;`)
yield* tx.run(`DROP INDEX IF EXISTS \`session_workspace_idx\`;`)
yield* tx.run(`DROP INDEX IF EXISTS \`session_parent_idx\`;`)
yield* tx.run(`DROP INDEX IF EXISTS \`session_time_suspended_idx\`;`)
yield* tx.run(`ALTER TABLE \`session\` RENAME TO \`session_v2\`;`)
yield* tx.run(`CREATE INDEX \`session_v2_project_idx\` ON \`session_v2\` (\`project_id\`);`)
yield* tx.run(`CREATE INDEX \`session_v2_workspace_idx\` ON \`session_v2\` (\`workspace_id\`);`)
yield* tx.run(`CREATE INDEX \`session_v2_parent_idx\` ON \`session_v2\` (\`parent_id\`);`)
yield* tx.run(
`CREATE INDEX \`session_v2_time_suspended_idx\` ON \`session_v2\` (\`time_suspended\`) WHERE "session_v2"."time_suspended" is not null;`,
)
yield* tx.run(`DROP TABLE IF EXISTS \`data_migration\`;`)
yield* tx.run(`DROP TABLE IF EXISTS \`session_context_epoch\`;`)
yield* tx.run(`DROP TABLE IF EXISTS \`session_input\`;`)
return
}
yield* tx.run(`
CREATE TABLE IF NOT EXISTS \`kv\` (
\`key\` text PRIMARY KEY,
+55 -32
View File
@@ -4,21 +4,15 @@ import { Context, Effect, Layer } from "effect"
import { ChildProcess } from "effect/unstable/process"
import path from "path"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Npm } from "@opencode-ai/util/npm"
import { AppProcess } from "@opencode-ai/util/process"
import { Global } from "@opencode-ai/util/global"
import { Config } from "./config.js"
import { Location } from "./location.js"
import type { Info } from "./formatter/builtins.js"
import { State } from "./state.js"
import { make, type Info } from "./formatter/builtins.js"
type Data = {
formatters: Info[]
}
export type Draft = {
set: (formatter: Info) => void
remove: (name: string) => void
}
export interface Interface extends State.Transformable<Draft> {
export interface Interface {
readonly file: (filepath: string) => Effect.Effect<boolean>
}
@@ -27,24 +21,54 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const npm = yield* Npm.Service
const processes = yield* AppProcess.Service
const global = yield* Global.Service
const commands = new Map<string, string[] | false>()
const state = State.create<Data, Draft>({
name: "formatter",
initial: () => ({ formatters: [] }),
draft: (draft) => ({
set: (formatter) => {
const index = draft.formatters.findIndex((item) => item.name === formatter.name)
if (index === -1) draft.formatters.push(formatter)
else draft.formatters[index] = formatter
},
remove: (name) => {
draft.formatters = draft.formatters.filter((formatter) => formatter.name !== name)
},
}),
finalize: () => Effect.sync(() => commands.clear()),
})
let formatters: Info[] = []
const load = yield* Effect.cached(
Effect.gen(function* () {
const configured = Config.latest(yield* config.entries(), "formatter")
if (!configured) {
yield* Effect.logInfo("all formatters are disabled")
return
}
const builtIns = make({
directory: location.directory,
worktree: location.project.directory,
fs,
npm,
processes,
bin: global.bin,
})
formatters = builtIns
if (configured === true) return
for (const [name, entry] of Object.entries(configured)) {
const index = formatters.findIndex((formatter) => formatter.name === name)
if (entry.disabled) {
if (index !== -1) formatters.splice(index, 1)
continue
}
const builtIn = builtIns.find((formatter) => formatter.name === name)
const formatter: Info = {
name,
extensions: entry.extensions ?? builtIn?.extensions ?? [],
environment: { ...builtIn?.environment, ...entry.environment },
enabled:
builtIn && !entry.command ? builtIn.enabled : Effect.succeed(entry.command ? [...entry.command] : false),
}
if (index === -1) formatters.push(formatter)
else formatters[index] = formatter
}
}).pipe(Effect.withSpan("Formatter.load")),
)
const command = Effect.fnUntraced(function* (formatter: Info) {
const cached = commands.get(formatter.name)
@@ -55,9 +79,8 @@ const layer = Layer.effect(
})
const file = Effect.fn("Formatter.file")(function* (filepath: string) {
const matching = state
.get()
.formatters.filter((formatter) => formatter.extensions.includes(path.extname(filepath)))
yield* load
const matching = formatters.filter((formatter) => formatter.extensions.includes(path.extname(filepath)))
for (const formatter of matching) {
const enabled = yield* command(formatter)
@@ -95,12 +118,12 @@ const layer = Layer.effect(
return false
})
return Service.of({ transform: state.transform, reload: state.reload, file })
return Service.of({ file })
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Location.node, AppProcess.node],
deps: [Config.node, FSUtil.node, Location.node, Npm.node, AppProcess.node, Global.node],
})
+1 -1
View File
@@ -33,7 +33,7 @@ const layer = Layer.effect(
` Workspace root folder: ${location.project.directory}`,
` Is directory a git repo: ${location.vcs?.type === "git" ? "yes" : "no"}`,
` Platform: ${process.platform}`,
` Prefer ${global.tmp} over generic system temporary directories such as /tmp; it is pre-created and approved for external access.`,
` Use ${global.tmp} for temporary work outside the workspace; it already exists and is pre-approved for external directory access.`,
"</env>",
].join("\n"),
),
-6
View File
@@ -3,7 +3,6 @@ export * as PluginInternal from "./internal.js"
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
import { AppProcess } from "@opencode-ai/util/process"
import { Context, Effect, Scope } from "effect"
import { HttpClient } from "effect/unstable/http"
import { Agent } from "../agent.js"
@@ -13,7 +12,6 @@ import { Config } from "../config.js"
import { Credential } from "../credential.js"
import { ConfigAgentPlugin } from "../config/plugin/agent.js"
import { ConfigCommandPlugin } from "../config/plugin/command.js"
import { ConfigFormatterPlugin } from "../config/plugin/formatter.js"
import { ConfigInstructionPlugin } from "../config/plugin/instruction.js"
import { ConfigProviderPlugin } from "../config/plugin/provider.js"
import { ConfigPolicyPlugin } from "../config/plugin/policy.js"
@@ -76,7 +74,6 @@ import { WellKnownPlugin } from "../wellknown/plugin.js"
const services = Effect.fn("PluginInternal.services")(function* () {
const agent = yield* Agent.Service
const processes = yield* AppProcess.Service
const catalog = yield* Catalog.Service
const command = yield* Command.Service
const config = yield* Config.Service
@@ -114,7 +111,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const wellknown = yield* WellKnown.Service
return Context.mergeAll(
Context.make(Agent.Service, agent),
Context.make(AppProcess.Service, processes),
Context.make(Catalog.Service, catalog),
Context.make(Command.Service, command),
Context.make(Config.Service, config),
@@ -159,7 +155,6 @@ export type Requirements = ContextServices<Effect.Success<ReturnType<typeof serv
export const requirements = LayerNode.group([
Agent.node,
AppProcess.node,
Catalog.node,
Command.node,
Config.node,
@@ -229,7 +224,6 @@ const post = [
ConfigReferencePlugin.Plugin,
ConfigAgentPlugin.Plugin,
ConfigCommandPlugin.Plugin,
ConfigFormatterPlugin.Plugin,
ConfigSkillPlugin.Plugin,
ConfigProviderPlugin.Plugin,
ConfigWebSearchPlugin.Plugin,
-6
View File
@@ -15,10 +15,8 @@ import { GoogleVertexPlugin } from "./provider/google-vertex.js"
import { GroqPlugin } from "./provider/groq.js"
import { KiloPlugin } from "./provider/kilo.js"
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 +27,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"
@@ -51,10 +48,8 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
GroqPlugin,
KiloPlugin,
LLMGatewayPlugin,
LMStudioPlugin,
MistralPlugin,
NvidiaPlugin,
OllamaPlugin,
OpencodePlugin,
SnowflakeCortexPlugin,
OpenAICompatiblePlugin,
@@ -65,7 +60,6 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
TogetherAIPlugin,
VercelPlugin,
VenicePlugin,
VLLMPlugin,
XAIPlugin,
ZenmuxPlugin,
DynamicProviderPlugin,
@@ -1,174 +0,0 @@
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Document, type Entry } from "@opencode-ai/schema/config"
import { Duration, Effect, Schedule, Schema, Semaphore, Stream } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Config } from "../../config.js"
import { Model } from "../../model.js"
import { Provider } from "../../provider.js"
import type { PluginInternal } from "../internal.js"
const providerID = "lmstudio"
const RemoteModel = Schema.Struct({
type: Schema.Literals(["llm", "embedding"]),
key: Schema.String,
display_name: Schema.String,
architecture: Schema.NullOr(Schema.String).pipe(Schema.optional),
loaded_instances: Schema.Array(
Schema.Struct({
config: Schema.Struct({ context_length: Schema.Int }),
}),
),
max_context_length: Schema.Int,
capabilities: Schema.Struct({
vision: Schema.Boolean,
trained_for_tool_use: Schema.Boolean,
}).pipe(Schema.optional),
})
const Response = Schema.Struct({ models: Schema.Array(RemoteModel) })
const discovery = new Map<string, { checked: number; apiKey?: string; models?: (typeof RemoteModel.Type)[] }>()
const discoveryLock = Semaphore.makeUnsafe(1)
export function make(origin = "http://127.0.0.1:1234", interval: Duration.Input = "30 seconds") {
return define({
id: "opencode.provider.lmstudio",
effect: Effect.fn(function* (ctx) {
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
const config = yield* Config.Service
const source = { current: configured(yield* config.entries(), origin) }
const loaded = { models: [] as (typeof RemoteModel.Type)[], hash: "[]" }
yield* ctx.integration.transform((integrations) => {
if (loaded.models.length === 0) return
integrations.remove(providerID)
})
yield* ctx.catalog.transform((catalog) => {
if (loaded.models.length === 0) return
for (const model of catalog.provider.get(providerID)?.models.values() ?? []) {
catalog.model.remove(providerID, model.id)
}
catalog.provider.update(providerID, (provider) => {
provider.name = "LM Studio"
provider.activation = "enabled"
provider.package = "@opencode-ai/ai/providers/openai-compatible"
provider.settings = {
baseURL: source.current.baseURL,
provider: providerID,
apiKey: source.current.apiKey ?? "",
}
provider.integrationID = undefined
})
for (const item of loaded.models) {
catalog.model.update(providerID, item.key, (model) => {
model.modelID = Model.ID.make(item.key)
model.name = item.display_name || item.key
model.family = item.architecture ? Model.Family.make(item.architecture) : undefined
model.capabilities = {
tools: item.capabilities?.trained_for_tool_use ?? false,
input: ["text", ...(item.capabilities?.vision ? ["image"] : [])],
output: ["text"],
}
model.limit = {
context:
item.loaded_instances.length === 0
? item.max_context_length
: Math.min(...item.loaded_instances.map((instance) => instance.config.context_length)),
output: 0,
}
})
}
})
const discover = Effect.fn("LMStudioPlugin.discover")(function* () {
const current = source.current
if (!current.endpoint) return undefined
return yield* discoveryLock.withPermit(
Effect.gen(function* () {
const cached = discovery.get(current.endpoint)
if (cached && cached.apiKey === current.apiKey && Date.now() - cached.checked < Duration.toMillis(interval))
return { source: current, models: cached.models }
discovery.set(current.endpoint, {
checked: Date.now(),
apiKey: current.apiKey,
models: cached && cached.apiKey === current.apiKey ? cached.models : undefined,
})
const request = current.apiKey
? HttpClientRequest.get(current.endpoint).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(current.apiKey),
)
: HttpClientRequest.get(current.endpoint).pipe(HttpClientRequest.acceptJson)
const response = yield* http
.execute(request)
.pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(Response)), Effect.timeout("1 second"))
const models = response.models
.filter((model) => model.type === "llm" && model.key.length > 0)
.toSorted((a, b) => a.key.localeCompare(b.key))
discovery.set(current.endpoint, { checked: Date.now(), apiKey: current.apiKey, models })
return { source: current, models }
}),
)
})
const refresh = Effect.fn("LMStudioPlugin.refresh")(function* () {
const result = yield* discover()
if (!result?.models || result.source !== source.current) return
const hash = JSON.stringify(result.models)
if (hash === loaded.hash) return
loaded.models = result.models
loaded.hash = hash
yield* ctx.integration.reload()
yield* ctx.catalog.reload()
})
// Keep the last successful inventory through transient outages instead of flickering model availability.
yield* refresh().pipe(Effect.ignore, Effect.repeat(Schedule.spaced(interval)), Effect.forkScoped)
const reload = Effect.fn("LMStudioPlugin.reload")(function* () {
const next = configured(yield* config.entries(), origin)
if (
next.baseURL === source.current.baseURL &&
next.apiKey === source.current.apiKey &&
next.endpoint === source.current.endpoint
)
return
source.current = next
loaded.models = []
loaded.hash = "[]"
yield* ctx.integration.reload()
yield* ctx.catalog.reload()
yield* refresh().pipe(Effect.ignore)
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(reload),
Effect.forkScoped({ startImmediately: true }),
)
}),
} satisfies PluginInternal.InternalPlugin)
}
export const LMStudioPlugin = make()
function configured(entries: readonly Entry[], origin: string) {
const settings = entries
.filter((entry): entry is Document => entry.type === "document")
.flatMap((entry) => {
const settings = entry.info.providers?.[providerID]?.settings
return settings ? [settings] : []
})
.reduce<Provider.Settings | undefined>((result, item) => Provider.mergeOverlay(result, item), undefined)
const baseURL = (
typeof settings?.baseURL === "string" ? settings.baseURL : `${origin.replace(/\/+$/, "")}/v1`
).replace(/\/+$/, "")
const apiKey = typeof settings?.apiKey === "string" ? settings.apiKey : undefined
if (!URL.canParse(baseURL)) return { baseURL, apiKey }
const url = new URL(baseURL)
if (url.protocol !== "http:" && url.protocol !== "https:") return { baseURL, apiKey }
const prefix = url.pathname.endsWith("/v1") ? url.pathname.slice(0, -3) : url.pathname.replace(/\/+$/, "")
url.pathname = `${prefix}/api/v1/models`
url.search = ""
url.hash = ""
return { baseURL, apiKey, endpoint: url.toString() }
}
-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
+1 -1
View File
@@ -324,7 +324,7 @@ export const layer = (options?: ShellSelect.Options) =>
runFork(
handle.exitCode.pipe(
Effect.flatMap((code) => finish("exited", code)),
Effect.catch(() => finish("exited")),
Effect.catch(() => Effect.void),
),
)
@@ -13,10 +13,6 @@ import { tmpdir } from "./fixture/tmpdir"
import type { SqlClient } from "effect/unstable/sql/SqlClient"
import legacyCredentialsMigration from "@opencode-ai/core/database/migration/20260805200742_import_legacy_credentials"
import worktreeMigration from "@opencode-ai/core/database/migration/20260812213948_worktree"
import previousV2Migration from "@opencode-ai/core/database/migration/20260804233008_loose_psylocke"
import workspaceMigration from "@opencode-ai/core/database/migration/20260808023530_workspace_domain"
import executionClaimsMigration from "@opencode-ai/core/database/migration/20260811161259_execution_claim_attempts"
import sessionInboxMigration from "@opencode-ai/core/database/migration/20260812181746_session_inbox"
import { Global } from "@opencode-ai/util/global"
const run = <A, E>(
@@ -132,142 +128,6 @@ describe("DatabaseMigration", () => {
)
})
test("preserves previous V2 state through the current migration lineage", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`PRAGMA foreign_keys = ON`)
yield* db.run(sql`CREATE TABLE migration (id text PRIMARY KEY, time_completed integer NOT NULL)`)
yield* db.run(sql`
INSERT INTO migration (id, time_completed)
VALUES ('20260730195856_optional_session_title', 1)
`)
yield* db.run(sql`CREATE TABLE project (id text PRIMARY KEY)`)
yield* db.run(sql`
CREATE TABLE project_directory (
project_id text NOT NULL,
directory text NOT NULL,
type text,
strategy text,
time_created integer NOT NULL,
PRIMARY KEY (project_id, directory)
)
`)
yield* db.run(sql`
CREATE TABLE workspace (
id text PRIMARY KEY,
type text NOT NULL,
name text NOT NULL,
project_id text NOT NULL,
time_used integer NOT NULL
)
`)
yield* db.run(sql`
CREATE TABLE session (
id text PRIMARY KEY,
project_id text NOT NULL REFERENCES project(id) ON DELETE CASCADE,
workspace_id text,
parent_id text,
time_suspended integer
)
`)
yield* db.run(sql`CREATE INDEX session_project_idx ON session (project_id)`)
yield* db.run(sql`CREATE INDEX session_workspace_idx ON session (workspace_id)`)
yield* db.run(sql`CREATE INDEX session_parent_idx ON session (parent_id)`)
yield* db.run(
sql`CREATE INDEX session_time_suspended_idx ON session (time_suspended) WHERE "session"."time_suspended" IS NOT NULL`,
)
yield* db.run(sql`
CREATE TABLE session_message (
id text PRIMARY KEY,
session_id text NOT NULL REFERENCES session(id) ON DELETE CASCADE,
data text NOT NULL
)
`)
yield* db.run(sql`CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL)`)
yield* db.run(sql`
CREATE TABLE session_pending (
id text PRIMARY KEY,
session_id text NOT NULL REFERENCES session(id) ON DELETE CASCADE
)
`)
yield* db.run(sql`CREATE TABLE event_sequence (aggregate_id text PRIMARY KEY, seq integer NOT NULL)`)
yield* db.run(sql`
CREATE TABLE event (
id text PRIMARY KEY,
aggregate_id text NOT NULL REFERENCES event_sequence(aggregate_id) ON DELETE CASCADE,
seq integer NOT NULL,
created integer NOT NULL,
type text NOT NULL,
data text NOT NULL
)
`)
yield* db.run(sql`CREATE TABLE data_migration (name text PRIMARY KEY)`)
yield* db.run(sql`INSERT INTO project VALUES ('project')`)
yield* db.run(sql`INSERT INTO project_directory VALUES ('project', '/repo', 'main', NULL, 1)`)
yield* db.run(sql`INSERT INTO session VALUES ('session', 'project', NULL, NULL, NULL)`)
yield* db.run(sql`INSERT INTO session_message VALUES ('message', 'session', '{"text":"preserved"}')`)
yield* db.run(sql`INSERT INTO session_pending VALUES ('pending', 'session')`)
yield* db.run(sql`INSERT INTO event_sequence VALUES ('session', 41)`)
yield* db.run(sql`INSERT INTO event VALUES ('event', 'session', 41, 1, 'session.text.ended.1', '{}')`)
yield* DatabaseMigration.applyOnly(db, [
previousV2Migration,
workspaceMigration,
executionClaimsMigration,
sessionInboxMigration,
worktreeMigration,
])
expect(yield* db.get(sql`SELECT id, resume_attempts FROM session_v2`)).toEqual({
id: "session",
resume_attempts: 0,
})
expect(yield* db.get(sql`SELECT id, data FROM session_message`)).toEqual({
id: "message",
data: '{"text":"preserved"}',
})
expect(yield* db.get(sql`SELECT id FROM session_pending`)).toEqual({ id: "pending" })
expect(yield* db.get(sql`SELECT seq FROM event_sequence`)).toEqual({ seq: 41 })
expect(yield* db.get(sql`SELECT id, seq FROM event`)).toEqual({ id: "event", seq: 41 })
expect(
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`),
).toBeUndefined()
expect(yield* db.get(sql`SELECT directory FROM worktree`)).toEqual({ directory: "/repo" })
expect(yield* db.all<{ table: string }>(sql`PRAGMA foreign_key_list(session_message)`)).toContainEqual(
expect.objectContaining({ table: "session_v2" }),
)
expect(yield* db.all<{ table: string }>(sql`PRAGMA foreign_key_list(session_pending)`)).toContainEqual(
expect.objectContaining({ table: "session_v2" }),
)
}),
)
})
test("rejects previous V2 databases with V1-only session history", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`CREATE TABLE migration (id text PRIMARY KEY, time_completed integer NOT NULL)`)
yield* db.run(sql`
INSERT INTO migration (id, time_completed)
VALUES ('20260730195856_optional_session_title', 1)
`)
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`)
yield* db.run(sql`CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL)`)
yield* db.run(sql`CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL)`)
yield* db.run(sql`INSERT INTO session VALUES ('session')`)
yield* db.run(sql`INSERT INTO message VALUES ('message', 'session')`)
expect((yield* Effect.exit(DatabaseMigration.applyOnly(db, [previousV2Migration])))._tag).toBe("Failure")
expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`)).toEqual({
name: "session",
})
expect(yield* db.get(sql`SELECT id FROM migration WHERE id = ${previousV2Migration.id}`)).toBeUndefined()
}),
)
})
test("copies project directories into worktrees without removing the old table", async () => {
await run(
Effect.gen(function* () {
+118 -151
View File
@@ -1,30 +1,41 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Effect, Layer, Schema } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Info } from "@opencode-ai/schema/config"
import { Global } from "@opencode-ai/util/global"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Npm } from "@opencode-ai/util/npm"
import { Document, Info } from "@opencode-ai/schema/config"
import { Config } from "../src/config"
import { Formatter } from "../src/formatter"
import { Location } from "../src/location"
import { tempGlobalLayer } from "./fixture/global"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
[Global.node, tempGlobalLayer],
]),
)
const it = testEffect(Layer.empty)
type ConfigInput = typeof Info.Encoded
function formatterLayer(directory: string, configured?: ConfigInput["formatter"]) {
const entries =
configured === undefined
? []
: [
new Document({
type: "document",
info: Schema.decodeUnknownSync(Info)({ formatter: configured }),
}),
]
return AppNodeBuilder.build(Formatter.node, [
[Config.node, Config.testLayer(entries)],
[
Location.node,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
],
[Npm.node, Layer.mock(Npm.Service, { which: () => Effect.succeed(undefined) })],
])
}
function withTemp<A, E, R>(body: (directory: string) => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
@@ -33,166 +44,122 @@ function withTemp<A, E, R>(body: (directory: string) => Effect.Effect<A, E, R>)
)
}
function withFormatter<A, E, R>(
configured: ConfigInput["formatter"],
body: (formatter: Formatter.Interface, directory: string) => Effect.Effect<A, E, R>,
) {
return withTemp((directory) =>
Effect.promise(() =>
fs.writeFile(path.join(directory, "opencode.json"), JSON.stringify({ formatter: configured })),
).pipe(
Effect.andThen(
Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* body(yield* Formatter.Service, directory)
}).pipe(
Effect.scoped,
Effect.provide(
LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(directory) })),
),
describe("Formatter", () => {
it.live("does not run formatters marked as disabled in config", () =>
withTemp((directory) =>
Effect.gen(function* () {
const file = path.join(directory, "test.disabled")
expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(false)
}).pipe(
Effect.provide(
formatterLayer(directory, {
disabled: {
disabled: true,
command: [process.execPath, "-e", "process.exit(0)", "$FILE"],
extensions: [".disabled"],
},
}),
),
),
),
)
}
describe("Formatter", () => {
it.live("does not run formatters marked as disabled in config", () =>
withFormatter(
{
disabled: {
disabled: true,
command: [process.execPath, "-e", "process.exit(0)", "$FILE"],
extensions: [".disabled"],
},
},
(formatter, directory) =>
Effect.gen(function* () {
const file = path.join(directory, "test.disabled")
expect(yield* formatter.file(file)).toBe(false)
}),
),
)
it.live("file() returns false when no formatter runs", () =>
withFormatter(false, (formatter, directory) =>
withTemp((directory) =>
Effect.gen(function* () {
const file = path.join(directory, "test.txt")
yield* Effect.promise(() => fs.writeFile(file, "x"))
expect(yield* formatter.file(file)).toBe(false)
}),
expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(false)
}).pipe(Effect.provide(formatterLayer(directory, false))),
),
)
it.live("loads formatter state per directory", () =>
withFormatter(false, (disabledFormatter, off) =>
withFormatter(
{
isolated: {
command: [process.execPath, "-e", "process.exit(0)", "$FILE"],
extensions: [".isolated"],
},
},
(enabledFormatter, on) =>
Effect.gen(function* () {
const offFile = path.join(off, "test.isolated")
const onFile = path.join(on, "test.isolated")
const disabled = yield* disabledFormatter.file(offFile)
const enabled = yield* enabledFormatter.file(onFile)
expect(disabled).toBe(false)
expect(enabled).toBe(true)
}),
withTemp((off) =>
withTemp((on) =>
Effect.gen(function* () {
const offFile = path.join(off, "test.isolated")
const onFile = path.join(on, "test.isolated")
const disabled = yield* Formatter.Service.use((formatter) => formatter.file(offFile)).pipe(
Effect.provide(formatterLayer(off, false)),
)
const enabled = yield* Formatter.Service.use((formatter) => formatter.file(onFile)).pipe(
Effect.provide(
formatterLayer(on, {
isolated: {
command: [process.execPath, "-e", "process.exit(0)", "$FILE"],
extensions: [".isolated"],
},
}),
),
)
expect(disabled).toBe(false)
expect(enabled).toBe(true)
}),
),
),
)
it.live("stops after the first matching formatter succeeds", () =>
withFormatter(
{
first: {
command: [
process.execPath,
"-e",
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'A')",
"$FILE",
],
extensions: [".seq"],
},
second: {
command: [
process.execPath,
"-e",
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'B')",
"$FILE",
],
extensions: [".seq"],
},
},
(formatter, directory) =>
Effect.gen(function* () {
const file = path.join(directory, "test.seq")
yield* Effect.promise(() => fs.writeFile(file, "x"))
expect(yield* formatter.file(file)).toBe(true)
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xA")
}),
withTemp((directory) =>
Effect.gen(function* () {
const file = path.join(directory, "test.seq")
yield* Effect.promise(() => fs.writeFile(file, "x"))
expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(true)
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xA")
}).pipe(
Effect.provide(
formatterLayer(directory, {
first: {
command: [
process.execPath,
"-e",
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'A')",
"$FILE",
],
extensions: [".seq"],
},
second: {
command: [
process.execPath,
"-e",
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'B')",
"$FILE",
],
extensions: [".seq"],
},
}),
),
),
),
)
it.live("tries the next matching formatter when the first fails", () =>
withFormatter(
{
first: {
command: [process.execPath, "-e", "process.exit(1)", "$FILE"],
extensions: [".fallback"],
},
second: {
command: [
process.execPath,
"-e",
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'B')",
"$FILE",
],
extensions: [".fallback"],
},
},
(formatter, directory) =>
Effect.gen(function* () {
const file = path.join(directory, "test.fallback")
yield* Effect.promise(() => fs.writeFile(file, "x"))
expect(yield* formatter.file(file)).toBe(true)
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xB")
}),
),
)
it.live("rebuilds formatter state and clears resolved commands", () =>
withFormatter(false, (formatter, directory) =>
withTemp((directory) =>
Effect.gen(function* () {
const command = { suffix: "A" }
yield* formatter.transform((draft) => {
const suffix = command.suffix
draft.set({
name: "reload",
extensions: [".reload"],
enabled: Effect.succeed([
process.execPath,
"-e",
`const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, '${suffix}')`,
"$FILE",
]),
})
})
const file = path.join(directory, "test.reload")
const file = path.join(directory, "test.fallback")
yield* Effect.promise(() => fs.writeFile(file, "x"))
expect(yield* formatter.file(file)).toBe(true)
command.suffix = "B"
yield* formatter.reload()
expect(yield* formatter.file(file)).toBe(true)
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xAB")
}),
expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(true)
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xB")
}).pipe(
Effect.provide(
formatterLayer(directory, {
first: {
command: [process.execPath, "-e", "process.exit(1)", "$FILE"],
extensions: [".fallback"],
},
second: {
command: [
process.execPath,
"-e",
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'B')",
"$FILE",
],
extensions: [".fallback"],
},
}),
),
),
),
)
})
@@ -51,7 +51,7 @@ describe("InstructionBuiltIns", () => {
` Workspace root folder: ${projectDirectory}`,
" Is directory a git repo: yes",
` Platform: ${process.platform}`,
` Prefer ${temporary} over generic system temporary directories such as /tmp; it is pre-created and approved for external access.`,
` Use ${temporary} for temporary work outside the workspace; it already exists and is pre-approved for external directory access.`,
"</env>",
"",
`Today's date: ${localDate(timestamp)}`,
@@ -1,341 +0,0 @@
import { Bus } from "@opencode-ai/core/bus"
import { Catalog } from "@opencode-ai/core/catalog"
import { Config } from "@opencode-ai/core/config"
import { Integration } from "@opencode-ai/core/integration"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { LMStudioPlugin, make } from "@opencode-ai/core/plugin/provider/lmstudio"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { Provider } from "@opencode-ai/core/provider"
import { Document, Event, Info } from "@opencode-ai/schema/config"
import { describe, expect } from "bun:test"
import { Duration, Effect, Layer, Schema } from "effect"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(Layer.merge(PluginTestLayer, Config.testLayer()))
const decode = Schema.decodeUnknownSync(Info)
const addPlugin = Effect.fn(function* (origin: string, interval: Duration.Input = "1 hour") {
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
yield* make(origin, interval).effect(host)
})
function eventually<A>(
effect: Effect.Effect<A>,
predicate: (value: A) => boolean,
remaining = 3000,
): Effect.Effect<A, Error> {
return Effect.gen(function* () {
const value = yield* effect
if (predicate(value)) return value
if (remaining === 0) return yield* Effect.fail(new Error("Timed out waiting for value"))
yield* Effect.promise(() => Bun.sleep(1))
return yield* eventually(effect, predicate, remaining - 1)
})
}
describe("LMStudioPlugin", () => {
it.effect("is registered as a built-in provider plugin", () =>
Effect.sync(() => {
expect(LMStudioPlugin.id).toBe("opencode.provider.lmstudio")
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.lmstudio")
}),
)
it.live("discovers local language models with their capabilities and effective context", () =>
Effect.acquireUseRelease(
Effect.sync(() =>
Bun.serve({
port: 0,
fetch: () =>
Response.json({
models: [
{
type: "llm",
key: "google/gemma-4-26b-a4b",
display_name: "Gemma 4 26B A4B",
architecture: "gemma4",
loaded_instances: [{ config: { context_length: 32_768 } }, { config: { context_length: 16_384 } }],
max_context_length: 262_144,
capabilities: { vision: true, trained_for_tool_use: true },
},
{
type: "llm",
key: "deepseek-r1",
display_name: "DeepSeek R1",
architecture: "deepseek",
loaded_instances: [],
max_context_length: 131_072,
capabilities: { vision: false, trained_for_tool_use: false },
},
{
type: "embedding",
key: "nomic-embed",
display_name: "Nomic Embed",
loaded_instances: [],
max_context_length: 2048,
},
],
}),
}),
),
(server) =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* addPlugin(server.url.origin)
const providerID = Provider.ID.make("lmstudio")
const gemma = yield* eventually(
catalog.model.get(providerID, Model.ID.make("google/gemma-4-26b-a4b")),
(model) => model !== undefined,
)
expect(yield* catalog.provider.get(providerID)).toEqual({
id: providerID,
name: "LM Studio",
activation: "enabled",
package: "@opencode-ai/ai/providers/openai-compatible",
settings: { baseURL: `${server.url.origin}/v1`, provider: "lmstudio", apiKey: "" },
})
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(providerID)
expect(gemma).toMatchObject({
family: "gemma4",
name: "Gemma 4 26B A4B",
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
limit: { context: 16_384, output: 0 },
})
expect(yield* catalog.model.get(providerID, Model.ID.make("deepseek-r1"))).toMatchObject({
capabilities: { tools: false, input: ["text"], output: ["text"] },
limit: { context: 131_072, output: 0 },
})
expect(yield* catalog.model.get(providerID, Model.ID.make("nomic-embed"))).toBeUndefined()
}),
(server) => Effect.promise(() => server.stop(true)),
),
)
it.live("refreshes the catalog when LM Studio models change", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const models: Array<Record<string, unknown>> = []
return {
models,
server: Bun.serve({ port: 0, fetch: () => Response.json({ models }) }),
}
}),
({ models, server }) =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = Provider.ID.make("lmstudio")
yield* addPlugin(server.url.origin, "5 millis")
expect(yield* catalog.provider.get(providerID)).toBeUndefined()
models.push({
type: "llm",
key: "qwen/qwen3-coder",
display_name: "Qwen 3 Coder",
architecture: "qwen3",
loaded_instances: [],
max_context_length: 65_536,
capabilities: { vision: false, trained_for_tool_use: true },
})
expect(
yield* eventually(
catalog.model.get(providerID, Model.ID.make("qwen/qwen3-coder")),
(model) => model !== undefined,
),
).toMatchObject({ name: "Qwen 3 Coder" })
models.splice(0)
yield* eventually(catalog.provider.get(providerID), (provider) => provider === undefined)
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
)
it.live(
"discovers from configured endpoints with bearer authentication",
() =>
Effect.acquireUseRelease(
Effect.sync(() => {
const requests: Array<{ authorization: string | null; path: string }> = []
const model = (key: string) => ({
type: "llm",
key,
display_name: key,
loaded_instances: [],
max_context_length: 32_768,
})
return {
requests,
initial: Bun.serve({ port: 0, fetch: () => Response.json({ models: [model("initial-model")] }) }),
configured: Bun.serve({
port: 0,
fetch: (request) => {
requests.push({
authorization: request.headers.get("authorization"),
path: new URL(request.url).pathname,
})
return Response.json({ models: [model("configured-model")] })
},
}),
}
}),
({ requests, initial, configured }) =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const catalog = yield* Catalog.Service
const config = yield* Config.Test
const providerID = Provider.ID.make("lmstudio")
yield* addPlugin(initial.url.origin)
yield* eventually(
catalog.model.get(providerID, Model.ID.make("initial-model")),
(model) => model !== undefined,
)
const baseURL = `${configured.url.origin}/proxy/v1`
yield* config.setEntries([configuration(baseURL, "secret")])
yield* bus.publish(Event.Updated, {})
yield* eventually(
catalog.model.get(providerID, Model.ID.make("configured-model")),
(model) => model !== undefined,
)
expect(requests).toContainEqual({ authorization: "Bearer secret", path: "/proxy/api/v1/models" })
expect(yield* catalog.model.get(providerID, Model.ID.make("initial-model"))).toBeUndefined()
expect((yield* catalog.provider.get(providerID))?.settings).toEqual({
baseURL,
provider: "lmstudio",
apiKey: "secret",
})
requests.splice(0)
yield* config.setEntries([configuration(baseURL, "secret"), configuration(baseURL, null)])
yield* bus.publish(Event.Updated, {})
yield* eventually(catalog.provider.get(providerID), (provider) => provider?.settings?.apiKey === "")
expect(requests).toContainEqual({ authorization: null, path: "/proxy/api/v1/models" })
}),
({ initial, configured }) => Effect.promise(() => Promise.all([initial.stop(true), configured.stop(true)])),
),
10_000,
)
it.live("shares discovery requests across plugin instances", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const requests = { count: 0 }
return {
requests,
server: Bun.serve({
port: 0,
fetch: () => {
requests.count++
return Response.json({
models: [
{
type: "llm",
key: "shared-model",
display_name: "Shared Model",
loaded_instances: [],
max_context_length: 32_768,
},
],
})
},
}),
}
}),
({ requests, server }) =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* addPlugin(server.url.origin)
yield* addPlugin(server.url.origin)
yield* eventually(
catalog.model.get(Provider.ID.make("lmstudio"), Model.ID.make("shared-model")),
(model) => model !== undefined,
)
expect(requests.count).toBe(1)
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
)
it.live("replaces the credential-gated Models.dev catalog when discovery succeeds", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const models = [
{
type: "llm",
key: "discovered-model",
display_name: "Discovered Model",
loaded_instances: [],
max_context_length: 32_768,
},
]
return { models, server: Bun.serve({ port: 0, fetch: () => Response.json({ models }) }) }
}),
({ models, server }) =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
const providerID = Provider.ID.make("lmstudio")
yield* integrations.transform((draft) => {
draft.update(Integration.ID.make("lmstudio"), (integration) => {
integration.name = "LMStudio"
})
draft.method.update({
integrationID: Integration.ID.make("lmstudio"),
method: { type: "env", names: ["LMSTUDIO_API_KEY"] },
})
})
yield* catalog.transform((draft) => {
draft.provider.update(providerID, (provider) => {
provider.name = "LMStudio"
provider.package = "aisdk:@ai-sdk/openai-compatible"
provider.integrationID = Integration.ID.make("lmstudio")
})
draft.model.update(providerID, Model.ID.make("static-model"), () => {})
})
expect((yield* catalog.provider.available()).map((provider) => provider.id)).not.toContain(providerID)
yield* addPlugin(server.url.origin, "5 millis")
yield* eventually(
catalog.model.get(providerID, Model.ID.make("discovered-model")),
(model) => model !== undefined,
)
expect(yield* integrations.get(Integration.ID.make("lmstudio"))).toBeUndefined()
expect((yield* catalog.provider.get(providerID))?.integrationID).toBeUndefined()
expect(yield* catalog.model.get(providerID, Model.ID.make("static-model"))).toBeUndefined()
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(providerID)
yield* integrations.transform((draft) => {
draft.update(Integration.ID.make("lmstudio"), (integration) => {
integration.name = "Configured LM Studio"
})
draft.method.update({ integrationID: Integration.ID.make("lmstudio"), method: { type: "key" } })
})
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(providerID)
models.splice(0)
yield* eventually(
catalog.model.get(providerID, Model.ID.make("static-model")),
(model) => model !== undefined,
)
expect(yield* catalog.model.get(providerID, Model.ID.make("discovered-model"))).toBeUndefined()
expect(yield* integrations.get(Integration.ID.make("lmstudio"))).toBeDefined()
expect((yield* catalog.provider.get(providerID))?.integrationID).toBe(Integration.ID.make("lmstudio"))
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
)
})
function configuration(baseURL: string, apiKey: string | null) {
return new Document({
type: "document",
info: decode({ providers: { lmstudio: { settings: { baseURL, apiKey } } } }),
})
}
@@ -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 } } }),
})
}
-34
View File
@@ -782,40 +782,6 @@ describe("ShellTool", () => {
),
)
if (!isWindows) {
it.live("settles a shell terminated by an external signal", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const shell = yield* Shell.Service
const settled = yield* executeTool(
registry,
call({ command: idleCommand, background: true }, "call-external-signal"),
)
const shellID = settled.metadata?.shellID
expect(typeof shellID).toBe("string")
if (typeof shellID !== "string") return
const id = ShellSchema.ID.make(shellID)
const info = yield* shell.get(id)
expect(typeof info.pid).toBe("number")
if (info.pid === undefined) return
process.kill(-info.pid, "SIGTERM")
const result = yield* shell.wait(id).pipe(Effect.timeoutOption(Duration.seconds(1)))
expect(result._tag).toBe("Some")
if (result._tag === "Some") expect(result.value.status).toBe("exited")
expect((yield* shell.list()).map((item) => item.id)).not.toContain(id)
}),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
)
}
it.live("backgrounds a foreground command when the session is signaled", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
@@ -13,7 +13,13 @@ type Experiment = {
// In-flight features anyone can opt into. Each entry is temporary: an
// experiment either graduates (delete the entry, make the behavior
// unconditional) or dies (delete the entry and the branch it gated).
export const experiments: Experiment[] = []
export const experiments: Experiment[] = [
{
id: "tab_scroll",
title: "Remember tab scroll",
description: "Keep each open tab's reading position and show a shortcut back to the bottom.",
},
]
export function DialogExperiments() {
const config = useConfig()
@@ -452,6 +452,7 @@ export function Prompt(props: PromptProps) {
title: "Queue prompt",
name: "prompt.queue",
category: "Prompt",
palette: undefined,
run: async (_input: string | undefined, event?: KeyEvent) => {
event?.preventDefault()
event?.stopPropagation()
+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()}
+1 -1
View File
@@ -178,7 +178,7 @@ export const Definitions = {
"session.toggle.thinking": keybind("none", "Toggle thinking blocks visibility"),
"prompt.submit": keybind("none", "Submit prompt"),
"prompt.queue": keybind("<leader>return", "Queue prompt"),
"prompt.queue": keybind("alt+return", "Queue prompt"),
"prompt.editor_context.clear": keybind("none", "Clear editor context"),
"prompt.images.view": keybind("<leader>i", "View image attachments"),
"prompt.skills": keybind("none", "Open skill selector"),
+1 -1
View File
@@ -163,7 +163,7 @@ export const Definitions = {
display_thinking: keybind("none", "Toggle thinking blocks visibility"),
prompt_submit: keybind("none", "Submit prompt"),
prompt_queue: keybind("<leader>return", "Queue prompt"),
prompt_queue: keybind("alt+return", "Queue prompt"),
prompt_editor_context_clear: keybind("none", "Clear editor context"),
prompt_images_view: keybind("<leader>i", "View image attachments"),
prompt_skills: keybind("none", "Open skill selector"),
@@ -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(
@@ -87,6 +87,11 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
renderer.off("blur", onBlur)
})
createEffect(() => {
if (config.experimental?.tab_scroll === true) return
scrollAnchors.clear()
})
function state() {
if (config.tabs.scope === "cwd") return store.cwd[paths.cwd] ?? fallback
return store.global
-1
View File
@@ -1050,7 +1050,6 @@ export function createPromptState(input: PromptInput): PromptState {
id: "prompt.queue",
title: "Queue prompt",
group: "Prompt",
palette: true,
run() {
syncDraft()
submitPrompt(promptCopy(draft), "queue")
+1 -1
View File
@@ -595,7 +595,7 @@ export function RunFooterView(props: RunFooterViewProps) {
{
id: "session.queued_prompts",
title: "View queued prompts",
group: "Prompt",
group: "Session",
run: openQueuedMenu,
},
],
+9 -14
View File
@@ -429,6 +429,7 @@ export function Session(props: { verticalTabsWidth: number }) {
return scroll.scrollTop < Math.max(0, scroll.scrollHeight - scroll.viewport.height) - 1
}
function updateAwayFromBottom() {
if (config.experimental?.tab_scroll !== true) return
if (awayTimer) clearTimeout(awayTimer)
awayTimer = setTimeout(() => {
awayTimer = undefined
@@ -439,7 +440,7 @@ export function Session(props: { verticalTabsWidth: number }) {
})
}
function saveScrollAnchor() {
if (!isAwayFromBottom()) {
if (config.experimental?.tab_scroll !== true || !isAwayFromBottom()) {
sessionTabs.setScrollAnchor(sessionID, undefined)
return
}
@@ -456,7 +457,7 @@ export function Session(props: { verticalTabsWidth: number }) {
else sessionTabs.setScrollAnchor(sessionID, undefined)
}
function restoreScrollPosition() {
const anchor = sessionTabs.scrollAnchor(sessionID)
const anchor = config.experimental?.tab_scroll === true ? sessionTabs.scrollAnchor(sessionID) : undefined
const index = anchor ? boundaries().indexOf(anchor.messageID) : -1
if (!anchor || index === -1) {
scroll.scrollTo(scroll.scrollHeight)
@@ -1062,7 +1063,7 @@ export function Session(props: { verticalTabsWidth: number }) {
{
title: "View queued prompts",
id: "session.queued_prompts",
group: "Prompt",
group: "Session",
enabled: queuedPrompts().length > 0,
run: openQueuedPrompts,
},
@@ -1194,21 +1195,15 @@ export function Session(props: { verticalTabsWidth: number }) {
</scrollbox>
</box>
<box height={1} flexShrink={0} flexDirection="row" justifyContent="flex-end">
<Show when={awayFromBottom()}>
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={
latestHovered() ? theme.background.action.primary.focused : theme.background.action.primary.default
}
<Show when={config.experimental?.tab_scroll === true && awayFromBottom()}>
<text
fg={latestHovered() ? theme.text.default : theme.text.subdued}
onMouseOver={() => setLatestHovered(true)}
onMouseOut={() => setLatestHovered(false)}
onMouseUp={toBottom}
>
<text fg={latestHovered() ? theme.text.action.primary.focused : theme.text.action.primary.default}>
Jump to latest
</text>
</box>
Latest
</text>
</Show>
</box>
<box flexShrink={0}>
-1
View File
@@ -107,7 +107,6 @@ test("preserves migrated v1 keybind defaults", () => {
const pairs = [
["app.exit", "app_exit"],
["prompt.paste", "input_paste"],
["prompt.queue", "prompt_queue"],
["session.delete", "session_delete"],
["session.list", "session_list"],
["agent.list", "agent_list"],
@@ -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",
"·",
"·",
])
})
@@ -239,23 +239,6 @@ test("stores session tabs for the current working directory by default", async (
}
})
test("keeps scroll anchors for open session tabs", async () => {
const setup = await renderSessionTabs("first")
try {
await wait(() => setup.tabs.current() === "first")
setup.tabs.setScrollAnchor("first", { messageID: "msg_1", screenY: -3 })
expect(setup.tabs.scrollAnchor("first")).toEqual({ messageID: "msg_1", screenY: -3 })
setup.tabs.close("first")
await wait(() => setup.tabs.tabs().every((tab) => tab.sessionID !== "first"))
expect(setup.tabs.scrollAnchor("first")).toBeUndefined()
} finally {
await setup.destroy()
}
})
test("only the foreground TUI mutates unread state", async () => {
await using temporary = await tmpdir()
let foreground: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
+2 -4
View File
@@ -981,8 +981,7 @@ test("direct footer steers the oldest queued prompt from an empty composer", asy
try {
await app.renderOnce()
app.mockInput.pressKey("x", { ctrl: true })
app.mockInput.pressEnter()
app.mockInput.pressEnter({ meta: true })
await Bun.sleep(0)
expect(steered).toEqual([])
app.mockInput.pressEnter()
@@ -1035,8 +1034,7 @@ test("direct footer rejects local commands submitted with the queue shortcut", a
try {
await app.renderOnce()
await app.mockInput.typeText("/settings ")
app.mockInput.pressKey("x", { ctrl: true })
app.mockInput.pressEnter()
app.mockInput.pressEnter({ meta: true })
await Bun.sleep(0)
expect(submitted).toEqual([])
expect(statuses).toContain("this prompt cannot be queued")
+1 -1
View File
@@ -22,7 +22,7 @@ describe("run runtime boot", () => {
expect(result.keybinds.get("prompt.clear")?.[0]?.key).toBe("ctrl+c")
expect(result.keybinds.get("input.submit")?.[0]?.key).toBe("return")
expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("shift+return,ctrl+return,ctrl+j")
expect(result.keybinds.get("prompt.queue")?.[0]?.key).toBe("<leader>return")
expect(result.keybinds.get("prompt.queue")?.[0]?.key).toBe("alt+return")
})
test("preserves shared config while resolving independent Mini defaults", async () => {
+11 -29
View File
@@ -17,7 +17,7 @@
}
[data-slot="animated-number-digit"] {
display: inline-grid;
display: inline-block;
width: 1ch;
height: 1em;
line-height: 1em;
@@ -41,12 +41,19 @@
mask-repeat: no-repeat;
}
[data-slot="animated-number-static"],
[data-slot="animated-number-strip"] {
grid-area: 1 / 1;
display: inline-flex;
flex-direction: column;
transform: translateY(calc(var(--animated-number-offset, 10) * -1em));
transition-property: transform;
transition-duration: var(--animated-number-duration, 560ms);
transition-timing-function: var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
}
[data-slot="animated-number-strip"][data-animating="false"] {
transition-duration: 0ms;
}
[data-slot="animated-number-static"],
[data-slot="animated-number-cell"] {
display: inline-flex;
align-items: center;
@@ -55,24 +62,6 @@
height: 1em;
line-height: 1em;
}
[data-slot="animated-number-digit"][data-animating="true"] [data-slot="animated-number-static"] {
visibility: hidden;
}
[data-slot="animated-number-strip"] {
display: inline-flex;
flex-direction: column;
margin-top: calc(var(--animated-number-offset, 10) * -1em);
transition-property: margin-top;
transition-duration: var(--animated-number-duration, 560ms);
transition-timing-function: var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
}
[data-slot="animated-number-digit"][data-animating="false"] [data-slot="animated-number-strip"] {
transition-duration: 0ms;
visibility: hidden;
}
}
@media (prefers-reduced-motion: reduce) {
@@ -82,12 +71,5 @@
[data-component="animated-number"] [data-slot="animated-number-strip"] {
transition-duration: 0ms;
visibility: hidden;
}
[data-component="animated-number"]
[data-slot="animated-number-digit"][data-animating]
[data-slot="animated-number-static"] {
visibility: visible;
}
}
@@ -43,10 +43,10 @@ function Digit(props: { value: number; direction: 1 | -1 }) {
)
return (
<span data-slot="animated-number-digit" data-animating={animating() ? "true" : "false"}>
<span data-slot="animated-number-static">{props.value}</span>
<span data-slot="animated-number-digit">
<span
data-slot="animated-number-strip"
data-animating={animating() ? "true" : "false"}
onTransitionEnd={() => {
setState("animating", false)
setState("step", (value) => normalize(value) + 10)
@@ -152,112 +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:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"model": "lmstudio/google/gemma-4-26b-a4b",
}
```
OpenCode refreshes the inventory in the background and reads context, vision, and tool-use capabilities from LM
Studio. Embedding models are excluded because they cannot drive a session. Disable discovery with
`"plugins": ["-opencode.provider.lmstudio"]`.
For a different host or port, configure the OpenAI-compatible base URL. Models are still discovered automatically:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"providers": {
"lmstudio": {
"settings": {
"baseURL": "http://127.0.0.1:5678/v1",
"apiKey": "{env:LMSTUDIO_API_KEY}",
},
},
},
}
```
Omit `apiKey` when LM Studio authentication is disabled.
#### 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"
@@ -267,7 +161,7 @@ For an OpenAI-compatible server, define a provider package, endpoint, and at lea
"providers": {
"local": {
"name": "Local server",
"package": "@opencode-ai/ai/providers/openai-compatible",
"package": "aisdk:@ai-sdk/openai-compatible",
"settings": {
"baseURL": "http://127.0.0.1:1234/v1",
},