Compare commits

..
Author SHA1 Message Date
LukeParkerDev 5c2eaade2c perf(desktop): remember the bundled CLI version between launches
Every launch spawned the bundled 200 MB CLI executable for --version and
waited ~380 ms for it before the background service, WSL support and,
because windows are created after every layer builds, the window itself.
The version only changes when an update replaces the executable, so keep
it in the settings store keyed by the executable's path, size and mtime
and only spawn when that identity changes.

Packaged build, warm service, 5 launches (median ms since spawn):
renderer process 851 -> 512, window visible 1085 -> 778,
shell visible 1289 -> 961.
2026-09-18 22:41:37 +10:00
203 changed files with 3316 additions and 7039 deletions
+1 -1
View File
@@ -12,7 +12,7 @@
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `LanguageModel.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, and `LLM.generateObject`. Use `LLMRequest.update(...)` when deriving canonical request data; do not add a duplicate `LLM.updateRequest(...)` path. Two ways to construct the same thing is one too many.
- Prefer forward compatibility for provider-defined options that OpenCode only passes through. For pass-through string enums, expose known values for autocomplete while accepting future values with `Known | (string & {})`, and accept any string at runtime. Closed literals are appropriate when OpenCode branches on a value, transforms its associated structure, or otherwise cannot correctly handle an unknown variant. New options whose shape or behavior requires implementation remain unsupported until they are handled; do not blindly forward unknown structures.
- Keep provider-defined string enums forward-compatible. Expose known values for autocomplete while accepting future values with `Known | (string & {})`; use `Schema.String` at runtime unless rejecting unknown values is required for correctness.
- Order reasoning-effort values from lowest to highest: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. Provider-specific subsets follow the same relative order in types, schemas, option lists, and tests.
## Tests
+2 -2
View File
@@ -8,7 +8,7 @@ import { OpenResponsesOptions } from "./utils/open-responses-options.js"
export type ReasoningEffort = OpenResponsesOptions.ReasoningEffort
const Options = Schema.Struct({
reasoningEffort: Schema.optional(OpenResponsesOptions.ReasoningEffort),
reasoningEffort: OpenResponsesOptions.Options.fields.reasoningEffort,
enableThinking: Schema.optional(Schema.Boolean),
thinkingBudget: Schema.optional(Schema.Int),
preserveThinking: Schema.optional(Schema.Boolean),
@@ -19,7 +19,7 @@ const Options = Schema.Struct({
}),
),
toolStream: Schema.optional(Schema.Boolean),
parallelToolCalls: Schema.optional(Schema.Boolean),
parallelToolCalls: OpenResponsesOptions.Options.fields.parallelToolCalls,
repetitionPenalty: Schema.optional(Schema.Number),
responseFormat: Schema.optional(
Schema.Struct({
@@ -6,9 +6,9 @@ import { OpenResponsesOptions } from "./utils/open-responses-options.js"
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
const Options = Schema.Struct({
reasoningEffort: Schema.optional(OpenResponsesOptions.ReasoningEffort),
reasoningEffort: OpenResponsesOptions.Options.fields.reasoningEffort,
enableThinking: Schema.optional(Schema.Boolean),
store: Schema.optional(Schema.Boolean),
store: OpenResponsesOptions.Options.fields.store,
previousResponseId: Schema.optional(Schema.String),
conversation: Schema.optional(Schema.String),
})
+167 -79
View File
@@ -1,5 +1,5 @@
import { Buffer } from "node:buffer"
import { Effect, Option, Schema, SchemaGetter } from "effect"
import { Effect, Option, Schema } from "effect"
import { Tool } from "@opencode/schema/tool"
import { Route } from "../route/client.js"
import { Auth } from "../route/auth.js"
@@ -21,12 +21,11 @@ import {
type JsonSchema,
type MediaPart,
type ProviderMetadata,
type ProviderOptions,
type ToolCallPart,
type ToolDefinition,
type ToolResultPart,
} from "../schema/index.js"
import { JsonObject, knownString, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { classifyProviderFailure } from "../provider-error.js"
import { effortUpdate, resolveEffortUpdates } from "../effort-updates.js"
import * as Cache from "./utils/cache.js"
@@ -53,10 +52,57 @@ const SSE_EVENTS = new Set([
])
export const framing = Framing.sseEvents(SSE_EVENTS)
export type ThinkingBlockBinding = typeof AnthropicThinkingBlockBinding.Type
export type ThinkingInput = typeof Thinking.Encoded
/** Caller-facing provider options; unknown keys are accepted and ignored. `Options.Type` is the wire-ready form. */
export type OptionsInput = ProviderOptions & typeof Options.Encoded
export type ThinkingBlockBinding = {
readonly prefix_mismatch_behavior?: "error" | "drop_block" | (string & {})
}
export type ThinkingInput =
| {
readonly type: "adaptive"
readonly display?: "summarized" | "omitted"
readonly block_binding?: ThinkingBlockBinding
}
| {
readonly type: "disabled"
}
| ({
readonly type: "enabled"
readonly display?: "summarized" | "omitted"
readonly block_binding?: ThinkingBlockBinding
} & (
| { readonly budgetTokens: number; readonly budget_tokens?: number }
| { readonly budgetTokens?: number; readonly budget_tokens: number }
))
export interface OptionsInput {
/** Advanced in-band compaction. The caller owns checkpoint persistence and recovery. */
readonly contextManagement?: ContextManagement
readonly [key: string]: unknown
readonly thinking?: ThinkingInput
readonly effort?: string
readonly service_tier?: "auto" | "standard_only"
readonly serviceTier?: "auto" | "standard_only"
// SDK Metadata:2649 {user_id?: string | null}
readonly metadata?: { readonly user_id?: string | null }
// SDK MessageCreateParamsContainer:2596 ContainerParams|string
readonly container?:
| string
| { readonly id?: string | null; readonly skills?: ReadonlyArray<Record<string, unknown>> | null }
readonly inference_geo?: string | null
readonly inferenceGeo?: string | null
readonly cache_control?: { readonly type: "ephemeral"; readonly ttl?: "5m" | "1h" }
readonly cacheControl?: { readonly type: "ephemeral"; readonly ttl?: "5m" | "1h" }
// SDK OutputConfig:2684 {effort, format: JSONOutputFormat}
readonly output_config?: {
readonly effort?: string | null
readonly format?: { readonly type: "json_schema"; readonly schema: Record<string, unknown> } | null
}
readonly outputConfig?: {
readonly effort?: string | null
readonly format?: { readonly type: "json_schema"; readonly schema: Record<string, unknown> } | null
}
}
export type ProviderOptionsInput = OptionsInput
export const ContextManagement = Schema.Struct({
@@ -83,7 +129,6 @@ const AnthropicCacheControl = Schema.Struct({
type: Schema.tag("ephemeral"),
ttl: Schema.optional(Schema.Literals(["5m", "1h"])),
})
const AnthropicServiceTier = knownString<"auto" | "standard_only">()
const AnthropicTextBlock = Schema.Struct({
type: Schema.tag("text"),
@@ -272,21 +317,25 @@ const AnthropicToolChoice = Schema.Union([
])
const AnthropicThinkingBlockBinding = Schema.Struct({
prefix_mismatch_behavior: Schema.optional(knownString<"error" | "drop_block">()),
prefix_mismatch_behavior: Schema.optional(Schema.String),
})
const AnthropicThinkingFields = {
display: Schema.optional(knownString<"summarized" | "omitted">()),
block_binding: Schema.optional(AnthropicThinkingBlockBinding),
}
const AnthropicThinkingEnabled = Schema.Struct({
type: Schema.tag("enabled"),
budget_tokens: Schema.Number,
...AnthropicThinkingFields,
})
const AnthropicThinkingAdaptive = Schema.Struct({ type: Schema.tag("adaptive"), ...AnthropicThinkingFields })
const AnthropicThinkingDisabled = Schema.Struct({ type: Schema.tag("disabled") })
const AnthropicThinking = Schema.Union([AnthropicThinkingEnabled, AnthropicThinkingAdaptive, AnthropicThinkingDisabled])
const AnthropicThinking = Schema.Union([
Schema.Struct({
type: Schema.tag("enabled"),
budget_tokens: Schema.Number,
display: Schema.optional(Schema.Literals(["summarized", "omitted"])),
block_binding: Schema.optional(AnthropicThinkingBlockBinding),
}),
Schema.Struct({
type: Schema.tag("adaptive"),
display: Schema.optional(Schema.Literals(["summarized", "omitted"])),
block_binding: Schema.optional(AnthropicThinkingBlockBinding),
}),
Schema.Struct({
type: Schema.tag("disabled"),
}),
])
type AnthropicThinking = typeof AnthropicThinking.Type
// SDK OutputConfig:2684 {effort?: "low"|"medium"|"high"|"xhigh"|"max"|null, format?: JSONOutputFormat:2399}
@@ -311,53 +360,6 @@ const AnthropicContainer = Schema.Union([
}),
])
// =============================================================================
// Provider Options
// =============================================================================
// Callers spell the budget as `budgetTokens` or the wire `budget_tokens`; the
// keys are disjoint per variant so the input type requires exactly one and the
// transform can narrow on it. Decoding straight to the wire block keeps the
// alias out of the rest of the file.
const ThinkingEnabledInput = Schema.Union([
Schema.Struct({ type: Schema.tag("enabled"), budgetTokens: Schema.Number, ...AnthropicThinkingFields }),
Schema.Struct({ type: Schema.tag("enabled"), budget_tokens: Schema.Number, ...AnthropicThinkingFields }),
]).pipe(
Schema.decodeTo(AnthropicThinkingEnabled, {
decode: SchemaGetter.transform((input) => ({
type: "enabled" as const,
budget_tokens: "budgetTokens" in input ? input.budgetTokens : input.budget_tokens,
display: input.display,
block_binding: input.block_binding,
})),
encode: SchemaGetter.passthrough({ strict: false }),
}),
)
const Thinking = Schema.Union([ThinkingEnabledInput, AnthropicThinkingAdaptive, AnthropicThinkingDisabled])
const OutputConfigInput = Schema.Struct({
effort: optionalNull(Schema.String),
format: optionalNull(AnthropicJsonOutputFormat),
})
// Both key spellings are accepted; `fromRequest` prefers the snake_case one.
const Options = Schema.Struct({
/** Advanced in-band compaction. The caller owns checkpoint persistence and recovery. */
contextManagement: Schema.optional(ContextManagement),
thinking: Schema.optional(Thinking),
effort: Schema.optional(Schema.String),
service_tier: Schema.optional(AnthropicServiceTier),
serviceTier: Schema.optional(AnthropicServiceTier),
metadata: Schema.optional(AnthropicMetadata),
container: Schema.optional(AnthropicContainer),
inference_geo: optionalNull(Schema.String),
inferenceGeo: optionalNull(Schema.String),
cache_control: Schema.optional(AnthropicCacheControl),
cacheControl: Schema.optional(AnthropicCacheControl),
output_config: Schema.optional(OutputConfigInput),
outputConfig: Schema.optional(OutputConfigInput),
})
const decodeOptions = ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))
const AnthropicBodyFields = {
context_management: Schema.optional(
Schema.Struct({
@@ -389,7 +391,7 @@ const AnthropicBodyFields = {
container: Schema.optional(Schema.NullOr(AnthropicContainer)),
inference_geo: Schema.optional(Schema.NullOr(Schema.String)),
metadata: Schema.optional(AnthropicMetadata),
service_tier: Schema.optional(AnthropicServiceTier),
service_tier: Schema.optional(Schema.Literals(["auto", "standard_only"])),
}
export const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields)
export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesBody>
@@ -999,6 +1001,64 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
return messages
})
const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* (request: LLMRequest) {
const input = request.providerOptions as Record<string, unknown> | undefined
const rawServiceTier =
(input as Record<string, unknown> | undefined)?.service_tier ??
(input as Record<string, unknown> | undefined)?.serviceTier
const service_tier =
rawServiceTier === "auto" || rawServiceTier === "standard_only"
? (rawServiceTier as "auto" | "standard_only")
: undefined
const rawMetadata = (input as Record<string, unknown> | undefined)?.metadata
const metadata =
ProviderShared.isRecord(rawMetadata) && (typeof rawMetadata.user_id === "string" || rawMetadata.user_id === null)
? { user_id: rawMetadata.user_id as string | null }
: undefined
const container =
typeof (input as Record<string, unknown> | undefined)?.container === "string" ||
ProviderShared.isRecord((input as Record<string, unknown> | undefined)?.container)
? ((input as Record<string, unknown>).container as
| string
| { id?: string | null; skills?: ReadonlyArray<Record<string, unknown>> | null })
: undefined
const rawInferenceGeo =
(input as Record<string, unknown> | undefined)?.inference_geo ??
(input as Record<string, unknown> | undefined)?.inferenceGeo
const inference_geo = typeof rawInferenceGeo === "string" ? rawInferenceGeo : undefined
const rawCacheControl =
(input as Record<string, unknown> | undefined)?.cache_control ??
(input as Record<string, unknown> | undefined)?.cacheControl
const cache_control =
ProviderShared.isRecord(rawCacheControl) && rawCacheControl.type === "ephemeral"
? (rawCacheControl as { type: "ephemeral"; ttl?: "5m" | "1h" })
: undefined
const rawOutputConfig =
(input as Record<string, unknown> | undefined)?.output_config ??
(input as Record<string, unknown> | undefined)?.outputConfig
const outputConfigEffort =
typeof (input as Record<string, unknown> | undefined)?.effort === "string"
? ((input as Record<string, unknown>).effort as string)
: ProviderShared.isRecord(rawOutputConfig) && typeof rawOutputConfig.effort === "string"
? (rawOutputConfig.effort as string)
: undefined
const outputConfigFormat =
ProviderShared.isRecord(rawOutputConfig) && ProviderShared.isRecord(rawOutputConfig.format)
? (rawOutputConfig.format as { type: "json_schema"; schema: Record<string, unknown> })
: undefined
const thinking = yield* resolveThinking(input?.thinking)
return {
thinking: applyThinkingBindingDefault(request.model, thinking),
effort: outputConfigEffort,
format: outputConfigFormat,
service_tier,
metadata,
container,
inference_geo,
cache_control,
}
})
// Accept gateway namespaces and Vertex suffixes without treating a snapshot date as a minor version.
const claudeVersion = (id: string) => {
const match = /(?:^|[./])claude-(?<family>[a-z]+)-(?<major>\d+)(?:[.-](?<minor>\d{1,2}))?(?:$|[-:@])/.exec(
@@ -1037,12 +1097,35 @@ const applyThinkingBindingDefault = (model: LLMRequest["model"], thinking: Anthr
}
}
const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function* (input: unknown) {
if (!ProviderShared.isRecord(input)) return undefined
if (input.type === "disabled") return { type: "disabled" as const }
if (input.type !== "adaptive" && input.type !== "enabled") return undefined
const block_binding = yield* ProviderShared.validateWith(
Schema.decodeUnknownEffect(Schema.UndefinedOr(AnthropicThinkingBlockBinding)),
)(input.block_binding)
const display =
input.display === "summarized" || input.display === "omitted"
? (input.display as "summarized" | "omitted")
: undefined
if (input.type === "adaptive") return { type: "adaptive" as const, display, block_binding }
const budget =
typeof input.budgetTokens === "number"
? input.budgetTokens
: typeof input.budget_tokens === "number"
? input.budget_tokens
: undefined
if (budget === undefined)
return yield* ProviderShared.invalidRequest("Anthropic thinking provider option requires budgetTokens")
return { type: "enabled" as const, budget_tokens: budget, display, block_binding }
})
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
const options = yield* decodeOptions(request.providerOptions ?? {})
const management = options.contextManagement
const outputConfig = options.output_config ?? options.outputConfig
const format = outputConfig?.format ?? undefined
const updates = resolveEffortUpdates(request, options.effort ?? outputConfig?.effort ?? undefined)
const management = yield* ProviderShared.validateWith(
Schema.decodeUnknownEffect(Schema.UndefinedOr(ContextManagement)),
)(request.providerOptions?.contextManagement)
const options = yield* resolveOptions(request)
const updates = resolveEffortUpdates(request, options.effort)
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
// Allocate the 4-breakpoint budget in invalidation order: tools → system →
@@ -1078,7 +1161,12 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
)
}
const output_config =
updates.effort === undefined && format === undefined ? undefined : { effort: updates.effort, format }
updates.effort === undefined && options.format === undefined
? undefined
: {
...(updates.effort === undefined ? {} : { effort: updates.effort }),
...(options.format === undefined ? {} : { format: options.format }),
}
const body = {
model: request.model.id,
system,
@@ -1091,14 +1179,14 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
top_p: generation?.topP,
top_k: generation?.topK,
stop_sequences: generation?.stop,
thinking: applyThinkingBindingDefault(request.model, options.thinking),
thinking: options.thinking,
output_config,
// top-level passthrough per SDK MessageCreateParamsBase:4638,4643,4649,4654,4670
cache_control: options.cache_control ?? options.cacheControl,
cache_control: options.cache_control,
container: options.container,
inference_geo: options.inference_geo ?? options.inferenceGeo ?? undefined,
inference_geo: options.inference_geo,
metadata: options.metadata,
service_tier: options.service_tier ?? options.serviceTier,
service_tier: options.service_tier,
}
if (!management) return body
return {
+68 -45
View File
@@ -14,13 +14,12 @@ import {
type LLMRequest,
type MediaPart,
type ProviderMetadata,
type ProviderOptions,
type TextPart,
type ToolCallPart,
type ToolDefinition,
} from "../schema/index.js"
import { classifyProviderFailure } from "../provider-error.js"
import { JsonObject, knownString, lenient, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { GeminiToolSchema } from "./utils/gemini-tool-schema.js"
import { Lifecycle } from "./utils/lifecycle.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
@@ -51,8 +50,35 @@ const omitsFunctionCallIds = (modelID: string) => {
return match !== null && Number(match[1]) < 3
}
/** Caller-facing provider options; unknown keys are accepted and ignored. */
export type OptionsInput = ProviderOptions & typeof Options.Encoded
export interface OptionsInput {
readonly [key: string]: unknown
readonly cachedContent?: string
readonly safetySettings?: ReadonlyArray<{
readonly category:
| "HARM_CATEGORY_UNSPECIFIED"
| "HARM_CATEGORY_HATE_SPEECH"
| "HARM_CATEGORY_DANGEROUS_CONTENT"
| "HARM_CATEGORY_HARASSMENT"
| "HARM_CATEGORY_SEXUALLY_EXPLICIT"
| "HARM_CATEGORY_CIVIC_INTEGRITY"
| (string & {})
readonly threshold:
| "HARM_BLOCK_THRESHOLD_UNSPECIFIED"
| "BLOCK_LOW_AND_ABOVE"
| "BLOCK_MEDIUM_AND_ABOVE"
| "BLOCK_ONLY_HIGH"
| "BLOCK_NONE"
| "OFF"
| (string & {})
}>
readonly serviceTier?: "standard" | "flex" | "priority" | (string & {})
readonly thinkingConfig?: {
readonly thinkingBudget?: number
readonly includeThoughts?: boolean
readonly thinkingLevel?: "minimal" | "low" | "medium" | "high" | (string & {})
}
}
export type ProviderOptionsInput = OptionsInput
// =============================================================================
@@ -135,50 +161,17 @@ const GeminiToolConfig = Schema.Struct({
}),
})
const GeminiThinkingLevel = knownString<"minimal" | "low" | "medium" | "high">()
const GeminiThinkingConfig = Schema.Struct({
thinkingBudget: Schema.optional(Schema.Number),
includeThoughts: Schema.optional(Schema.Boolean),
thinkingLevel: Schema.optional(GeminiThinkingLevel),
thinkingLevel: Schema.optional(Schema.String),
})
const GeminiSafetySetting = Schema.Struct({
category: knownString<
| "HARM_CATEGORY_UNSPECIFIED"
| "HARM_CATEGORY_HATE_SPEECH"
| "HARM_CATEGORY_DANGEROUS_CONTENT"
| "HARM_CATEGORY_HARASSMENT"
| "HARM_CATEGORY_SEXUALLY_EXPLICIT"
| "HARM_CATEGORY_CIVIC_INTEGRITY"
>(),
threshold: knownString<
| "HARM_BLOCK_THRESHOLD_UNSPECIFIED"
| "BLOCK_LOW_AND_ABOVE"
| "BLOCK_MEDIUM_AND_ABOVE"
| "BLOCK_ONLY_HIGH"
| "BLOCK_NONE"
| "OFF"
>(),
category: Schema.String,
threshold: Schema.String,
})
// =============================================================================
// Provider Options
// =============================================================================
// Malformed fields are dropped rather than failing the request; a `thinkingConfig`
// object that omits `includeThoughts` asks for thoughts.
const GeminiThinkingConfigInput = Schema.Struct({
thinkingBudget: lenient(Schema.Number),
includeThoughts: lenient(Schema.Boolean),
thinkingLevel: lenient(GeminiThinkingLevel),
})
const Options = Schema.Struct({
cachedContent: lenient(Schema.String),
safetySettings: lenient(Schema.Array(GeminiSafetySetting)),
serviceTier: lenient(knownString<"standard" | "flex" | "priority">()),
thinkingConfig: lenient(GeminiThinkingConfigInput),
})
const decodeOptions = ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))
const GeminiGenerationConfig = Schema.Struct({
maxOutputTokens: Schema.optional(Schema.Number),
temperature: Schema.optional(Schema.Number),
@@ -438,11 +431,44 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
return contents
})
const resolveOptions = (request: LLMRequest) => {
const input = request.providerOptions
const value = input?.thinkingConfig
const thinkingConfig = {
thinkingBudget:
ProviderShared.isRecord(value) && typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined,
includeThoughts:
ProviderShared.isRecord(value) && typeof value.includeThoughts === "boolean"
? value.includeThoughts
: ProviderShared.isRecord(value)
? true
: undefined,
thinkingLevel:
ProviderShared.isRecord(value) && typeof value.thinkingLevel === "string" ? value.thinkingLevel : undefined,
}
return {
cachedContent: typeof input?.cachedContent === "string" ? input.cachedContent : undefined,
safetySettings: mapSafetySettings(input?.safetySettings),
serviceTier: typeof input?.serviceTier === "string" ? input.serviceTier : undefined,
thinkingConfig: Object.values(thinkingConfig).some((item) => item !== undefined) ? thinkingConfig : undefined,
}
}
function mapSafetySettings(value: unknown) {
if (!Array.isArray(value)) return undefined
const settings = value.flatMap((item) =>
ProviderShared.isRecord(item) && typeof item.category === "string" && typeof item.threshold === "string"
? [{ category: item.category, threshold: item.threshold }]
: [],
)
return settings
}
const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) {
const flattened = ProviderShared.flattenToolRequest(request)
const hasTools = flattened.tools.length > 0
const generation = request.generation
const options = yield* decodeOptions(request.providerOptions ?? {})
const options = resolveOptions(request)
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const generationConfig = {
maxOutputTokens: generation?.maxTokens,
@@ -453,10 +479,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
presencePenalty: generation?.presencePenalty,
seed: generation?.seed,
stopSequences: generation?.stop,
thinkingConfig:
options.thinkingConfig === undefined
? undefined
: { ...options.thinkingConfig, includeThoughts: options.thinkingConfig.includeThoughts ?? true },
thinkingConfig: options.thinkingConfig,
}
return {
+1 -11
View File
@@ -1,6 +1,6 @@
import { Buffer } from "node:buffer"
import { Tool } from "@opencode/schema/tool"
import { Effect, Option, Schema, Stream } from "effect"
import { Effect, Schema, Stream } from "effect"
import * as Sse from "effect/unstable/encoding/Sse"
import { Headers, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import {
@@ -29,16 +29,6 @@ const isJson = Schema.is(Schema.Json)
export const JsonObject = Schema.Record(Schema.String, Schema.Unknown)
export const optionalArray = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.Array(schema))
export const optionalNull = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.NullOr(schema))
/** Optional field whose malformed value decodes to `undefined` instead of failing the enclosing struct. */
export const lenient = <const S extends Schema.Top>(schema: S) =>
Schema.optionalKey(
Schema.UndefinedOr(schema).pipe(Schema.catchDecoding(() => Effect.succeed(Option.some(undefined)))),
)
/** Provider-defined string enum: known values for autocomplete, any string accepted at runtime. */
export const knownString = <Known extends string>() =>
Schema.declare<Known | (string & {})>((value): value is Known | (string & {}) => typeof value === "string", {
expected: "string",
})
export const OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH = 64
@@ -1,6 +1,5 @@
import { Schema } from "effect"
import { Option, Schema } from "effect"
import { ReasoningEffort, ReasoningEfforts, type LLMRequest } from "../../schema/index.js"
import { lenient } from "../shared.js"
export { ReasoningEffort, ReasoningEfforts }
@@ -50,22 +49,21 @@ export const StreamOptions = Schema.Struct({
includeObfuscation: Schema.optional(Schema.Boolean),
})
// Malformed options are dropped one at a time so a bad `topLogprobs` cannot discard `store` or `reasoningEffort`.
export const Options = Schema.Struct({
store: lenient(Schema.Boolean),
metadata: lenient(Schema.Record(Schema.String, Schema.String)),
safetyIdentifier: lenient(Schema.String),
streamOptions: lenient(StreamOptions),
topLogprobs: lenient(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 20 }))),
reasoningEffort: lenient(ReasoningEffort),
reasoningSummary: lenient(Schema.Literals(["auto", "concise", "detailed"])),
include: lenient(Schema.Array(ResponseIncludableSchema)),
textVerbosity: lenient(TextVerbositySchema),
serviceTier: lenient(ServiceTierSchema),
truncation: lenient(TruncationSchema),
allowedTools: lenient(AllowedTools),
maxToolCalls: lenient(Schema.Int),
parallelToolCalls: lenient(Schema.Boolean),
store: Schema.optional(Schema.Boolean),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
safetyIdentifier: Schema.optional(Schema.String),
streamOptions: Schema.optional(StreamOptions),
topLogprobs: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 20 }))),
reasoningEffort: Schema.optional(ReasoningEffort),
reasoningSummary: Schema.optional(Schema.Literals(["auto", "concise", "detailed"])),
include: Schema.optional(Schema.Array(ResponseIncludableSchema)),
textVerbosity: Schema.optional(TextVerbositySchema),
serviceTier: Schema.optional(ServiceTierSchema),
truncation: Schema.optional(TruncationSchema),
allowedTools: Schema.optional(AllowedTools),
maxToolCalls: Schema.optional(Schema.Int),
parallelToolCalls: Schema.optional(Schema.Boolean),
})
export type Options = typeof Options.Type
@@ -73,10 +71,11 @@ export type Resolved = Omit<Options, "allowedTools"> & {
readonly allowedTools?: AllowedTools & { readonly mode: NonNullable<AllowedTools["mode"]> }
}
const decodeOptions = Schema.decodeUnknownSync(Options)
const decodeOptions = Schema.decodeUnknownOption(Options)
export const resolve = (request: LLMRequest): Resolved => {
const input = decodeOptions(request.providerOptions ?? {})
const input = Option.getOrUndefined(decodeOptions(request.providerOptions))
if (!input) return {}
return {
...input,
include: input.include?.length ? input.include : undefined,
@@ -4,22 +4,6 @@ import { Anthropic } from "../../src/providers.js"
const model = Anthropic.provider.model("claude-sonnet-4-5")
LLM.request({ model, prompt: "Hello", providerOptions: { thinking: { type: "adaptive" } } })
LLM.request({
model,
prompt: "Hello",
providerOptions: {
serviceTier: "future-tier",
thinking: { type: "adaptive", display: "future-display" },
},
})
LLM.request({
model,
prompt: "Hello",
providerOptions: {
// @ts-expect-error Anthropic cache TTL values are protocol constraints.
cacheControl: { type: "ephemeral", ttl: "future-ttl" },
},
})
LLM.request({
model,
@@ -166,89 +166,7 @@ describe("Anthropic Messages route", () => {
}),
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
expect(error.message).toContain("budgetTokens")
}),
)
it.effect("lowers passthrough provider options and accepts either key spelling", () =>
Effect.gen(function* () {
const snake = yield* compileRequest(
LLMRequest.update(request, {
providerOptions: {
service_tier: "auto",
metadata: { user_id: "user_1" },
container: { id: "container_1" },
inference_geo: "us",
cache_control: { type: "ephemeral", ttl: "1h" },
output_config: { format: { type: "json_schema", schema: { type: "object" } } },
},
}),
)
const camel = yield* compileRequest(
LLMRequest.update(request, {
providerOptions: {
serviceTier: "standard_only",
container: "container_2",
inferenceGeo: "eu",
cacheControl: { type: "ephemeral" },
outputConfig: { effort: "low" },
},
}),
)
expect(snake.body).toMatchObject({
service_tier: "auto",
metadata: { user_id: "user_1" },
container: { id: "container_1" },
inference_geo: "us",
cache_control: { type: "ephemeral", ttl: "1h" },
output_config: { format: { type: "json_schema", schema: { type: "object" } } },
})
expect(camel.body).toMatchObject({
service_tier: "standard_only",
container: "container_2",
inference_geo: "eu",
cache_control: { type: "ephemeral" },
output_config: { effort: "low" },
})
}),
)
it.effect("forwards unknown values for pass-through string enums", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLMRequest.update(request, {
providerOptions: {
service_tier: "future-tier",
thinking: { type: "adaptive", display: "future-display" },
},
}),
)
expect(prepared.body).toMatchObject({
service_tier: "future-tier",
thinking: { type: "adaptive", display: "future-display" },
})
}),
)
it.effect("ignores unknown provider options and rejects malformed known ones", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(LLMRequest.update(request, { providerOptions: { unknownOption: true } }))
const malformed = [
{ service_tier: 1 },
{ metadata: { user_id: 42 } },
{ cache_control: { type: "ephemeral", ttl: "future-ttl" } },
{ output_config: { format: { type: "text" } } },
{ thinking: { type: "automatic" } },
]
const errors = yield* Effect.forEach(malformed, (providerOptions) =>
compileRequest(LLMRequest.update(request, { providerOptions })).pipe(Effect.flip),
)
expect(prepared.body).not.toHaveProperty("unknownOption")
expect(errors.map((error) => error.reason._tag)).toEqual(malformed.map(() => "InvalidRequest"))
expect(error.message).toContain("Anthropic thinking provider option requires budgetTokens")
}),
)
@@ -248,21 +248,6 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("keeps valid Chat options when a sibling option is malformed", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).chat("gpt-4o-mini"),
prompt: "think",
providerOptions: { store: true, reasoningEffort: "max", topLogprobs: 25 },
}),
)
expect(prepared.body.store).toBe(true)
expect(prepared.body.reasoning_effort).toBe("max")
}),
)
it.effect("maps the request prompt cache key when the compatibility flag is set", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -1945,30 +1945,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("drops a malformed provider option without discarding its siblings", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
prompt: "hi",
providerOptions: {
topLogprobs: 25,
metadata: { tenant: 7 },
reasoningEffort: "high",
serviceTier: "priority",
maxToolCalls: 4,
},
}),
)
expect(prepared.body.top_logprobs).toBeUndefined()
expect(prepared.body.metadata).toBeUndefined()
expect(prepared.body.reasoning).toEqual({ effort: "high" })
expect(prepared.body.service_tier).toBe("priority")
expect(prepared.body.max_tool_calls).toBe(4)
}),
)
it.effect("accepts the full ResponseIncludable union", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -1,783 +0,0 @@
import { expect, test } from "@playwright/test"
import type { Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
const directory = "/console-auth-project"
const location = { directory, project: { id: "proj_console", directory, canonical: directory } }
const provider = {
id: "opencode",
integrationID: "opencode",
name: "Anomaly / OpenCode",
activation: "enabled",
package: "@ai-sdk/openai-compatible",
}
const secondProvider = {
...provider,
id: "console-google",
canonical: "google",
name: "Anomaly / Google",
package: "@ai-sdk/google",
}
const directProvider = {
...provider,
id: "openrouter",
integrationID: "openrouter",
canonical: "openrouter",
name: "OpenRouter",
}
const model = {
id: "sonnet",
modelID: "sonnet",
providerID: provider.id,
name: "Console Sonnet",
enabled: true,
status: "active",
capabilities: { tools: true, input: ["text"], output: ["text"] },
variants: [],
cost: [],
time: { released: 1700000000000 },
limit: { context: 200000, output: 32000 },
}
const models = [
model,
...Array.from({ length: 18 }, (_, index) => ({
...model,
id: `model-${index + 2}`,
modelID: `model-${index + 2}`,
name: `Console Model ${index + 2}`,
})),
{ ...model, id: "gemini", modelID: "gemini", providerID: secondProvider.id, name: "Console Gemini" },
]
const directModel = {
...model,
id: "openrouter-model",
modelID: "openrouter-model",
providerID: directProvider.id,
name: "OpenRouter Model",
cost: [{ input: 1, output: 1, cache: { read: 0, write: 0 } }],
}
const integration = {
id: "opencode",
name: "OpenCode",
connections: [],
methods: [
{
id: "device",
type: "oauth",
label: "OpenCode Console account",
form: [
{
key: "server",
type: "string",
format: "uri",
hidden: true,
default: "https://opencode.ai/console",
},
],
},
{ type: "key", label: "API key (service account)" },
],
}
async function fixture(
page: Page,
remote = false,
options: {
draft?: boolean
browserFailed?: boolean
slowStart?: Promise<void>
existingProvider?: boolean
singleProvider?: boolean
stagedCatalog?: boolean
paidModels?: boolean
staleIntegration?: boolean
directProvider?: boolean
} = {},
) {
const state = {
status: "pending",
connected: false,
starts: 0,
cancelled: [] as string[],
models: true,
modelError: false,
statusError: false,
startError: false,
startGate: options.slowStart,
catalogReady: !options.stagedCatalog,
}
const server = remote ? "http://production.example:4096" : undefined
const currentIntegration = () => ({
...integration,
connections:
state.connected && !options.staleIntegration
? [{ type: "credential", id: "cred_console", label: "Anomaly" }]
: [],
})
await mockOpenCodeServer(page, {
server,
directory,
provider: [],
sessions: [],
project: {
id: "proj_console",
canonical: directory,
name: "Console test",
time: { created: 1700000000000, updated: 1700000000000 },
},
pageMessages: () => ({ items: [] }),
})
await page
.context()
.route("https://console.example/**", (route) =>
route.fulfill({ contentType: "text/html", body: "<title>Console fixture</title><p>Authorize access</p>" }),
)
await page.route("**/api/integration**", async (route) => {
const request = route.request()
const path = new URL(request.url()).pathname
if (request.method() === "OPTIONS") return route.fallback()
const headers = { "access-control-allow-origin": "*" }
const json = (data: unknown) => route.fulfill({ headers, json: { location, data } })
if (path === "/api/integration") return json([currentIntegration()])
if (path === "/api/integration/opencode") return json(currentIntegration())
if (path === "/api/integration/opencode/connect/oauth") {
expect(request.postDataJSON()).toEqual({
methodID: "device",
answer: { server: "https://opencode.ai/console" },
})
state.starts++
if (state.startGate) await state.startGate
if (state.startError) return route.fulfill({ status: 503, headers })
return json({
attemptID: `con_${state.starts}`,
mode: "auto",
instructions: "Confirmation code: TFXS-STXG",
url: "https://console.example/device?user_code=TFXS-STXG&client_id=opencode-cli",
time: { created: Date.now(), expires: Date.now() + 60000 },
})
}
if (path.includes("/connect/oauth/con_")) {
if (request.method() === "DELETE") {
state.cancelled.push(path.split("/").pop()!)
return route.fulfill({ status: 204, headers })
}
if (state.statusError) return route.fulfill({ status: 503, headers })
if (state.status === "complete") state.connected = true
return json({
status: state.status,
...(state.status === "failed" ? { message: "Device authorization failed: access_denied" } : {}),
time: { created: 0, expires: Date.now() + 60000 },
})
}
return route.fallback()
})
await page.route("**/api/provider**", (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
return route.fulfill({
headers: { "access-control-allow-origin": "*" },
json: {
location,
data: !state.connected
? options.existingProvider
? [directProvider]
: []
: state.catalogReady
? [provider, ...(options.singleProvider ? [] : [secondProvider])].concat(
options.directProvider || options.existingProvider ? [directProvider] : [],
)
: [{ ...provider, name: "OpenCode Zen" }],
},
})
})
await page.route("**/api/model**", (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
if (state.modelError) return route.fulfill({ status: 503, headers: { "access-control-allow-origin": "*" } })
const available = state.connected && state.models
const source = options.directProvider || options.existingProvider ? [...models, directModel] : models
const catalog = options.paidModels
? source.map((model) => ({ ...model, cost: [{ input: 1, output: 1, cache: { read: 0, write: 0 } }] }))
: source
return route.fulfill({
headers: { "access-control-allow-origin": "*" },
json: {
location,
data: new URL(route.request().url()).pathname.endsWith("/default")
? available
? catalog[0]
: null
: available
? !state.catalogReady
? catalog.filter((model) => model.providerID === provider.id).slice(0, 6)
: options.singleProvider
? catalog.filter((model) => model.providerID === provider.id)
: catalog
: options.existingProvider
? [directModel]
: [],
},
})
})
await page.route("**/api/credential/**", async (route) => {
if (route.request().method() !== "DELETE") return route.fallback()
state.connected = false
state.status = "pending"
await route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
await page.evaluate(() => {
const host = window as Window & { __mockServerStream?: { push: (events: unknown[]) => void } }
if (!host.__mockServerStream) throw new Error("Missing fixture event stream")
host.__mockServerStream.push([
{ id: "evt_credential_removed", type: "credential.updated", data: {} },
{
id: "evt_credential_switched",
type: "credential.switched",
data: { integrationID: "opencode", credentialID: null },
},
])
})
})
await page.addInitScript(
({ directory, server }) => {
if (server) localStorage.setItem("opencode.settings.dat:defaultServerUrl", server)
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
list: server ? [{ type: "http", displayName: "Production server", http: { url: server } }] : [],
projects: { [server ?? "local"]: [{ worktree: directory, expanded: true }] },
}),
)
},
{ directory, server },
)
const params = new URLSearchParams()
if (server) params.set("server", server)
if (options.browserFailed) params.set("browserFailed", "1")
await page.goto(`/e2e/desktop/index.html?${params}`)
const dialog = page.locator('[data-component="dialog-v2"]').getByRole("dialog")
if (options.draft) {
await page.keyboard.press("Control+t")
const composer = page.locator('[data-component="composer-editor"]')
await expect(composer).toBeEditable()
await composer.fill("Keep this draft throughout sign-in")
const tip = page.locator('[data-component="new-session-tip"]')
await expect(tip).toContainText("Connect to 75+ providers")
await tip.getByRole("button", { name: /Connect to 75\+ providers/ }).click()
await dialog.getByRole("button", { name: /^OpenCode / }).click()
await dialog.getByRole("button", { name: "Continue to OpenCode Console" }).click()
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
return { state, dialog }
}
await page.getByRole("button", { name: "Settings", exact: true }).click()
await page.getByRole("tab", { name: "Providers", exact: true }).click()
// Use the picker so this also exercises the existing Settings entry point.
await page.getByRole("button", { name: "Show more providers", exact: true }).click()
await page
.getByRole("dialog")
.getByRole("button", { name: /^OpenCode / })
.click()
await expect(dialog.getByRole("button", { name: "Continue to OpenCode Console" })).toBeEnabled()
return { state, dialog }
}
test("Console account is primary and the code is displayed without a copy-code step", async ({ page }) => {
const { state, dialog } = await fixture(page)
await expect(dialog.getByRole("heading", { name: "Connect OpenCode", exact: true })).toBeVisible()
await expect(dialog.getByText("Service account?", { exact: true })).toBeVisible()
await expect(dialog.getByRole("button", { name: "Use API key", exact: true })).toBeVisible()
const shell = await dialog.boundingBox()
const back = await dialog.getByRole("button", { name: "Navigate back" }).boundingBox()
const heading = await dialog.getByRole("heading", { name: "Connect OpenCode", exact: true }).boundingBox()
const logo = await dialog.locator('[data-component="opencode-logo"]').boundingBox()
const description = await dialog
.getByText("Sign in with your OpenCode Console account to use the available models.")
.boundingBox()
const primary = await dialog.getByRole("button", { name: "Continue to OpenCode Console" }).boundingBox()
const service = await dialog.locator('[data-component="console-service-account"]').boundingBox()
if (!shell || !back || !heading || !logo || !description || !primary || !service)
throw new Error("Missing dialog layout")
expect(shell.height).toBe(512)
expect(back.x - shell.x).toBe(20)
expect(back.y - shell.y).toBe(16)
expect(heading.y - (back.y + back.height)).toBe(12)
expect(logo.y + logo.height / 2).toBe(heading.y + heading.height / 2)
expect(description.y - (heading.y + heading.height)).toBe(24)
expect(primary.y - (description.y + description.height)).toBe(20)
expect(service.y - (primary.y + primary.height)).toBe(20)
await page.screenshot({ path: test.info().outputPath("connect-console-light.png") })
const popup = page.waitForEvent("popup")
await dialog.getByRole("button", { name: "Continue to OpenCode Console" }).click()
const consolePage = await popup
await expect(consolePage).toHaveURL(/user_code=TFXS-STXG/)
await expect(consolePage).toHaveURL(/client_id=opencode-desktop/)
await expect(consolePage).toHaveURL(/return_window=console-auth-fixture/)
await expect(
dialog.getByText("Continue in your browser. Confirm the code shown there matches the one below."),
).toBeVisible()
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
await expect(dialog.getByRole("textbox")).toHaveCount(0)
await expect(dialog.getByRole("button", { name: "Copy sign-in link" })).toBeVisible()
const authHeading = await dialog.getByRole("heading", { name: "Connecting to OpenCode" }).boundingBox()
const authDescription = await dialog
.getByText("Continue in your browser. Confirm the code shown there matches the one below.")
.boundingBox()
const label = await dialog.getByText("Device code", { exact: true }).boundingBox()
const code = await dialog.getByRole("group", { name: "Device code: TFXS-STXG" }).boundingBox()
const waiting = await dialog.getByRole("status").boundingBox()
const fallback = await dialog.locator('[data-component="console-browser-fallback"]').boundingBox()
const authShell = await dialog.boundingBox()
if (!authHeading || !authDescription || !label || !code || !waiting || !fallback || !authShell)
throw new Error("Missing authorization layout")
expect(authDescription.y - (authHeading.y + authHeading.height)).toBe(24)
expect(label.y - (authDescription.y + authDescription.height)).toBe(20)
expect(code.y - (label.y + label.height)).toBe(8)
expect(code.height).toBe(48)
expect(waiting.y - (code.y + code.height)).toBe(8)
expect(fallback.y - (waiting.y + waiting.height)).toBe(20)
expect(authShell.height).toBeLessThan(512)
expect(authShell.y + authShell.height - (fallback.y + fallback.height)).toBe(16)
await page.screenshot({ path: test.info().outputPath("console-auth-light.png") })
await page.emulateMedia({ colorScheme: "dark" })
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark")
await page.screenshot({ path: test.info().outputPath("console-auth-dark.png") })
state.status = "complete"
await expect(dialog.getByRole("heading", { name: "Connected to OpenCode" })).toBeVisible()
const list = dialog.getByRole("radiogroup", { name: "Models available from OpenCode" })
const available = dialog.locator('[data-component="available-models-heading"]')
await expect(available).toContainText("Available models")
await expect(available).toContainText("Anomaly")
await expect(dialog.getByRole("button", { name: "OpenCode", exact: true })).toBeVisible()
await expect(dialog.getByRole("button", { name: "Google", exact: true })).toBeVisible()
await expect(list.getByRole("radio")).toHaveCount(models.length)
await page.mouse.move(0, 0)
const first = list.getByRole("radio", { name: "Console Sonnet" })
await expect(first).toBeChecked()
await expect(first).toHaveCSS("background-color", "rgba(0, 0, 0, 0)")
await expect(dialog.locator('[data-component="settings-list"]')).toHaveCount(2)
await expect(first.locator('[data-slot="settings-row-title"]')).toHaveCSS("font-weight", "440")
await expect(first).toHaveCSS("border-radius", "4px")
const providerGroups = dialog.locator('[data-component="provider-model-group"]')
await expect(providerGroups).toHaveCount(2)
const openCodeGroup = dialog.locator('[data-component="provider-model-group"][data-provider="opencode"]')
await expect(openCodeGroup).toHaveCSS("border-radius", "8px")
await expect
.poll(() =>
openCodeGroup.evaluate((element) => {
const list = element.querySelector<HTMLElement>('[data-component="settings-list"]')
if (!list) return false
const background = getComputedStyle(element).backgroundColor
return background !== "rgba(0, 0, 0, 0)" && getComputedStyle(list).backgroundColor === "rgba(0, 0, 0, 0)"
}),
)
.toBe(true)
await expect(providerGroups.getByText(/models? enabled$/)).toHaveCount(0)
const google = dialog.getByRole("button", { name: "Google", exact: true })
await expect(
dialog.locator(
'[data-component="provider-model-group"][data-provider="console-google"] [data-component="provider-icon"] use',
),
).toHaveAttribute("href", /#google$/)
const gemini = list.getByRole("radio", { name: "Console Gemini" })
const geminiShell = list
.locator('[data-component="connected-model-row-shell"]')
.filter({ hasText: /^Console Gemini$/ })
await expect(gemini).toHaveCSS("height", "40px")
await expect(geminiShell).toHaveCSS("margin-left", "4px")
await google.click()
await expect(google).toHaveAttribute("aria-expanded", "false")
await expect(gemini).toBeHidden()
await google.click()
await expect(google).toHaveAttribute("aria-expanded", "true")
await expect(dialog.locator('[data-slot="dialog-header"]')).toHaveCSS("padding-top", "20px")
const hovered = list.getByRole("radio", { name: "Console Model 3" })
const hoveredShell = list
.locator('[data-component="connected-model-row-shell"]')
.filter({ hasText: /^Console Model 3$/ })
await expect(hoveredShell).toHaveCSS("margin-left", "4px")
await expect(hoveredShell).toHaveCSS("padding-top", "4px")
await expect(hovered).toHaveCSS("height", "40px")
await hovered.hover()
await expect
.poll(() => hovered.evaluate((element) => getComputedStyle(element).backgroundColor))
.not.toBe("rgba(0, 0, 0, 0)")
await expect(hovered).toHaveCSS("border-bottom-width", "0px")
await expect(list.getByRole("radio", { name: "Console Model 2" })).toHaveCSS("border-bottom-width", "0px")
await page.screenshot({ path: test.info().outputPath("first-provider-models-dark.png") })
await list.getByRole("radio", { name: "Console Model 2" }).click()
await expect(list.getByRole("radio", { name: "Console Model 2" })).toBeChecked()
const scroll = dialog.locator('[data-component="first-provider-model-scroll"]')
const footer = dialog.locator('[data-component="first-provider-model-footer"]')
const footerBefore = await footer.boundingBox()
expect(await scroll.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true)
await scroll.evaluate((element) => element.scrollTo({ top: element.scrollHeight }))
await expect(list.getByRole("radio", { name: models.at(-1)!.name })).toBeInViewport()
expect(await footer.boundingBox()).toEqual(footerBefore)
await dialog.getByRole("button", { name: "Continue", exact: true }).click()
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
await expect(page.locator('[data-action="composer-model"]')).toContainText("Console Model 2")
expect(state.starts).toBe(1)
expect(state.cancelled).toEqual([])
})
test("provider form returns to the picker and its backdrop closes", async ({ page }) => {
const { dialog } = await fixture(page)
await dialog.getByRole("button", { name: "Navigate back", exact: true }).click()
await expect(dialog.getByRole("heading", { name: "Connect provider", exact: true })).toBeVisible()
await expect(dialog.getByRole("button", { name: /^OpenCode / })).toBeVisible()
await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 8, y: 8 } })
await expect(dialog).toBeHidden()
})
test("Manage models groups Console providers like Settings", async ({ page }) => {
const { state, dialog } = await fixture(page, false, { draft: true, paidModels: true, directProvider: true })
state.status = "complete"
await expect(dialog.getByRole("heading", { name: "Connected to OpenCode" })).toBeVisible()
await dialog.getByRole("button", { name: "Continue", exact: true }).click()
await page.locator('[data-action="composer-model"]').click()
const search = page.getByPlaceholder("Search models", { exact: true })
await expect(search).toBeFocused()
await search.press("ArrowUp")
await search.press("Enter")
await expect(dialog.getByRole("heading", { name: "Manage models", exact: true })).toBeVisible()
const managed = dialog.locator('[data-component="manage-models-console"]')
await expect(managed.getByRole("button", { name: "OpenCode Anomaly", exact: true })).toHaveAttribute(
"aria-expanded",
"true",
)
await expect(managed.locator('[data-component="provider-model-group"]')).toHaveCount(2)
await expect(managed.getByRole("button", { name: /^Google \d+ models? enabled$/ })).toBeVisible()
await expect(managed.getByText("Anomaly / Google", { exact: true })).toHaveCount(0)
await expect(dialog.getByRole("button", { name: "OpenRouter", exact: true })).toBeVisible()
await dialog.getByRole("button", { name: "Connect provider", exact: true }).click()
await expect(dialog.getByRole("heading", { name: "Connect provider", exact: true })).toBeVisible()
await expect(dialog.getByRole("button", { name: /^OpenCode Reliable optimized models/ })).toHaveCount(0)
})
test("Manage models opens the Models settings page", async ({ page }) => {
const { state, dialog } = await fixture(page)
await dialog.getByRole("button", { name: "Continue to OpenCode Console" }).click()
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
state.status = "complete"
await expect(dialog.getByRole("heading", { name: "Connected to OpenCode" })).toBeVisible()
await dialog.getByRole("button", { name: "Manage models", exact: true }).click()
await expect(dialog).toBeHidden()
await expect(page.getByRole("tab", { name: "Models", exact: true })).toHaveAttribute("aria-selected", "true")
const console = page.locator('[data-component="settings-models-console"]')
const consoleToggle = console.getByRole("button", { name: "OpenCode Anomaly", exact: true })
await expect(consoleToggle).toHaveAttribute("aria-expanded", "true")
const groups = console.locator('[data-component="provider-model-group"]')
await expect(groups).toHaveCount(2)
await expect(console.locator(".settings-models-console-groups")).toHaveCSS("border-inline-start-style", "solid")
const openCode = console.locator('[data-component="provider-model-group"][data-provider="opencode"]')
await expect(openCode).toBeVisible()
const openCodeToggle = openCode.getByRole("button", { name: "OpenCode 0 models enabled", exact: true })
await expect(openCodeToggle).toBeVisible()
const divider = openCode.locator('[data-component="settings-list"]')
await openCodeToggle.hover()
await expect(openCode).toHaveCSS("outline-style", "solid")
await expect(divider).toHaveCSS("border-top-color", "rgba(0, 0, 0, 0)")
await expect(openCodeToggle.locator(".provider-model-group-chevron")).toHaveCSS("margin-left", "-2px")
await openCode.getByRole("switch", { name: "Console Model 2" }).press("Space")
await expect(openCode.getByRole("button", { name: "OpenCode 1 model enabled", exact: true })).toBeVisible()
await consoleToggle.click()
await expect(groups).toHaveCount(0)
await consoleToggle.click()
await expect(groups).toHaveCount(2)
await page.screenshot({ path: test.info().outputPath("settings-console-models.png") })
await page.evaluate(() => {
document.documentElement.dir = "rtl"
})
await expect(console.locator(".settings-models-console-groups")).toHaveCSS("border-right-style", "solid")
await page.screenshot({ path: test.info().outputPath("settings-console-models-rtl.png") })
})
test("a single managed provider uses a collapsible container", async ({ page }) => {
const { state, dialog } = await fixture(page, false, { singleProvider: true })
await dialog.getByRole("button", { name: "Continue to OpenCode Console" }).click()
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
state.status = "complete"
await expect(dialog.getByRole("heading", { name: "Connected to OpenCode" })).toBeVisible()
await expect(dialog.getByRole("button", { name: "Anomaly / OpenCode", exact: true })).toHaveCount(0)
const provider = dialog.getByRole("button", { name: "OpenCode", exact: true })
await expect(provider).toHaveAttribute("aria-expanded", "true")
await provider.click()
await expect(dialog.getByRole("radio", { name: "Console Sonnet" })).toBeHidden()
await provider.click()
await expect(dialog.getByRole("radio", { name: "Console Sonnet" })).toBeVisible()
await expect(dialog.locator('[data-component="available-models-heading"]')).toContainText("Available models")
await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 8, y: 8 } })
await expect(dialog).toBeHidden()
})
test("model choice is skipped after a provider has already been connected", async ({ page }) => {
const { state, dialog } = await fixture(page, false, { existingProvider: true })
await dialog.getByRole("button", { name: "Continue to OpenCode Console" }).click()
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
state.status = "complete"
await expect(dialog).toBeHidden()
await expect(page.getByRole("tab", { name: "Providers", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(page.getByText("OpenCode Console connected", { exact: true })).toBeVisible()
const connected = page.locator('[data-component="connected-providers-section"]')
await connected.getByRole("button", { name: "2 providers available", exact: true }).click()
await connected.getByRole("button", { name: "Google", exact: true }).click()
await expect(page.getByRole("tab", { name: "Models", exact: true })).toHaveAttribute("aria-selected", "true")
const card = page.locator('[data-component="provider-model-group"][data-provider="console-google"]')
const search = page.getByRole("searchbox", { name: "Search models", exact: true })
await expect(card).toBeVisible()
await expect
.poll(async () => {
const cardBox = await card.boundingBox()
const searchBox = await search.boundingBox()
if (!cardBox || !searchBox) return false
return cardBox.y >= searchBox.y + searchBox.height + 20
})
.toBe(true)
})
test("Console reconnect clears disconnected provider suppression", async ({ page }) => {
const { state, dialog } = await fixture(page)
await dialog.getByRole("button", { name: "Continue to OpenCode Console" }).click()
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
state.status = "complete"
await expect(dialog.getByRole("heading", { name: "Connected to OpenCode" })).toBeVisible()
await dialog.getByRole("button", { name: "Close", exact: true }).click()
const connected = page.locator('[data-component="connected-providers-section"]')
await expect(connected.getByText("OpenCode", { exact: true })).toBeVisible()
await connected.getByRole("button", { name: "Disconnect", exact: true }).click()
await expect(connected).toContainText("No connected providers")
const popular = page.getByRole("heading", { name: "Popular providers", exact: true }).locator("..")
await expect(popular.getByRole("button", { name: "Connect", exact: true })).toBeVisible()
await page.getByRole("button", { name: "Show more providers", exact: true }).click()
await dialog.getByRole("button", { name: /^OpenCode / }).click()
await dialog.getByRole("button", { name: "Continue to OpenCode Console" }).click()
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
state.status = "complete"
await expect(dialog.getByRole("heading", { name: "Connected to OpenCode" })).toBeVisible()
await page.keyboard.press("Escape")
await expect(dialog).toBeHidden()
await expect(connected.getByText("OpenCode", { exact: true })).toBeVisible()
await expect(connected.getByText("Anomaly", { exact: true })).toBeVisible()
})
test("service-account API key form matches the Console dialog layout", async ({ page }) => {
const { dialog } = await fixture(page)
const initialShell = await dialog.boundingBox()
await dialog.getByRole("button", { name: "Use API key", exact: true }).click()
await expect(dialog.getByRole("heading", { name: "Connect OpenCode", exact: true })).toBeVisible()
const description = dialog.getByText("Connect using a service-account API key from OpenCode Console.")
const label = dialog.locator('[data-component="provider-api-key-label"]')
const input = dialog.getByLabel("OpenCode Console API key", { exact: true })
const button = dialog.getByRole("button", { name: "Continue", exact: true })
await expect(input).toBeFocused()
const shell = await dialog.boundingBox()
const heading = await dialog.getByRole("heading", { name: "Connect OpenCode", exact: true }).boundingBox()
const descriptionBox = await description.boundingBox()
const labelBox = await label.boundingBox()
const fieldBox = await input.locator("..").locator("..").boundingBox()
const buttonBox = await button.boundingBox()
if (!shell || !heading || !descriptionBox || !labelBox || !fieldBox || !buttonBox)
throw new Error("Missing API key dialog layout")
if (!initialShell) throw new Error("Missing initial Console dialog layout")
expect(shell.height).toBe(512)
expect(shell.height).toBe(initialShell.height)
expect(descriptionBox.y - (heading.y + heading.height)).toBe(24)
expect(labelBox.y - (descriptionBox.y + descriptionBox.height)).toBe(20)
expect(fieldBox.y - (labelBox.y + labelBox.height)).toBe(8)
expect(buttonBox.y - (fieldBox.y + fieldBox.height)).toBe(20)
await page.screenshot({ path: test.info().outputPath("console-api-key-light.png") })
})
test("setup preserves the draft and Continue restores composer focus", async ({ page }) => {
const { state, dialog } = await fixture(page, false, { draft: true })
state.status = "complete"
await expect(dialog.getByRole("heading", { name: "Connected to OpenCode" })).toBeVisible()
await dialog.getByRole("button", { name: "Continue", exact: true }).click()
const composer = page.locator('[data-component="composer-editor"]')
await expect(composer).toHaveText("Keep this draft throughout sign-in")
await expect(composer).toBeFocused()
await expect(page.locator('[data-action="composer-model"]')).toContainText("Console Sonnet")
})
test("catalog refresh failure retries without asking for authorization again", async ({ page }) => {
const { state, dialog } = await fixture(page)
await dialog.getByRole("button", { name: "Continue to OpenCode Console" }).click()
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
state.modelError = true
state.status = "complete"
await expect(dialog.getByRole("alert")).toContainText("Your account is connected, but we couldn't load your models")
state.modelError = false
await dialog.getByRole("button", { name: "Try again", exact: true }).click()
await expect(dialog.getByRole("heading", { name: "Connected to OpenCode" })).toBeVisible()
expect(state.starts).toBe(1)
})
test("status request failure resumes the existing attempt", async ({ page }) => {
const { state, dialog } = await fixture(page)
state.statusError = true
await dialog.getByRole("button", { name: "Continue to OpenCode Console" }).click()
const alert = dialog.getByRole("alert")
await expect(alert).toBeVisible()
await expect(alert).toHaveClass(/text-v2-text-text-base/)
await expect(alert.locator("svg")).toHaveClass(/text-v2-state-fg-danger/)
state.statusError = false
state.status = "complete"
await dialog.getByRole("button", { name: "Try again", exact: true }).click()
await expect(dialog.getByRole("heading", { name: "Connected to OpenCode" })).toBeVisible()
expect(state.starts).toBe(1)
expect(state.cancelled).toEqual([])
})
test("retrying authorization startup keeps the error view busy", async ({ page }) => {
const { state, dialog } = await fixture(page)
state.startError = true
await dialog.getByRole("button", { name: "Continue to OpenCode Console" }).click()
const alert = dialog.getByRole("alert")
await expect(alert).toContainText("Couldn't start sign-in")
await expect(dialog.getByRole("heading", { name: "Connect to OpenCode", exact: true })).toBeVisible()
await expect(dialog.locator('[data-component="provider-connect-content"]')).toHaveCSS("padding-left", "12px")
const retry = Promise.withResolvers<void>()
state.startError = false
state.startGate = retry.promise
await dialog.getByRole("button", { name: "Try again", exact: true }).click()
const opening = dialog.getByRole("button", { name: "Opening browser…", exact: true })
await expect(opening).toBeDisabled()
await expect(alert).toContainText("Couldn't start sign-in")
const popup = page.waitForEvent("popup")
retry.resolve()
await popup
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
})
test("backdrop clicks do not cancel Console authorization", async ({ page }) => {
const { state, dialog } = await fixture(page)
await dialog.getByRole("button", { name: "Continue to OpenCode Console" }).click()
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 8, y: 8 } })
await expect(dialog).toBeVisible()
expect(state.cancelled).toEqual([])
await dialog.getByRole("button", { name: "Close", exact: true }).click()
await expect.poll(() => state.cancelled).toEqual(["con_1"])
})
test("first connection waits for the managed Console catalog", async ({ page }) => {
const { state, dialog } = await fixture(page, false, { stagedCatalog: true, directProvider: true })
await dialog.getByRole("button", { name: "Continue to OpenCode Console" }).click()
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
state.status = "complete"
await expect(dialog.getByRole("status")).toContainText("Waiting for confirmation")
await expect(dialog.getByText("OpenCode connected. Loading your models", { exact: true })).toHaveCount(0)
await expect(dialog.locator('[data-component="first-provider-models"]')).toHaveCount(0)
const connected = page.locator('[data-component="connected-providers-section"]')
await expect(connected.getByText("OpenCode Zen", { exact: true })).toHaveCount(0)
state.catalogReady = true
await page.evaluate((directory) => {
const host = window as Window & { __mockServerStream?: { push: (events: unknown[]) => void } }
if (!host.__mockServerStream) throw new Error("Missing fixture event stream")
host.__mockServerStream.push([
{ id: "evt_console_provider", type: "provider.updated", location: { directory }, data: {} },
])
}, directory)
await expect(dialog.getByRole("heading", { name: "Connected to OpenCode" })).toBeVisible()
await expect(dialog.locator('[data-component="provider-model-group"]')).toHaveCount(2)
await dialog.getByRole("button", { name: "Close", exact: true }).click()
await expect(connected.getByText("OpenCode", { exact: true })).toBeVisible()
await expect(connected.getByText("Anomaly", { exact: true })).toBeVisible()
const consoleRow = connected.locator(".settings-provider-console-header")
const directRow = connected.locator(".settings-provider-row").filter({ hasText: "OpenRouter" })
await expect(directRow).toBeVisible()
await expect.poll(async () => (await directRow.boundingBox())?.height).toBe((await consoleRow.boundingBox())?.height)
})
test("closing during authorization startup cancels the late server attempt", async ({ page }) => {
const start = Promise.withResolvers<void>()
const { state, dialog } = await fixture(page, false, { slowStart: start.promise })
await dialog.getByRole("button", { name: "Continue to OpenCode Console" }).click()
await expect.poll(() => state.starts).toBe(1)
await dialog.getByRole("button", { name: "Close", exact: true }).click()
await expect(dialog).toBeHidden()
start.resolve()
await expect.poll(() => state.cancelled).toEqual(["con_1"])
})
test("authorization startup stays on the Continue button until the device code is ready", async ({ page }) => {
const start = Promise.withResolvers<void>()
const { dialog } = await fixture(page, false, { slowStart: start.promise })
const button = dialog.getByRole("button", { name: "Continue to OpenCode Console" })
await button.click()
await expect(dialog.getByRole("button", { name: "Opening browser…" })).toHaveAttribute("aria-busy", "true")
await expect(dialog.getByRole("heading", { name: "Connect OpenCode", exact: true })).toBeVisible()
await expect(dialog.getByRole("group", { name: /Device code/ })).toHaveCount(0)
start.resolve()
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
})
test("browser failure offers a copyable sign-in link in a narrow RTL window", async ({ page, context }) => {
await context.grantPermissions(["clipboard-read", "clipboard-write"])
const { dialog } = await fixture(page, false, { browserFailed: true })
await dialog.getByRole("button", { name: "Continue to OpenCode Console" }).click()
await expect(dialog.getByText(/We couldn't open your browser/)).toBeVisible()
await page.setViewportSize({ width: 380, height: 650 })
await page.evaluate(() => {
document.documentElement.dir = "rtl"
})
const code = dialog.getByRole("group", { name: "Device code: TFXS-STXG" })
await expect(code).toHaveCSS("direction", "ltr")
await expect(code).toBeInViewport()
await dialog.getByRole("button", { name: "Copy sign-in link" }).click()
await expect(dialog.getByRole("button", { name: "Sign-in link copied" })).toBeVisible()
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(
"https://console.example/device?user_code=TFXS-STXG&client_id=opencode-desktop&return_window=console-auth-fixture",
)
await expect(dialog.getByRole("button", { name: "Open Console again" })).toBeInViewport()
await page.screenshot({ path: test.info().outputPath("console-auth-narrow-rtl.png") })
})
test("cancel releases the server attempt and retrying expiration creates a new attempt", async ({ page }) => {
const { state, dialog } = await fixture(page)
await dialog.getByRole("button", { name: "Continue to OpenCode Console" }).click()
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
state.status = "expired"
await expect(dialog.getByRole("alert")).toContainText("has expired")
state.status = "pending"
await dialog.getByRole("button", { name: "Try again", exact: true }).click()
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
await expect.poll(() => state.starts).toBe(2)
await dialog.getByRole("button", { name: "Close", exact: true }).click()
await expect.poll(() => state.cancelled).toEqual(["con_1", "con_2"])
})
test("an authorized workspace without models stays connected and can refresh", async ({ page }) => {
const { state, dialog } = await fixture(page)
state.models = false
await dialog.getByRole("button", { name: "Continue to OpenCode Console" }).click()
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
state.status = "complete"
await expect(dialog.getByText(/this Console workspace has no available models/)).toBeVisible()
state.models = true
await dialog.getByRole("button", { name: "Refresh models" }).click()
await expect(dialog.getByRole("heading", { name: "Connected to OpenCode" })).toBeVisible()
expect(state.starts).toBe(1)
})
test("remote disclosure precedes authorization and all auth requests target that server", async ({ page }) => {
const { state, dialog } = await fixture(page, true)
await expect(dialog.getByRole("note")).toContainText("Connecting on “Production server”")
await expect(dialog.getByRole("note")).toContainText("credentials will be stored on this server")
expect(state.starts).toBe(0)
const request = page.waitForRequest(
(request) => request.method() === "POST" && request.url().includes("/connect/oauth"),
)
await dialog.getByRole("button", { name: "Continue to OpenCode Console" }).click()
expect(new URL((await request).url()).origin).toBe("http://production.example:4096")
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
const cancelled = page.waitForRequest(
(request) => request.method() === "DELETE" && request.url().includes("/connect/oauth"),
)
await dialog.getByRole("button", { name: "Close", exact: true }).click()
expect(new URL((await cancelled).url()).origin).toBe("http://production.example:4096")
})
-11
View File
@@ -1,11 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body class="overflow-hidden bg-v2-background-bg-deep">
<div id="root" class="flex h-dvh flex-col p-px"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
-46
View File
@@ -1,46 +0,0 @@
import { render } from "solid-js/web"
import { MemoryRouter } from "@solidjs/router"
import { AppBaseProviders, AppInterface } from "@/app"
import { PlatformProvider } from "@/runtime/platform/platform"
import { createBrowserDraftStore } from "@/runtime/persistence/drafts"
import { ServerConnection } from "@/runtime/server/registry"
// Exercise the real Desktop renderer with local browser/clipboard adapters and
// an HTTP fixture. No Electron service or account credentials are touched.
const params = new URLSearchParams(window.location.search)
const remote = params.get("server")
const server: ServerConnection.Any = remote
? { type: "http", displayName: "Production server", http: { url: remote } }
: { type: "sidecar", variant: "base", http: { url: "http://127.0.0.1:4096" } }
const root = document.getElementById("root")
if (!root) throw new Error("Missing fixture root")
render(
() => (
<PlatformProvider
value={{
platform: "desktop",
windowID: "console-auth-fixture",
os: "linux",
draftStore: createBrowserDraftStore(),
openExternal: () => {},
restart: async () => {},
notify: async () => {},
openDirectoryPickerDialog: async () => null,
writeClipboardText: (text) => navigator.clipboard.writeText(text),
openBrowser: async (url) => {
if (params.has("browserFailed")) return false
const browser = window.open("about:blank", "_blank")
if (!browser) return false
browser.opener = null
browser.location.replace(url)
return true
},
}}
>
<AppBaseProviders locale="en">
<AppInterface servers={[server]} defaultServer={ServerConnection.key(server)} router={MemoryRouter} />
</AppBaseProviders>
</PlatformProvider>
),
root,
)
@@ -1,19 +0,0 @@
import { defineConfig, devices } from "@playwright/test"
const port = Number(process.env.PLAYWRIGHT_PORT ?? 4454)
export default defineConfig({
testDir: ".",
outputDir: "../test-results/desktop",
timeout: 60000,
expect: { timeout: 10000 },
workers: 1,
retries: 0,
use: { baseURL: `http://127.0.0.1:${port}`, screenshot: "only-on-failure", serviceWorkers: "block" },
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
webServer: {
command: `bun run dev -- --host 127.0.0.1 --port ${port} --strictPort`,
url: `http://127.0.0.1:${port}`,
reuseExistingServer: true,
timeout: 120000,
},
})
@@ -159,9 +159,9 @@ test.describe("session timeline projection", () => {
await expect(shortNotice.getByText(`Switched to ${shortName}`, { exact: true })).toBeVisible()
await expect(shortNotice.locator('[data-slot="session-timeline-notice-variant"]')).toHaveText("xhigh")
await expect(page.getByText("fast-nano", { exact: true })).toHaveCount(0)
await expect(shortNotice.locator('[data-component="logo-mark"]')).toBeVisible()
await expect(shortNotice.locator('[data-component="provider-icon"]')).toBeVisible()
await expect(longNotice).toBeVisible()
await expect(longNotice.locator('[data-component="logo-mark"]')).toBeVisible()
await expect(longNotice.locator('[data-component="provider-icon"]')).toBeVisible()
await expect(longNotice.locator('[data-slot="session-timeline-notice-variant"]')).toHaveCount(0)
await expect(longNotice.locator("[title]")).toHaveAttribute("title", `Switched to ${longName}`)
await expect.poll(() => longNotice.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
-1
View File
@@ -19,7 +19,6 @@ const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0
export default defineConfig({
testDir: "./e2e",
testIgnore: [
"desktop/**",
"service-worker/**",
process.env.OPENCODE_PERFORMANCE === "1" ? "performance/**/*.test.ts" : "performance/**",
],
+9 -8
View File
@@ -3,7 +3,7 @@ import { Button } from "@opencode/ui/button"
import { useDialog } from "@opencode/ui/context/dialog"
import { Icon } from "@opencode/ui/icon"
import { Keybind } from "@opencode/ui/keybind"
import { ProviderModelIcon } from "@/providers/models/provider-group"
import { ProviderIcon } from "@opencode/ui/provider-icon"
import { Tooltip } from "@opencode/ui/tooltip"
import { ComposerEditor } from "./editor/editor"
import { ModelSelectorPopover } from "@/providers/models/select-dialog"
@@ -35,7 +35,7 @@ export function Composer(props: { class?: string; model: ComposerModel; borderUn
title={language.t("command.model.choose")}
keybind={command.keybindParts("model.choose")}
model={props.model.model.selection}
provider={props.model.model.selection.current()?.provider}
providerID={props.model.model.selection.current()?.provider?.id}
modelName={props.model.model.selection.current()?.name ?? language.t("dialog.model.select.title")}
onClose={props.model.restoreFocus}
onUnpaidClick={() => dialog.show(() => <DialogSelectModelUnpaid model={props.model.model.selection} />)}
@@ -52,7 +52,7 @@ function ComposerModelControl(props: {
title: string
keybind: string[]
model: ComposerModel["model"]["selection"]
provider?: { id: string; canonical?: string; name: string }
providerID?: string
modelName: string
onClose: () => void
onUnpaidClick: () => void
@@ -60,11 +60,12 @@ function ComposerModelControl(props: {
const shouldAnimate = createMemo<boolean>((previous) => previous ?? props.loading)
const content = () => (
<>
<Show when={props.provider}>
{(provider) => (
<ProviderModelIcon
provider={provider()}
class="shrink-0 opacity-40 transition-opacity duration-150 group-hover:opacity-100"
<Show when={props.providerID}>
{(providerID) => (
<ProviderIcon
id={providerID()}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
)}
</Show>
+4 -18
View File
@@ -19,7 +19,6 @@ import {
import { useLanguage } from "@/runtime/i18n/language"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useProviders } from "@/providers/catalog/providers"
import { useIntegrations } from "@/providers/catalog/integrations"
import { NEW_SESSION_CONTENT_WIDTH } from "@/new-session/layout"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
@@ -153,8 +152,6 @@ export function NewSessionView(props: {
</div>
</div>
<NewSessionTips
selection={props.composer.model.selection}
onDone={props.composer.restoreFocus}
workspaceEligible={
!!props.project.selected() &&
props.workspace.bar.visible() &&
@@ -168,19 +165,13 @@ export function NewSessionView(props: {
)
}
function NewSessionTips(props: {
selection: ComposerModel["model"]["selection"]
onDone: () => void
workspaceEligible: boolean
onWorkspace: () => void
}) {
function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () => void }) {
const language = useLanguage()
const dialog = useDialog()
const sdk = useWorkspaceLocation()
const providers = useProviders(() => sdk().directory)
const integrations = useIntegrations(() => sdk().directory)
const [providerState, setProviderState, , providerReady] = persisted(
Persist.global("new-session.provider-tip-v3"),
Persist.global("new-session.provider-tip"),
ProviderTipSchema,
{ dismissedAt: 0 },
)
@@ -199,10 +190,7 @@ function NewSessionTips(props: {
() =>
providers.ready() &&
providerReady() &&
!integrations.list().some((integration) => integration.connections.length > 0) &&
!providers
.connected()
.some((provider) => provider.id !== "opencode" && Object.keys(provider.models).length > 0) &&
providers.paid().length === 0 &&
Date.now() - providerState.dismissedAt >= providerTipDismissalDuration,
)
const tip = createMemo<"workspace" | "provider" | undefined>(() => {
@@ -224,9 +212,7 @@ function NewSessionTips(props: {
return
}
void import("@/providers/connect/dialog").then(({ DialogConnectProvider }) => {
void dialog.show(() => (
<DialogConnectProvider directory={sdk().directory} selection={props.selection} onDone={props.onDone} />
))
void dialog.show(() => <DialogConnectProvider directory={sdk().directory} />)
})
}
const dismiss = () => {
@@ -1,19 +0,0 @@
import { expect, test } from "bun:test"
import { consoleProviderGroup, consoleProviderName } from "./console"
test("groups only providers managed by the active Console workspace", () => {
const direct = { id: "openai", integrationID: "openai", name: "Anomaly / OpenAI" }
const group = consoleProviderGroup([
{ id: "opencode", integrationID: "opencode", name: "Anomaly / OpenCode" },
{ id: "console-openai", integrationID: "opencode", name: "Anomaly / OpenAI" },
{ id: "console-google", integrationID: "opencode", name: "Anomaly / Google" },
direct,
])
expect(group).toBeDefined()
if (!group) throw new Error("Expected Console provider group")
expect(group.workspace).toBe("Anomaly")
expect(group.providers.map((provider) => provider.id)).toEqual(["opencode", "console-openai", "console-google"])
expect(consoleProviderName(group, group.providers[1].name)).toBe("OpenAI")
expect(group.providers).not.toContain(direct)
})
@@ -1,26 +0,0 @@
type Provider = {
id: string
integrationID?: string
name: string
}
export function consoleProviderGroup<T extends Provider>(providers: readonly T[]) {
const root = providers.find((provider) => provider.id === "opencode" && provider.integrationID === "opencode")
const suffix = " / OpenCode"
if (!root?.name.endsWith(suffix)) return
const workspace = root.name.slice(0, -suffix.length).trim()
if (!workspace) return
const prefix = `${workspace} / `
return {
root,
workspace,
prefix,
providers: providers.filter(
(provider) => provider.integrationID === "opencode" && provider.name.startsWith(prefix),
),
}
}
export function consoleProviderName(group: { prefix: string }, name: string) {
return name.startsWith(group.prefix) ? name.slice(group.prefix.length) : name
}
@@ -45,10 +45,6 @@ export function useProviders(directory: Accessor<string | undefined>) {
},
all: () => providers().all,
default: () => providers().default,
usable: () =>
(data.location.model.list(location()) ?? []).some(
(model) => model.enabled && model.status !== "deprecated" && providers().connected.includes(model.providerID),
),
// V2 servers list only available providers, so the connectable catalog
// comes from the integration list, with the provider catalog as fallback.
popular: () => {
@@ -76,12 +72,15 @@ export function useProviders(directory: Accessor<string | undefined>) {
},
paid: () => {
const connected = new Set(providers().connected)
const paid = new Set(
(data.location.model.list(location()) ?? [])
.filter((model) => model.enabled && model.cost.some((cost) => cost.input > 0))
.map((model) => model.providerID),
)
return [...Iterable.filter(providers().all, ([id]) => connected.has(id) && paid.has(id))]
const paid = [
...Iterable.filter(
providers().all,
([id]) =>
connected.has(id) &&
(id !== "opencode" || Object.values(providers().all.get(id)?.models ?? {}).some((m) => m.cost?.input)),
),
]
return paid
},
}
}
@@ -1,68 +0,0 @@
import { For, Show } from "solid-js"
import { Button } from "@opencode/ui/button"
import { TextShimmer } from "@opencode/ui/text-shimmer"
import { useLanguage } from "@/runtime/i18n/language"
export function ConsoleAuthorization(props: {
code: string
browserFailed: boolean
copied: boolean
copyFailed: boolean
onOpen: () => void
onCopy: () => void
}) {
const language = useLanguage()
return (
<div
data-component="console-authorization"
class="flex flex-col gap-5 text-[13px] leading-5 text-v2-text-text-muted"
>
<p>
{language.t(
props.browserFailed ? "provider.connect.console.browserFailed" : "provider.connect.console.instructions",
)}
</p>
<div class="flex flex-col gap-2">
<div class="font-medium text-v2-text-text-base">{language.t("provider.connect.console.deviceCode")}</div>
<div
dir="ltr"
role="group"
aria-label={`${language.t("provider.connect.console.deviceCode")}: ${props.code}`}
class="flex max-w-full gap-1 self-start font-mono text-xl font-[530] text-v2-text-text-base tabular-nums"
>
<For each={props.code.split("")}>
{(character) => (
<span
aria-hidden="true"
class={
character === "-"
? "mx-1 flex h-12 items-center text-v2-text-text-muted"
: "flex h-12 w-8 items-center justify-center rounded-md border border-v2-border-border-base bg-v2-background-bg-layer-02"
}
>
{character}
</span>
)}
</For>
</div>
<p role="status">
<TextShimmer text={language.t("provider.connect.console.waiting")} active />
</p>
</div>
<div data-component="console-browser-fallback" class="flex min-h-7 flex-wrap items-center gap-x-3 gap-y-1">
<span class="text-v2-text-text-faint">{language.t("provider.connect.console.browserHint")}</span>
<Button variant="ghost-muted" onClick={props.onCopy}>
{language.t(props.copied ? "provider.connect.console.linkCopied" : "provider.connect.console.copyLink")}
</Button>
<Show when={props.browserFailed || props.copyFailed}>
<Button variant="ghost" onClick={props.onOpen}>
{language.t("provider.connect.console.openAgain")}
</Button>
</Show>
</div>
<Show when={props.copyFailed}>
<p role="alert">{language.t("provider.connect.console.copyFailed")}</p>
</Show>
</div>
)
}
+31 -170
View File
@@ -9,41 +9,22 @@ import { createStore, produce } from "solid-js/store"
export type ProviderConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
type Authorization = IntegrationOauthConnectOutput["data"]
export function providerFormDefaults(fields: ProviderConnectMethod["form"]) {
return (fields ?? []).reduce<FormAnswer>((answer, field) => {
if (field.type === "external" || !field.hidden || field.default === undefined) return answer
const active = (field.when ?? []).every((condition) => {
const actual = answer[condition.key]
if (actual === undefined) return false
const equal = Array.isArray(actual)
? typeof condition.value === "string" && actual.includes(condition.value)
: actual === condition.value
return condition.op === "eq" ? equal : !equal
})
if (!active) return answer
return { ...answer, [field.key]: field.default }
}, {})
}
export function createProviderConnectionController(options: {
provider: () => string
directory: () => string | undefined
onComplete: () => void
prepare?: (active: () => boolean) => Promise<boolean>
initialMethod?: string
pollInterval?: number
}) {
const language = useLanguage()
const platform = usePlatform()
const serverSDK = useServerSDK()
const data = useData()
// An authorization belongs to the server and Location where it began.
const directory = options.directory()
const integrationID = options.provider()
const desktopConsole = platform.platform === "desktop" && integrationID === "opencode"
const location = () => (directory ? { directory } : undefined)
const location = () => {
const directory = options.directory()
return directory ? { directory } : undefined
}
const [integration] = createResource(
() => ({ provider: integrationID, directory }),
() => ({ provider: options.provider(), directory: options.directory() }),
(input) =>
serverSDK.api.integration
.get({ integrationID: input.provider, location: location() })
@@ -60,12 +41,8 @@ export function createProviderConnectionController(options: {
methodIndex: undefined as number | undefined,
authorization: undefined as Authorization | undefined,
formAnswer: undefined as FormAnswer | undefined,
state: "pending" as "pending" | "waiting" | "refreshing" | "ready" | "error" | "form" | undefined,
state: "pending" as "pending" | "complete" | "error" | "form" | undefined,
error: undefined as string | undefined,
connected: false,
browserFailed: false,
statusFailed: false,
selectingIndex: undefined as number | undefined,
})
const polling = {
generation: 0,
@@ -82,7 +59,7 @@ export function createProviderConnectionController(options: {
| { type: "auth.form" }
| { type: "auth.answer"; answer: FormAnswer | undefined }
| { type: "auth.pending" }
| { type: "auth.authorized"; index: number; authorization: Authorization }
| { type: "auth.complete"; authorization: Authorization }
| { type: "auth.error"; error: string }
const dispatch = (action: Action) => {
@@ -94,10 +71,6 @@ export function createProviderConnectionController(options: {
draft.formAnswer = undefined
draft.state = undefined
draft.error = undefined
draft.connected = false
draft.browserFailed = false
draft.statusFailed = false
draft.selectingIndex = undefined
return
}
if (action.type === "method.reset") {
@@ -106,10 +79,6 @@ export function createProviderConnectionController(options: {
draft.formAnswer = undefined
draft.state = undefined
draft.error = undefined
draft.connected = false
draft.browserFailed = false
draft.statusFailed = false
draft.selectingIndex = undefined
return
}
if (action.type === "auth.form") {
@@ -126,15 +95,12 @@ export function createProviderConnectionController(options: {
if (action.type === "auth.pending") {
draft.state = "pending"
draft.error = undefined
draft.selectingIndex = undefined
return
}
if (action.type === "auth.authorized") {
draft.methodIndex = action.index
draft.state = "waiting"
if (action.type === "auth.complete") {
draft.state = "complete"
draft.authorization = action.authorization
draft.error = undefined
draft.selectingIndex = undefined
return
}
draft.state = "error"
@@ -149,64 +115,24 @@ export function createProviderConnectionController(options: {
clearTimeout(polling.timer)
polling.timer = undefined
}
const cancelAttempt = (authorization = store.authorization) => {
if (!desktopConsole) return
if (!authorization || (authorization.attemptID === store.authorization?.attemptID && store.connected)) return
void serverSDK.api.integration.oauth
.cancel({
integrationID,
attemptID: authorization.attemptID,
location: location(),
})
.catch(() => undefined)
}
const openBrowser = async () => {
const authorization = store.authorization
if (!authorization) return
const generation = polling.generation
const opened = await Promise.resolve()
.then(async () => {
if (platform.openBrowser) return platform.openBrowser(authorization.url)
platform.openExternal(authorization.url)
return true
})
.then((result) => result !== false)
.catch(() => false)
if (polling.disposed || generation !== polling.generation) return
setStore("browserFailed", !opened)
}
const finish = async () => {
cancelPolling()
const generation = polling.generation
setStore({ connected: true, state: "refreshing", error: undefined })
const ref = location()
data.location.integration.invalidate(ref)
data.location.provider.invalidate(ref)
data.location.model.invalidate(ref)
const refreshed = await Promise.all([
await Promise.all([
data.location.integration.sync(ref),
data.location.provider.sync(ref),
data.location.model.sync(ref),
])
.then(() => true)
.catch(() => false)
if (polling.disposed || generation !== polling.generation) return
const prepared =
refreshed && options.prepare
? await options.prepare(() => !polling.disposed && generation === polling.generation)
: refreshed
if (polling.disposed || generation !== polling.generation) return
if (!prepared && desktopConsole) {
dispatch({ type: "auth.error", error: language.t("provider.connect.console.refreshFailed") })
return
}
setStore("state", "ready")
]).catch(() => undefined)
if (polling.disposed) return
options.onComplete()
}
const poll = async (authorization: Authorization, generation: number) => {
const result = await serverSDK.api.integration.oauth
.status({
integrationID,
integrationID: options.provider(),
attemptID: authorization.attemptID,
location: location(),
})
@@ -214,14 +140,9 @@ export function createProviderConnectionController(options: {
.catch((error) => ({ ok: false as const, error }))
if (polling.disposed || generation !== polling.generation) return
if (!result.ok) {
setStore("statusFailed", true)
dispatch({
type: "auth.error",
error: desktopConsole
? language.t("provider.connect.console.statusFailed")
: result.error instanceof Error
? result.error.message
: String(result.error),
error: result.error instanceof Error ? result.error.message : String(result.error),
})
return
}
@@ -230,41 +151,21 @@ export function createProviderConnectionController(options: {
return
}
if (result.status.status === "failed") {
const message = result.status.message
dispatch({
type: "auth.error",
error:
desktopConsole && message.includes("expired_token")
? language.t("provider.connect.console.expired")
: desktopConsole && message.includes("access_denied")
? language.t("provider.connect.console.denied")
: message,
})
dispatch({ type: "auth.error", error: result.status.message })
return
}
if (result.status.status === "expired") {
dispatch({
type: "auth.error",
error: language.t(desktopConsole ? "provider.connect.console.expired" : "common.requestFailed"),
})
dispatch({ type: "auth.error", error: language.t("common.requestFailed") })
return
}
polling.timer = setTimeout(
() => void poll(authorization, generation),
options.pollInterval ?? (desktopConsole ? 500 : 1_000),
)
polling.timer = setTimeout(() => void poll(authorization, generation), options.pollInterval ?? 1_000)
}
const select = async (index: number, answer?: FormAnswer) => {
cancelPolling()
cancelAttempt()
const generation = polling.generation
const selected = methods()[index]
const defaults = providerFormDefaults(selected.form)
const resolvedAnswer = answer ? { ...defaults, ...answer } : defaults
const awaitAuthorization = desktopConsole && selected.type === "oauth" && selected.id === "device"
if (!awaitAuthorization) dispatch({ type: "method.select", index })
if (selected.form?.some((field) => field.type === "external" || !field.hidden) && !answer) {
if (awaitAuthorization) dispatch({ type: "method.select", index })
dispatch({ type: "method.select", index })
if (selected.form?.length && !answer) {
dispatch({ type: "auth.form" })
return
}
@@ -274,62 +175,41 @@ export function createProviderConnectionController(options: {
}
if (selected.type !== "oauth") return
if (selected.form?.some((field) => field.type !== "string")) {
dispatch({ type: "auth.error", error: language.t("provider.connect.form.unsupported") })
dispatch({ type: "auth.error", error: "This authentication form contains unsupported fields" })
return
}
if (awaitAuthorization) {
const retrying = store.state === "error"
setStore({
selectingIndex: index,
authorization: undefined,
...(retrying ? {} : { state: undefined, error: undefined }),
browserFailed: false,
statusFailed: false,
})
} else {
dispatch({ type: "auth.pending" })
}
dispatch({ type: "auth.pending" })
const result = await serverSDK.api.integration.oauth
.connect({
integrationID,
integrationID: options.provider(),
methodID: selected.id,
...(Object.keys(resolvedAnswer).length > 0 ? { answer: resolvedAnswer } : {}),
...(answer ? { answer } : {}),
location: location(),
})
.then((response) => {
if (integrationID === "opencode" && platform.platform === "desktop") {
if (options.provider() === "opencode" && platform.platform === "desktop") {
const url = new URL(response.data.url)
url.searchParams.set("client_id", "opencode-desktop")
url.searchParams.set("return_window", platform.windowID)
response.data.url = url.href
}
return { ok: true as const, authorization: response.data }
})
.catch((error) => ({ ok: false as const, error }))
if (polling.disposed || generation !== polling.generation) {
if (result.ok) cancelAttempt(result.authorization)
return
}
if (polling.disposed || generation !== polling.generation) return
if (!result.ok) {
if (awaitAuthorization) dispatch({ type: "method.select", index })
dispatch({
type: "auth.error",
error: desktopConsole ? language.t("provider.connect.console.startFailed") : String(result.error),
})
dispatch({ type: "auth.error", error: String(result.error) })
return
}
dispatch({ type: "auth.authorized", index, authorization: result.authorization })
if (desktopConsole && selected.id === "device") void openBrowser()
dispatch({ type: "auth.complete", authorization: result.authorization })
if (result.authorization.mode === "auto") void poll(result.authorization, generation)
}
const reset = () => {
cancelPolling()
cancelAttempt()
dispatch({ type: "method.reset" })
}
const connectKey = async (key: string) => {
await serverSDK.api.integration.connect.key({
integrationID,
integrationID: options.provider(),
location: location(),
key,
...(store.formAnswer ? { answer: store.formAnswer } : {}),
@@ -341,7 +221,7 @@ export function createProviderConnectionController(options: {
if (!authorization) return language.t("provider.connect.oauth.code.invalid")
const result = await serverSDK.api.integration.oauth
.complete({
integrationID,
integrationID: options.provider(),
attemptID: authorization.attemptID,
location: location(),
code,
@@ -358,20 +238,13 @@ export function createProviderConnectionController(options: {
let auto = false
createEffect(() => {
if (auto || integration.loading) return
const index = options.initialMethod
? methods().findIndex((method) => method.type === "oauth" && method.id === options.initialMethod)
: methods().length === 1
? 0
: -1
if (index < 0) return
if (auto || integration.loading || methods().length !== 1) return
auto = true
void select(index)
void select(0)
})
onCleanup(() => {
polling.disposed = true
cancelPolling()
cancelAttempt()
})
return {
@@ -381,9 +254,6 @@ export function createProviderConnectionController(options: {
currentMethod,
methodIndex: () => store.methodIndex,
authorization: () => store.authorization,
browserFailed: () => store.browserFailed,
selecting: (index: number) => store.selectingIndex === index,
openBrowser,
auth: {
state: () => store.state,
error: () => store.error,
@@ -391,15 +261,6 @@ export function createProviderConnectionController(options: {
reset,
connectKey,
completeCode,
refresh: finish,
retry: () => {
if (store.connected) return finish()
if (store.statusFailed && store.authorization) {
setStore({ state: "waiting", error: undefined, statusFailed: false })
return poll(store.authorization, polling.generation)
}
return store.methodIndex === undefined ? Promise.resolve() : select(store.methodIndex, store.formAnswer)
},
},
}
}
File diff suppressed because it is too large Load Diff
@@ -1,72 +0,0 @@
.connected-model-row-shell {
position: relative;
margin-inline: 4px;
padding-block: 4px;
}
.connected-model-row-shell:not(:last-child)::after {
content: "";
position: absolute;
inset-inline: 12px;
bottom: 0;
height: 0.5px;
background: var(--v2-border-border-base);
}
.connected-model-row {
display: flex;
width: 100%;
height: 40px;
align-items: center;
padding-inline: 12px;
padding-block: 0;
border: 0;
border-radius: 4px;
}
@media (hover: hover) {
.connected-model-row:hover {
background-color: var(--v2-background-bg-layer-03);
}
}
.connected-model-row:focus-visible {
background-color: var(--v2-background-bg-layer-03);
}
[data-component="first-provider-models"] [data-component="settings-list"] {
background-color: var(--v2-background-bg-layer-02);
overflow: hidden;
padding-inline: 0;
}
[data-component="first-provider-models"] .provider-model-groups--dialog .provider-model-group {
background-color: var(--v2-background-bg-layer-02);
}
[data-component="first-provider-models"] .provider-model-groups--dialog .provider-model-group-models {
padding-inline: 0;
}
[data-component="first-provider-models"]
.provider-model-groups--dialog
.provider-model-group-models
> [data-component="settings-list"] {
background-color: transparent;
}
@media (hover: hover) {
[data-component="first-provider-models"]
.provider-model-groups--dialog
.provider-model-group-trigger:not(:disabled):hover {
background-color: var(--v2-background-bg-layer-03);
}
}
[data-component="first-provider-models"] .settings-section {
gap: 8px;
}
[data-component="first-provider-models"] .settings-section[data-expanded] {
padding-bottom: 8px;
}
@@ -1,30 +0,0 @@
import { expect, test } from "bun:test"
import { authServerName } from "./remote"
test("SSH disclosure uses the remote identity even with a loopback proxy", () => {
expect(authServerName({ type: "ssh", host: "production.example", http: { url: "http://127.0.0.1:4096" } })).toBe(
"production.example",
)
expect(
authServerName({
type: "ssh",
host: "production.example",
displayName: "Production server",
http: { url: "http://127.0.0.1:4096" },
}),
).toBe("Production server")
})
test("local Desktop and loopback HTTP connections do not show remote disclosure", () => {
expect(authServerName({ type: "sidecar", variant: "base", http: { url: "http://127.0.0.1:4096" } })).toBeUndefined()
for (const host of ["localhost", "127.0.0.1", "[::1]"]) {
expect(authServerName({ type: "http", http: { url: `http://${host}:4096` } })).toBeUndefined()
}
})
test("WSL and remote HTTP connections show their server identity", () => {
expect(
authServerName({ type: "sidecar", variant: "wsl", distro: "Ubuntu", http: { url: "http://127.0.0.1:4096" } }),
).toBe("Ubuntu")
expect(authServerName({ type: "http", http: { url: "https://production.example" } })).toBe("production.example")
})
@@ -1,30 +0,0 @@
import { Show } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { ServerConnection, serverName } from "@/runtime/server/registry"
export function authServerName(server: ServerConnection.Any) {
if (ServerConnection.builtin(server)) return undefined
if (server.type === "http" && ["localhost", "127.0.0.1", "[::1]"].includes(new URL(server.http.url).hostname))
return undefined
if (server.type === "sidecar" && server.variant === "wsl") return server.displayName ?? server.distro
return serverName(server)
}
export function RemoteAuthNotice(props: { server: ServerConnection.Any }) {
const language = useLanguage()
return (
<Show when={authServerName(props.server)}>
{(name) => (
<div
class="rounded-md border border-v2-border-border-base bg-v2-background-bg-layer-02 p-3 text-[13px] leading-5"
role="note"
>
<p class="font-medium text-v2-text-text-base">
{language.t("provider.connect.remote.title", { server: name() })}
</p>
<p class="text-v2-text-text-muted">{language.t("provider.connect.remote.description")}</p>
</div>
)}
</Show>
)
}
+65 -169
View File
@@ -1,12 +1,12 @@
import { Button } from "@opencode/ui/button"
import { Badge } from "@opencode/ui/badge"
import { Dialog, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode/ui/dialog"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { ProviderIcon } from "@opencode/ui/provider-icon"
import { Switch } from "@opencode/ui/switch"
import { TextInput } from "@opencode/ui/text-input"
import { useFilteredList } from "@opencode/ui/hooks"
import { createMemo, For, Show, type Component } from "solid-js"
import { For, Show, type Component } from "solid-js"
import { createStore } from "solid-js/store"
import { useLocal } from "@/providers/models/selection"
import { popularProviders } from "@/providers/catalog/providers"
@@ -16,19 +16,9 @@ import { DialogConnectProvider } from "@/providers/connect/dialog"
import { decode64 } from "@/runtime/persistence/base64"
import { SettingsList } from "@/settings/list"
import { SettingsRow } from "@/settings/row"
import { OpenCodeLogo } from "@/providers/opencode-logo"
import { consoleProviderGroup, consoleProviderName } from "@/providers/catalog/console"
import { ProviderModelGroup, ProviderModelIcon } from "@/providers/models/provider-group"
import "@/settings/settings.css"
type ModelItem = ReturnType<ReturnType<typeof useLocal>["model"]["list"]>[number]
type ModelGroup = { category: string; items: ModelItem[] }
type ConsoleGroup = NonNullable<ReturnType<typeof consoleProviderGroup<ModelItem["provider"]>>>
type DisplayGroup =
| { type: "provider"; group: ModelGroup }
| { type: "console"; managed: ConsoleGroup; providers: ModelGroup[] }
const CONSOLE_GROUP_KEY = "console:opencode"
export const DialogManageModels: Component = () => {
const local = useLocal()
@@ -64,59 +54,9 @@ export const DialogManageModels: Component = () => {
const bPopular = bRank >= 0
if (aPopular && !bPopular) return -1
if (!aPopular && bPopular) return 1
if (aPopular && bPopular) return aRank - bRank
return a.items[0].provider.name.localeCompare(b.items[0].provider.name)
return aRank - bRank
},
})
const consoleGroup = createMemo(() =>
consoleProviderGroup([...new Map(local.model.list().map((item) => [item.provider.id, item.provider])).values()]),
)
const groups = createMemo<DisplayGroup[]>(() => {
const managed = consoleGroup()
if (!managed) return list.grouped.latest.map((group) => ({ type: "provider" as const, group }))
const ids = new Set(managed.providers.map((provider) => provider.id))
const providers = list.grouped.latest.filter((group) => ids.has(group.category))
if (providers.length === 0) return list.grouped.latest.map((group) => ({ type: "provider" as const, group }))
const first = list.grouped.latest.findIndex((group) => ids.has(group.category))
return list.grouped.latest.flatMap<DisplayGroup>((group, index) => {
if (!ids.has(group.category)) return [{ type: "provider" as const, group }]
if (index !== first) return []
return [{ type: "console" as const, managed, providers }]
})
})
const searching = () => list.filter().length > 0
const expanded = (key: string) => searching() || !store.collapsed[key]
const providerName = (provider: ModelItem["provider"]) =>
provider.id === "opencode" ? language.t("provider.connect.opencode.freeName") : provider.name
const enabled = createMemo(() =>
local.model.list().reduce((counts, item) => {
if (!local.model.visible({ providerID: item.provider.id, modelID: item.id })) return counts
counts.set(item.provider.id, (counts.get(item.provider.id) ?? 0) + 1)
return counts
}, new Map<string, number>()),
)
function ModelRows(props: { items: ModelItem[] }) {
return (
<SettingsList variant="catalog">
<For each={props.items}>
{(item) => (
<SettingsRow title={item.name} description="">
<div>
<Switch
checked={local.model.visible({ modelID: item.id, providerID: item.provider.id })}
onChange={(checked) => setModelVisibility(item, checked)}
hideLabel
>
{item.name}
</Switch>
</div>
</SettingsRow>
)}
</For>
</SettingsList>
)
}
return (
<Dialog size="large" variant="settings" class="settings-manage-models-dialog">
@@ -160,7 +100,7 @@ export const DialogManageModels: Component = () => {
</div>
</div>
<div data-slot="manage-models-scroll" class="relative min-h-0 flex-1">
<div class="settings-panel settings-models h-full px-4 pt-1 pb-4">
<div class="settings-panel settings-models h-full px-4 pt-4 pb-4">
<Show
when={!list.grouped.loading}
fallback={
@@ -181,112 +121,68 @@ export const DialogManageModels: Component = () => {
</div>
}
>
<For each={groups()}>
{(item) => (
<Show
when={item.type === "console" ? item : undefined}
fallback={
<Show when={item.type === "provider" ? item.group : undefined}>
{(group) => (
<div
class="settings-section"
data-component="settings-models-provider"
data-expanded={expanded(group().category) ? "" : undefined}
>
<div class="settings-models-group-header justify-between">
<button
type="button"
class="settings-models-group-trigger"
aria-expanded={expanded(group().category)}
disabled={searching()}
onClick={() => setStore("collapsed", group().category, expanded(group().category))}
>
<span class="settings-models-group-chevron">
<Icon
name="chevron-down"
size="small"
classList={{ collapsed: !expanded(group().category) }}
/>
</span>
<span class="settings-models-group-label">
<ProviderModelIcon provider={group().items[0].provider} class="shrink-0" />
<bdi class="settings-models-group-title">
{providerName(group().items[0].provider)}
</bdi>
</span>
</button>
<Switch
class="me-6"
checked={providerVisible(group().category)}
onChange={(checked) => setProviderVisibility(group().category, checked)}
hideLabel
>
{group().items[0].provider.name}
</Switch>
</div>
<Show when={expanded(group().category)}>
<ModelRows items={group().items} />
</Show>
</div>
)}
</Show>
}
>
{(console) => (
<div
class="settings-section settings-models-console"
data-component="manage-models-console"
data-expanded={expanded(CONSOLE_GROUP_KEY) ? "" : undefined}
>
<div class="settings-models-group-header">
<button
type="button"
class="settings-models-group-trigger"
aria-expanded={expanded(CONSOLE_GROUP_KEY)}
disabled={searching()}
onClick={() => setStore("collapsed", CONSOLE_GROUP_KEY, expanded(CONSOLE_GROUP_KEY))}
>
<span class="settings-models-group-chevron">
<Icon
name="chevron-down"
size="small"
classList={{ collapsed: !expanded(CONSOLE_GROUP_KEY) }}
/>
</span>
<span class="settings-models-group-label">
<OpenCodeLogo class="settings-models-provider-icon size-4 shrink-0" />
<span class="settings-models-group-title">
{language.t("provider.connect.opencode.name")}
</span>
<Badge>{console().managed.workspace}</Badge>
</span>
</button>
</div>
<Show when={expanded(CONSOLE_GROUP_KEY)}>
<div class="provider-model-groups settings-models-console-groups">
<For each={console().providers}>
{(group) => {
const count = () => enabled().get(group.category) ?? 0
return (
<ProviderModelGroup
provider={group.items[0].provider}
name={consoleProviderName(console().managed, group.items[0].provider.name)}
expanded={expanded(group.category)}
disabled={searching()}
detail={language.plural("settings.models.enabled", count(), { count: count() })}
onExpandedChange={(value) => setStore("collapsed", group.category, !value)}
>
<ModelRows items={group.items} />
</ProviderModelGroup>
)
}}
</For>
</div>
</Show>
<For each={list.grouped.latest}>
{(group) => {
const searching = () => list.filter().length > 0
const expanded = () => searching() || !store.collapsed[group.category]
return (
<div
class="settings-section"
data-component="settings-models-provider"
data-expanded={expanded() ? "" : undefined}
>
<div class="settings-models-group-header justify-between">
<button
type="button"
class="settings-models-group-trigger"
aria-expanded={expanded()}
disabled={searching()}
onClick={() => setStore("collapsed", group.category, expanded())}
>
<span class="settings-models-group-chevron">
<Icon
name="chevron-down"
size="small"
classList={{ "-rotate-90 rtl:rotate-90": !expanded() }}
/>
</span>
<span class="settings-models-group-label">
<ProviderIcon id={group.category} width={16} height={16} class="shrink-0" />
<span class="settings-models-group-title">{group.items[0].provider.name}</span>
</span>
</button>
<Switch
class="me-6"
checked={providerVisible(group.category)}
onChange={(checked) => setProviderVisibility(group.category, checked)}
hideLabel
>
{group.items[0].provider.name}
</Switch>
</div>
)}
</Show>
)}
<Show when={expanded()}>
<SettingsList variant="catalog">
<For each={group.items}>
{(item) => (
<SettingsRow title={item.name} description="">
<div>
<Switch
checked={local.model.visible({ modelID: item.id, providerID: item.provider.id })}
onChange={(checked) => setModelVisibility(item, checked)}
hideLabel
>
{item.name}
</Switch>
</div>
</SettingsRow>
)}
</For>
</SettingsList>
</Show>
</div>
)
}}
</For>
</Show>
</Show>
@@ -1,74 +0,0 @@
import { Icon } from "@opencode/ui/icon"
import { iconNames, type IconName } from "@opencode/ui/icons/provider"
import { ProviderIcon } from "@opencode/ui/provider-icon"
import type { JSX } from "solid-js"
import { Show } from "solid-js"
import { OpenCodeLogo } from "@/providers/opencode-logo"
import "@/settings/settings.css"
type ModelProvider = { id: string; canonical?: string; name: string }
export function ProviderModelIcon(props: { provider: ModelProvider; class?: string }) {
const icon = () =>
[
props.provider.canonical,
props.provider.canonical?.replace(/-token-plan$/, ""),
props.provider.id.replace(/^console-/, ""),
].find((id): id is IconName => !!id && iconNames.includes(id as IconName)) ?? props.provider.id
return (
<Show
when={props.provider.id === "opencode"}
fallback={<ProviderIcon id={icon()} width={16} height={16} class={props.class} />}
>
<OpenCodeLogo class={`size-4 ${props.class ?? ""}`} />
</Show>
)
}
export function ProviderModelGroup(props: {
provider: ModelProvider
name?: string
expanded: boolean
disabled?: boolean
detail?: JSX.Element
children: JSX.Element
ref?: (element: HTMLElement) => void
onExpandedChange: (expanded: boolean) => void
}) {
return (
<section
ref={props.ref}
class="provider-model-group"
data-component="provider-model-group"
data-provider={props.provider.id}
data-expanded={props.expanded ? "" : undefined}
>
<h3 class="provider-model-group-header">
<button
type="button"
class="provider-model-group-trigger"
aria-expanded={props.expanded}
disabled={props.disabled}
onClick={() => props.onExpandedChange(!props.expanded)}
>
<span class="provider-model-group-label">
<ProviderModelIcon provider={props.provider} class="shrink-0" />
<bdi class="provider-model-group-title">{props.name ?? props.provider.name}</bdi>
<Icon
name="chevron-down"
size="small"
classList={{ "provider-model-group-chevron": true, collapsed: !props.expanded }}
/>
</span>
<Show when={props.detail}>
<span class="provider-model-group-detail">{props.detail}</span>
</Show>
</button>
</h3>
<Show when={props.expanded}>
<div class="provider-model-group-models">{props.children}</div>
</Show>
</section>
)
}
@@ -6,13 +6,14 @@ import { useDialog } from "@opencode/ui/context/dialog"
import { popularProviders } from "@/providers/catalog/providers"
import { Button } from "@opencode/ui/button"
import { Badge } from "@opencode/ui/badge"
import { Dialog, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode/ui/dialog"
import { Dialog, DialogBody, DialogHeader, DialogTitle } from "@opencode/ui/dialog"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { ScrollView } from "@opencode/ui/scroll-view"
import { Tooltip } from "@opencode/ui/tooltip"
import { Menu } from "@opencode/ui/menu"
import { TextInput } from "@opencode/ui/text-input"
import { ProviderIcon } from "@opencode/ui/provider-icon"
import { ModelTooltip } from "./tooltip"
import { useLanguage } from "@/runtime/i18n/language"
import { decode64 } from "@/runtime/persistence/base64"
@@ -21,9 +22,6 @@ import { createMenuDismissController } from "@/shell/commands/menu-dismiss"
import { createEventListener } from "@solid-primitives/event-listener"
import { matchesModelSearch } from "./search"
import { SettingsList } from "@/settings/list"
import { OpenCodeLogo } from "@/providers/opencode-logo"
import { consoleProviderGroup, consoleProviderName } from "@/providers/catalog/console"
import { ProviderModelGroup, ProviderModelIcon } from "@/providers/models/provider-group"
import "@/settings/settings.css"
const isFree = (provider: string, cost: { input: number } | undefined) =>
@@ -31,15 +29,9 @@ const isFree = (provider: string, cost: { input: number } | undefined) =>
type ModelState = ModelSelection
type ModelItem = ReturnType<ModelState["list"]>[number]
type ModelGroup = { category: string; items: ModelItem[] }
type ConsoleGroup = NonNullable<ReturnType<typeof consoleProviderGroup<ModelItem["provider"]>>>
type DisplayGroup =
| { type: "provider"; group: ModelGroup }
| { type: "console"; managed: ConsoleGroup; providers: ModelGroup[] }
const modelKey = (model: ModelItem) => `${model.provider.id}:${model.id}`
const manageKey = "action:manage"
const CONSOLE_GROUP_KEY = "console:opencode"
const sortModelGroups = (a: { category: string; items: ModelItem[] }, b: { category: string; items: ModelItem[] }) => {
const aIndex = popularProviders.indexOf(a.category)
@@ -70,31 +62,9 @@ const ModelList: Component<{
collapsed: {} as Record<string, boolean>,
})
const models = createMemo(() => controller.models(store.search))
const modelGroups = createMemo(() => controller.groups(models()))
const consoleGroup = createMemo(() =>
consoleProviderGroup([...new Map(controller.all().map((item) => [item.provider.id, item.provider])).values()]),
)
const groups = createMemo<DisplayGroup[]>(() => {
const managed = consoleGroup()
if (!managed) return modelGroups().map((group) => ({ type: "provider" as const, group }))
const ids = new Set(managed.providers.map((provider) => provider.id))
const providers = modelGroups().filter((group) => ids.has(group.category))
if (providers.length === 0) return modelGroups().map((group) => ({ type: "provider" as const, group }))
const first = modelGroups().findIndex((group) => ids.has(group.category))
return modelGroups().flatMap<DisplayGroup>((group, index) => {
if (!ids.has(group.category)) return [{ type: "provider" as const, group }]
if (index !== first) return []
return [{ type: "console" as const, managed, providers }]
})
})
const groups = createMemo(() => controller.groups(models()))
const expanded = (provider: string) => store.search.length > 0 || !store.collapsed[provider]
const providerName = (provider: ModelItem["provider"]) =>
provider.id === "opencode" ? language.t("provider.connect.opencode.freeName") : provider.name
const managedIDs = createMemo(() => new Set(consoleGroup()?.providers.map((provider) => provider.id) ?? []))
const visibleModels = () =>
models().filter(
(item) => expanded(item.provider.id) && (!managedIDs().has(item.provider.id) || expanded(CONSOLE_GROUP_KEY)),
)
const visibleModels = () => models().filter((item) => expanded(item.provider.id))
let scrollRef: HTMLDivElement | undefined
const setSearch = (value: string) => {
@@ -118,53 +88,6 @@ const ModelList: Component<{
if (item) controller.select(item)
}
function ModelRows(props: { items: ModelItem[] }) {
return (
<SettingsList variant="catalog">
<For each={props.items}>
{(item) => (
<button
type="button"
data-component="settings-row"
data-option-key={modelKey(item)}
class="-mx-4 w-[calc(100%+32px)] px-4 text-start first:rounded-t-lg last:rounded-b-lg hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
classList={{ "bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
onMouseEnter={() => setStore("active", modelKey(item))}
onMouseLeave={() => setStore("active", "")}
onClick={() => controller.select(item)}
>
<div data-slot="settings-row-copy">
<div data-slot="settings-row-title" class="flex items-center gap-2">
<Tooltip
placement="right-start"
gutter={12}
openDelay={0}
value={
<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} v2 />
}
>
<span class="min-w-0 truncate">{item.name}</span>
</Tooltip>
<Show when={isFree(item.provider.id, item.cost)}>
<Badge class="shrink-0">{language.t("model.tag.free")}</Badge>
</Show>
<Show when={item.latest}>
<Badge class="shrink-0">{language.t("model.tag.latest")}</Badge>
</Show>
</div>
</div>
<div data-slot="settings-row-control" class="size-4">
<Show when={controller.current() === modelKey(item)}>
<Icon name="check" size="small" class="shrink-0 text-v2-icon-icon-base" />
</Show>
</div>
</button>
)}
</For>
</SettingsList>
)
}
return (
<div class="flex min-h-0 flex-1 flex-col">
<div class="shrink-0 px-4 pt-px pb-3">
@@ -214,100 +137,87 @@ const ModelList: Component<{
</div>
</div>
<div class="relative min-h-0 flex-1">
<div ref={(element) => (scrollRef = element)} class="settings-panel settings-models h-full px-4 pt-1 pb-4">
<div ref={(element) => (scrollRef = element)} class="settings-panel settings-models h-full px-4 pt-4 pb-4">
<Show
when={models().length > 0}
fallback={<div class="settings-models-status">{language.t("dialog.model.empty")}</div>}
>
<For each={groups()}>
{(item) => (
<Show
when={item.type === "console" ? item : undefined}
fallback={
<Show when={item.type === "provider" ? item.group : undefined}>
{(group) => {
const open = () => expanded(group().category)
return (
<section class="settings-section" data-expanded={open() ? "" : undefined}>
<h3 class="settings-models-group-header">
<button
type="button"
class="settings-models-group-trigger"
aria-expanded={open()}
disabled={store.search.length > 0}
onClick={() => setStore("collapsed", group().category, open())}
>
<span class="settings-models-group-chevron">
<Icon name="chevron-down" size="small" classList={{ collapsed: !open() }} />
</span>
<span class="settings-models-group-label">
<ProviderModelIcon provider={group().items[0].provider} class="shrink-0" />
<bdi class="settings-models-group-title">
{providerName(group().items[0].provider)}
</bdi>
</span>
</button>
</h3>
<Show when={open()}>
<ModelRows items={group().items} />
</Show>
</section>
)
}}
{(group) => {
const searching = () => store.search.length > 0
const open = () => expanded(group.category)
return (
<section class="settings-section" data-expanded={open() ? "" : undefined}>
<h3 class="settings-models-group-header">
<button
type="button"
class="settings-models-group-trigger"
aria-expanded={open()}
disabled={searching()}
onClick={() => setStore("collapsed", group.category, open())}
>
<span class="settings-models-group-chevron">
<Icon name="chevron-down" size="small" classList={{ "-rotate-90 rtl:rotate-90": !open() }} />
</span>
<span class="settings-models-group-label">
<ProviderIcon id={group.category} width={16} height={16} class="shrink-0" />
<span class="settings-models-group-title">{group.items[0].provider.name}</span>
</span>
</button>
</h3>
<Show when={open()}>
<SettingsList variant="catalog">
<For each={group.items}>
{(item) => (
<button
type="button"
data-component="settings-row"
data-option-key={modelKey(item)}
class="-mx-4 w-[calc(100%+32px)] px-4 text-start first:rounded-t-lg last:rounded-b-lg hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
classList={{ "bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
onMouseEnter={() => setStore("active", modelKey(item))}
onMouseLeave={() => setStore("active", "")}
onClick={() => controller.select(item)}
>
<div data-slot="settings-row-copy">
<div data-slot="settings-row-title" class="flex items-center gap-2">
<Tooltip
placement="right-start"
gutter={12}
openDelay={0}
value={
<ModelTooltip
model={item}
latest={item.latest}
free={isFree(item.provider.id, item.cost)}
v2
/>
}
>
<span class="min-w-0 truncate">{item.name}</span>
</Tooltip>
<Show when={isFree(item.provider.id, item.cost)}>
<Badge class="shrink-0">{language.t("model.tag.free")}</Badge>
</Show>
<Show when={item.latest}>
<Badge class="shrink-0">{language.t("model.tag.latest")}</Badge>
</Show>
</div>
</div>
<div data-slot="settings-row-control" class="size-4">
<Show when={controller.current() === modelKey(item)}>
<Icon name="check" size="small" class="shrink-0 text-v2-icon-icon-base" />
</Show>
</div>
</button>
)}
</For>
</SettingsList>
</Show>
}
>
{(console) => (
<section
class="settings-section settings-models-console"
data-component="select-model-console"
data-expanded={expanded(CONSOLE_GROUP_KEY) ? "" : undefined}
>
<h3 class="settings-models-group-header">
<button
type="button"
class="settings-models-group-trigger"
aria-expanded={expanded(CONSOLE_GROUP_KEY)}
disabled={store.search.length > 0}
onClick={() => setStore("collapsed", CONSOLE_GROUP_KEY, expanded(CONSOLE_GROUP_KEY))}
>
<span class="settings-models-group-chevron">
<Icon
name="chevron-down"
size="small"
classList={{ collapsed: !expanded(CONSOLE_GROUP_KEY) }}
/>
</span>
<span class="settings-models-group-label">
<OpenCodeLogo class="settings-models-provider-icon size-4 shrink-0" />
<span class="settings-models-group-title">
{language.t("provider.connect.opencode.name")}
</span>
<Badge>{console().managed.workspace}</Badge>
</span>
</button>
</h3>
<Show when={expanded(CONSOLE_GROUP_KEY)}>
<div class="provider-model-groups settings-models-console-groups">
<For each={console().providers}>
{(group) => (
<ProviderModelGroup
provider={group.items[0].provider}
name={consoleProviderName(console().managed, group.items[0].provider.name)}
expanded={expanded(group.category)}
disabled={store.search.length > 0}
onExpandedChange={(value) => setStore("collapsed", group.category, !value)}
>
<ModelRows items={group.items} />
</ProviderModelGroup>
)}
</For>
</div>
</Show>
</section>
)}
</Show>
)}
</section>
)
}}
</For>
</Show>
</div>
@@ -362,7 +272,6 @@ function createModelSelectorController(input: {
)
return {
all: () => model.list().filter((item) => (input.provider() ? item.provider.id === input.provider() : true)),
models: (search: string) => {
const query = search.trim()
const filtered = query
@@ -639,7 +548,7 @@ export const DialogSelectModel: Component<{ provider?: string; model?: ModelStat
return (
<Dialog size="large" variant="settings">
<DialogHeader hideClose closeLabel={language.t("common.close")}>
<DialogTitleGroup title={language.t("dialog.model.select.title")} />
<DialogTitle>{language.t("dialog.model.select.title")}</DialogTitle>
<Button icon="plus" onClick={provider}>
{language.t("command.provider.connect")}
</Button>
+2 -5
View File
@@ -1,7 +1,7 @@
import { DialogBody, DialogHeader, DialogTitle, Dialog } from "@opencode/ui/dialog"
import { Badge } from "@opencode/ui/badge"
import { Icon } from "@opencode/ui/icon"
import { ProviderModelIcon } from "@/providers/models/provider-group"
import { ProviderIcon } from "@opencode/ui/provider-icon"
import { Tooltip } from "@opencode/ui/tooltip"
import { useDialog } from "@opencode/ui/context/dialog"
import { useTheme } from "@opencode/ui/theme"
@@ -144,10 +144,7 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
}}
onClick={() => openProviders(provider.id)}
>
<ProviderModelIcon
provider={{ id: provider.id, name: provider.name }}
class="mt-0.5 shrink-0 text-v2-icon-icon-base"
/>
<ProviderIcon id={provider.id} class="mt-0.5 size-4 shrink-0 text-v2-icon-icon-base" />
<span class="flex min-w-0 flex-col">
<span class="truncate">{provider.name}</span>
<Show when={provider.id === "opencode" || provider.id === "opencode-go"}>
@@ -1,17 +0,0 @@
export function OpenCodeLogo(props: { class?: string }) {
return (
<svg
data-component="opencode-logo"
aria-hidden="true"
class={props.class}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g transform="translate(1.2 1.2) scale(0.85)">
<path opacity="0.2" d="M11.1999 12.8H4.79993V6.40002H11.1999V12.8Z" fill="currentColor" />
<path d="M11.2 3.2H4.79998V12.8H11.2V3.2ZM14.4 16H1.59998V0H14.4V16Z" fill="currentColor" />
</g>
</svg>
)
}
-52
View File
@@ -192,54 +192,6 @@ export const dict = {
"dialog.provider.viewAll": "Show more providers",
"provider.connect.title": "Connect {{provider}}",
"provider.connect.opencode.name": "OpenCode",
"provider.connect.opencode.freeName": "OpenCode Free",
"provider.connect.opencode.errorTitle": "Connect to OpenCode",
"provider.connect.console.title": "Connecting to OpenCode",
"provider.connect.console.name": "OpenCode Console",
"provider.connect.console.instructions":
"Continue in your browser. Confirm the code shown there matches the one below.",
"provider.connect.console.deviceCode": "Device code",
"provider.connect.console.waiting": "Waiting for confirmation…",
"provider.connect.console.browserHint": "Browser didn't open?",
"provider.connect.console.copyLink": "Copy sign-in link",
"provider.connect.console.linkCopied": "Sign-in link copied",
"provider.connect.console.copyFailed": "Couldn't copy the sign-in link. Open Console again to continue.",
"provider.connect.console.openAgain": "Open Console again",
"provider.connect.console.browserFailed":
"We couldn't open your browser. Try again or copy the sign-in link to continue.",
"provider.connect.console.expired": "This sign-in request has expired. Start again to get a new device code.",
"provider.connect.console.denied": "Access was denied in Console. Try again when you're ready to connect.",
"provider.connect.console.statusFailed": "Couldn't check authorization. Check your server connection and try again.",
"provider.connect.console.startFailed": "Couldn't start sign-in. Check your server connection and try again.",
"provider.connect.models.title": "Connected to {{provider}}",
"provider.connect.models.description": "Choose a model to start with. You can switch models anytime.",
"provider.connect.models.available": "Available models",
"provider.connect.models.list": "Models available from {{provider}}",
"provider.connect.console.retry": "Try again",
"provider.connect.console.refreshing": "OpenCode connected. Loading your models...",
"provider.connect.console.refreshFailed":
"Your account is connected, but we couldn't load your models. Try again to refresh them.",
"provider.connect.console.connected": "OpenCode connected",
"provider.connect.console.ready": "Your models are ready.",
"provider.connect.console.noModels":
"Your account is connected, but this Console workspace has no available models. Check its setup in Console, then refresh.",
"provider.connect.console.refresh": "Refresh models",
"provider.connect.console.model": "Model",
"provider.connect.console.start": "Start coding",
"provider.connect.console.done": "Done",
"provider.connect.console.continue": "Continue to OpenCode Console",
"provider.connect.console.openingBrowser": "Opening browser…",
"provider.connect.console.serviceAccount": "Service account?",
"provider.connect.console.useApiKey": "Use API key",
"provider.connect.console.otherMethods": "Other methods",
"provider.connect.console.serviceKey": "API key (service account)",
"provider.connect.console.serviceKeyDescription": "Connect using a service-account API key from OpenCode Console.",
"provider.connect.console.intro": "Sign in with your OpenCode Console account to use the available models.",
"provider.connect.remote.title": "Connecting on “{{server}}”",
"provider.connect.remote.description":
"Your OpenCode credentials will be stored on this server. Models will be available through this server.",
"provider.connect.form.unsupported": "This authentication form contains unsupported fields",
"provider.connect.title.anthropicProMax": "Login with Anthropic",
"provider.connect.selectMethod": "Select login method for {{provider}}.",
"provider.connect.method.apiKey": "API key",
@@ -1378,8 +1330,6 @@ export const dict = {
"settings.providers.section.connected": "Connected providers",
"settings.providers.connected.empty": "No connected providers",
"settings.providers.connected.environmentDescription": "Connected from your environment variables",
"settings.providers.console.available.one": "{{count}} provider available",
"settings.providers.console.available.other": "{{count}} providers available",
"settings.providers.section.popular": "Popular providers",
"settings.providers.custom.description": "Add an OpenAI-compatible provider by base URL.",
"settings.providers.tag.environment": "Environment",
@@ -1388,8 +1338,6 @@ export const dict = {
"settings.providers.tag.other": "Other",
"settings.models.title": "Models",
"settings.models.description": "Choose which models appear in model picker",
"settings.models.enabled.one": "{{count}} model enabled",
"settings.models.enabled.other": "{{count}} models enabled",
"settings.agents.title": "Agents",
"settings.agents.description": "Agent settings will be configurable here.",
"settings.commands.title": "Commands",
@@ -37,9 +37,6 @@ type PlatformBase = {
/** Open a web or mail URL in the default system application */
openExternal(url: string): void
/** Open an authentication page, reporting whether the browser could be launched. */
openBrowser?(url: string): Promise<boolean>
/** Open a local path in a local app (desktop only) */
openPath?(path: string, app?: string): Promise<void>
@@ -51,8 +51,6 @@ export function normalizeProviderList(
for (const provider of providers) {
all.set(provider.id, {
id: provider.id,
canonical: provider.canonical,
integrationID: provider.integrationID,
name: provider.name,
source: "custom",
env: [],
-2
View File
@@ -127,8 +127,6 @@ export type Model = {
export type Provider = {
id: string
canonical?: string
integrationID?: string
name: string
source: "env" | "config" | "custom" | "api"
env: string[]
+78 -198
View File
@@ -1,10 +1,10 @@
import { useFilteredList } from "@opencode/ui/hooks"
import { Badge } from "@opencode/ui/badge"
import { ProviderIcon } from "@opencode/ui/provider-icon"
import { Switch } from "@opencode/ui/switch"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { TextInput } from "@opencode/ui/text-input"
import { type Component, createEffect, createMemo, For, on, onCleanup, Show } from "solid-js"
import { type Component, createEffect, For, on, onCleanup, Show } from "solid-js"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { useLanguage } from "@/runtime/i18n/language"
@@ -14,30 +14,17 @@ import { popularProviders } from "@/providers/catalog/providers"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { SettingsList } from "@/settings/list"
import { SettingsRow } from "@/settings/row"
import { OpenCodeLogo } from "@/providers/opencode-logo"
import { consoleProviderGroup, consoleProviderName } from "@/providers/catalog/console"
import { ProviderModelGroup, ProviderModelIcon } from "@/providers/models/provider-group"
import "@/settings/settings.css"
type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
type ModelGroup = { category: string; items: ModelItem[] }
type ConsoleGroup = NonNullable<ReturnType<typeof consoleProviderGroup<ModelItem["provider"]>>>
type DisplayGroup =
| { type: "provider"; group: ModelGroup }
| { type: "console"; managed: ConsoleGroup; providers: ModelGroup[] }
const CONSOLE_GROUP_KEY = "console:opencode"
const PROVIDER_ICON_SIZE = 16
export const ModelProvidersSchema = Schema.Struct({
collapsed: Persistence.record(Persistence.fallback(Schema.Boolean, () => false)),
})
export const SettingsModels: Component<{
active?: boolean
autofocus?: boolean
provider?: string
onReveal?: () => void
}> = (props) => {
export const SettingsModels: Component<{ active?: boolean; autofocus?: boolean }> = (props) => {
const language = useLanguage()
const models = useModels()
const serverSdk = useServerSDK()
@@ -60,7 +47,6 @@ export const SettingsModels: Component<{
ModelProvidersSchema,
{ collapsed: {} },
)
const sections = new Map<string, HTMLElement>()
const list = useFilteredList<ModelItem>({
items: (_filter) => models.list(),
@@ -83,88 +69,6 @@ export const SettingsModels: Component<{
return aName.localeCompare(bName)
},
})
const consoleGroup = createMemo(() =>
consoleProviderGroup([...new Map(models.list().map((item) => [item.provider.id, item.provider])).values()]),
)
const groups = createMemo<DisplayGroup[]>(() => {
const managed = consoleGroup()
if (!managed) return list.grouped.latest.map((group) => ({ type: "provider" as const, group }))
const ids = new Set(managed.providers.map((provider) => provider.id))
const providers = list.grouped.latest.filter((group) => ids.has(group.category))
if (providers.length === 0) return list.grouped.latest.map((group) => ({ type: "provider" as const, group }))
const first = list.grouped.latest.findIndex((group) => ids.has(group.category))
return list.grouped.latest.flatMap<DisplayGroup>((group, index) => {
if (!ids.has(group.category)) return [{ type: "provider" as const, group }]
if (index !== first) return []
return [{ type: "console" as const, managed, providers }]
})
})
const searching = () => list.filter().length > 0
const expanded = (key: string) => searching() || !store.collapsed[key]
const providerName = (provider: ModelItem["provider"]) =>
provider.id === "opencode" ? language.t("provider.connect.opencode.freeName") : provider.name
const enabled = createMemo(() =>
models.list().reduce((counts, item) => {
if (!models.visible({ providerID: item.provider.id, modelID: item.id })) return counts
counts.set(item.provider.id, (counts.get(item.provider.id) ?? 0) + 1)
return counts
}, new Map<string, number>()),
)
function ModelRows(props: { items: ModelItem[] }) {
return (
<SettingsList variant="catalog">
<For each={props.items}>
{(item) => {
const key = { providerID: item.provider.id, modelID: item.id }
return (
<SettingsRow title={item.name} description="">
<div>
<Switch
checked={models.visible(key)}
onChange={(checked) => models.setVisibility(key, checked)}
hideLabel
>
{item.name}
</Switch>
</div>
</SettingsRow>
)
}}
</For>
</SettingsList>
)
}
createEffect(() => {
if (!props.active || !props.provider) return
const provider = props.provider
if (list.filter()) {
list.clear()
return
}
if (!list.grouped.latest.some((group) => group.category === provider)) return
const section = sections.get(provider)
if (!section?.isConnected) return
const managed = consoleGroup()?.providers.some((item) => item.id === provider)
setStore("collapsed", CONSOLE_GROUP_KEY, Boolean(!managed))
list.grouped.latest.forEach((group) => setStore("collapsed", group.category, group.category !== provider))
requestAnimationFrame(() => {
const panel = section.closest<HTMLElement>(".settings-panel")
const header = panel?.querySelector<HTMLElement>(".settings-tab-header")
if (panel && header) {
panel.scrollTo({
top: panel.scrollTop + section.getBoundingClientRect().top - header.getBoundingClientRect().bottom - 24,
})
} else {
section.scrollIntoView({ block: "start" })
}
section
.querySelector<HTMLElement>(".provider-model-group-trigger, .settings-models-group-trigger")
?.focus({ preventScroll: true })
props.onReveal?.()
})
})
return (
<>
@@ -223,107 +127,83 @@ export const SettingsModels: Component<{
</div>
}
>
<For each={groups()}>
{(item) => (
<Show
when={item.type === "console" ? item : undefined}
fallback={
<Show when={item.type === "provider" ? item.group : undefined}>
{(group) => (
<div
ref={(element) => sections.set(group().category, element)}
class="settings-section"
data-component="settings-models-provider"
data-expanded={expanded(group().category) ? "" : undefined}
>
<h3 class="settings-models-group-header">
<button
type="button"
class="settings-models-group-trigger"
aria-expanded={expanded(group().category)}
disabled={searching()}
onClick={() => setStore("collapsed", group().category, expanded(group().category))}
>
<span class="settings-models-group-chevron">
<Icon
name="chevron-down"
size="small"
classList={{ collapsed: !expanded(group().category) }}
<For each={list.grouped.latest}>
{(group) => {
const searching = () => list.filter().length > 0
const expanded = () => searching() || !store.collapsed[group.category]
return (
<div
class="settings-section"
data-component="settings-models-provider"
data-expanded={expanded() ? "" : undefined}
>
<h3 class="settings-models-group-header">
<button
type="button"
class="settings-models-group-trigger"
aria-expanded={expanded()}
disabled={searching()}
onClick={() => setStore("collapsed", group.category, expanded())}
>
<span class="settings-models-group-chevron">
<Show
when={expanded()}
fallback={
<svg width="5" height="6" viewBox="0 0 5 6" fill="none" aria-hidden="true">
<path
d="M0.75194 5.31663C0.41861 5.51103 0 5.27063 0 4.88473V0.500754C0 0.114854 0.41861 -0.125577 0.75194 0.0688635L4.5096 2.26084C4.8404 2.45378 4.8404 2.93168 4.5096 3.12462L0.75194 5.31663Z"
fill="currentColor"
/>
</span>
<span class="settings-models-group-label">
<ProviderModelIcon
provider={group().items[0].provider}
class="settings-models-provider-icon shrink-0"
/>
<bdi class="settings-models-group-title">{providerName(group().items[0].provider)}</bdi>
</span>
</button>
</h3>
<Show when={expanded(group().category)}>
<ModelRows items={group().items} />
</svg>
}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M5.37624 6.75194C5.18184 6.41861 5.42224 6 5.80814 6H10.1921C10.578 6 10.8184 6.41861 10.624 6.75194L8.43203 10.5096C8.23909 10.8404 7.76119 10.8404 7.56825 10.5096L5.37624 6.75194Z"
fill="currentColor"
/>
</svg>
</Show>
</div>
)}
</span>
<span class="settings-models-group-label">
<ProviderIcon
id={group.category}
width={PROVIDER_ICON_SIZE}
height={PROVIDER_ICON_SIZE}
class="settings-models-provider-icon shrink-0"
/>
<span class="settings-models-group-title">{group.items[0].provider.name}</span>
</span>
</button>
</h3>
<Show when={expanded()}>
<SettingsList variant="catalog">
<For each={group.items}>
{(item) => {
const key = { providerID: item.provider.id, modelID: item.id }
return (
<SettingsRow title={item.name} description="">
<div>
<Switch
checked={models.visible(key)}
onChange={(checked) => {
models.setVisibility(key, checked)
}}
hideLabel
>
{item.name}
</Switch>
</div>
</SettingsRow>
)
}}
</For>
</SettingsList>
</Show>
}
>
{(console) => (
<div
class="settings-section settings-models-console"
data-component="settings-models-console"
data-expanded={expanded(CONSOLE_GROUP_KEY) ? "" : undefined}
>
<h3 class="settings-models-group-header">
<button
type="button"
class="settings-models-group-trigger"
aria-expanded={expanded(CONSOLE_GROUP_KEY)}
disabled={searching()}
onClick={() => setStore("collapsed", CONSOLE_GROUP_KEY, expanded(CONSOLE_GROUP_KEY))}
>
<span class="settings-models-group-chevron">
<Icon
name="chevron-down"
size="small"
classList={{ collapsed: !expanded(CONSOLE_GROUP_KEY) }}
/>
</span>
<span class="settings-models-group-label">
<OpenCodeLogo class="settings-models-provider-icon size-4 shrink-0" />
<span class="settings-models-group-title">
{language.t("provider.connect.opencode.name")}
</span>
<Badge>{console().managed.workspace}</Badge>
</span>
</button>
</h3>
<Show when={expanded(CONSOLE_GROUP_KEY)}>
<div class="provider-model-groups settings-models-console-groups">
<For each={console().providers}>
{(group) => {
const count = () => enabled().get(group.category) ?? 0
return (
<ProviderModelGroup
ref={(element) => sections.set(group.category, element)}
provider={group.items[0].provider}
name={consoleProviderName(console().managed, group.items[0].provider.name)}
expanded={expanded(group.category)}
disabled={searching()}
detail={language.plural("settings.models.enabled", count(), { count: count() })}
onExpandedChange={(value) => setStore("collapsed", group.category, !value)}
>
<ModelRows items={group.items} />
</ProviderModelGroup>
)
}}
</For>
</div>
</Show>
</div>
)}
</Show>
)}
</div>
)
}}
</For>
</Show>
</Show>
+39 -218
View File
@@ -1,18 +1,13 @@
import { Button } from "@opencode/ui/button"
import { Badge } from "@opencode/ui/badge"
import { useDialog } from "@opencode/ui/context/dialog"
import { Icon } from "@opencode/ui/icon"
import { ProviderIcon } from "@opencode/ui/provider-icon"
import { OpenCodeLogo } from "@/providers/opencode-logo"
import { showToast } from "@/shell/notifications/toast"
import { popularProviders, useProviders } from "@/providers/catalog/providers"
import { consoleProviderGroup } from "@/providers/catalog/console"
import { useIntegrations } from "@/providers/catalog/integrations"
import { createEffect, createMemo, type Component, For, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { createMemo, type Component, For, Show } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { useServerSDK } from "@/runtime/server/client"
import { useData } from "@/runtime/server/current"
import { DialogConnectProvider, useProviderConnectController } from "@/providers/connect/dialog"
import { SettingsList } from "@/settings/list"
import "@/settings/settings.css"
@@ -35,103 +30,31 @@ const PROVIDER_ICON_SIZE = 16
export const SettingsProviders: Component<{
directory: string | undefined
onSelectProvider?: (providerID: string) => void
onBack?: () => void
}> = (props) => {
const dialog = useDialog()
const language = useLanguage()
const serverSdk = useServerSDK()
const data = useData()
const providers = useProviders(() => props.directory)
const integrations = useIntegrations(() => props.directory)
const providerConnect = useProviderConnectController()
const [state, setState] = createStore({
disconnecting: {} as Record<string, "removing" | "removed" | "absent" | undefined>,
consoleExpanded: false,
connecting: false,
})
const updateDisconnecting = (ids: string[], status: "removing" | "removed" | "absent" | undefined) =>
setState("disconnecting", (current) => ({
...current,
...Object.fromEntries(ids.map((id) => [id, status])),
}))
const providerConnect = useProviderConnectController({ onBack: props.onBack })
const integration = (providerID: string) => integrations.list().find((item) => item.id === providerID)
const connect = (provider?: string) => {
setState("connecting", true)
providerConnect.select(provider)
void dialog.show(
() => (
<DialogConnectProvider
directory={props.directory}
defaultLocation={props.directory === undefined}
controller={providerConnect}
onConnected={(providerID) => {
if (providerID === "opencode") {
setState("disconnecting", reconcile({}))
return
}
setState("disconnecting", providerID, undefined)
}}
/>
),
() => {
setState("connecting", false)
const location = props.directory ? { directory: props.directory } : undefined
data.location.integration.invalidate(location)
data.location.provider.invalidate(location)
data.location.model.invalidate(location)
void Promise.all([
data.location.integration.sync(location),
data.location.provider.sync(location),
data.location.model.sync(location),
]).catch(() => undefined)
},
)
void dialog.show(() => <DialogConnectProvider directory={props.directory} controller={providerConnect} />)
}
const available = createMemo(() => {
const connected = providers.connected()
const managedConsole = consoleProviderGroup(connected)
const consoleConnected = integrations
.list()
.find((item) => item.id === "opencode")
?.connections.some((connection) => connection.type === "credential" || connection.type === "env")
const consoleTransition =
state.connecting && providerConnect.selected() === "opencode" && consoleConnected && managedConsole === undefined
return connected
const connected = createMemo(() => {
return providers
.connected()
.filter(
(provider) =>
provider.id !== "opencode" ||
(!consoleTransition &&
(managedConsole !== undefined ||
consoleConnected ||
Object.values(provider.models).some((model) => model.cost.input > 0))),
provider.id !== "opencode" || Object.values(provider.models).some((model) => model.cost.input > 0),
)
.toSorted((a, b) => Number(b.id === "opencode-go") - Number(a.id === "opencode-go"))
})
createEffect(() => {
const ids = new Set(available().map((item) => item.id))
Object.entries(state.disconnecting).forEach(([id, status]) => {
if ((status === "removing" || status === "removed") && !ids.has(id)) {
setState("disconnecting", id, "absent")
return
}
if (status === "absent" && ids.has(id)) setState("disconnecting", id, undefined)
})
})
const connected = createMemo(() => available().filter((item) => !state.disconnecting[item.id]))
const consoleGroup = createMemo(() => consoleProviderGroup(available()))
const displayed = createMemo(() => {
const group = consoleGroup()
if (!group) return connected()
const grouped = new Set(group.providers.filter((item) => item.id !== group.root.id).map((item) => item.id))
return connected().filter((item) => !grouped.has(item.id))
})
const popular = createMemo(() => {
const connectedIDs = new Set(connected().map((p) => p.id))
const items = providers
@@ -175,10 +98,6 @@ export const SettingsProviders: Component<{
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
const disconnect = async (providerID: string, name: string) => {
if (state.disconnecting[providerID]) return
const group = consoleGroup()
const ids = group?.root.id === providerID ? group.providers.map((provider) => provider.id) : [providerID]
updateDisconnecting(ids, "removing")
const location = props.directory ? { directory: props.directory } : undefined
await serverSdk.api.integration
.get({ integrationID: providerID, location })
@@ -188,7 +107,6 @@ export const SettingsProviders: Component<{
await Promise.all(
credentials.map((credential) => serverSdk.api.credential.remove({ credentialID: credential.id })),
)
updateDisconnecting(ids, "removed")
showToast({
variant: "success",
icon: "circle-check",
@@ -197,7 +115,6 @@ export const SettingsProviders: Component<{
})
})
.catch((err: unknown) => {
updateDisconnecting(ids, undefined)
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
@@ -219,127 +136,38 @@ export const SettingsProviders: Component<{
<h3 class="settings-section-title">{language.t("settings.providers.section.connected")}</h3>
<SettingsList variant="catalog">
<Show
when={displayed().length > 0}
when={connected().length > 0}
fallback={<div class="settings-provider-empty">{language.t("settings.providers.connected.empty")}</div>}
>
<For each={displayed()}>
{(item) => {
const console = () => (consoleGroup()?.root.id === item.id ? consoleGroup() : undefined)
return (
<For each={connected()}>
{(item) => (
<div class="settings-provider-row group">
<div class="settings-provider-lead">
<ProviderIcon
id={item.id}
width={PROVIDER_ICON_SIZE}
height={PROVIDER_ICON_SIZE}
class="settings-provider-icon shrink-0"
/>
<div class="settings-provider-main">
<span class="settings-provider-name truncate">{item.name}</span>
<Badge>{type(item)}</Badge>
</div>
</div>
<Show
when={console()}
when={canDisconnect(item)}
fallback={
<div class="settings-provider-row group">
<div class="settings-provider-lead">
<Show
when={item.id === "opencode"}
fallback={
<ProviderIcon
id={item.id}
width={PROVIDER_ICON_SIZE}
height={PROVIDER_ICON_SIZE}
class="settings-provider-icon shrink-0"
/>
}
>
<OpenCodeLogo class="settings-provider-icon size-4 shrink-0" />
</Show>
<div class="settings-provider-main">
<span class="settings-provider-name truncate">{item.name}</span>
<Badge>{type(item)}</Badge>
</div>
</div>
<Show
when={canDisconnect(item)}
fallback={
<span class="settings-provider-env-hint">
{language.t("settings.providers.connected.environmentDescription")}
</span>
}
>
<Button
size="normal"
variant="ghost-muted"
onClick={() => void disconnect(item.id, item.name)}
>
{language.t("common.disconnect")}
</Button>
</Show>
</div>
<span class="settings-provider-env-hint">
{language.t("settings.providers.connected.environmentDescription")}
</span>
}
>
{(group) => (
<div class="settings-provider-console group">
<div class="settings-provider-console-header">
<div class="settings-provider-lead">
<OpenCodeLogo class="settings-provider-icon size-4 shrink-0" />
<div class="settings-provider-console-summary">
<div class="settings-provider-main">
<span class="settings-provider-name truncate">
{language.t("provider.connect.opencode.name")}
</span>
<Badge>{group().workspace}</Badge>
</div>
<Show when={group().providers.length > 1}>
<button
type="button"
class="settings-provider-console-toggle"
aria-expanded={state.consoleExpanded}
onClick={() => setState("consoleExpanded", (value) => !value)}
>
<span>
{language.plural(
"settings.providers.console.available",
group().providers.length,
{ count: group().providers.length },
)}
</span>
<Icon
name="chevron-right"
size="small"
classList={{
"settings-provider-console-chevron": true,
open: state.consoleExpanded,
}}
/>
</button>
</Show>
</div>
</div>
<Button
size="normal"
variant="ghost-muted"
onClick={() => void disconnect(item.id, language.t("provider.connect.opencode.name"))}
>
{language.t("common.disconnect")}
</Button>
</div>
<Show when={state.consoleExpanded}>
<div class="settings-provider-console-list">
<div class="settings-provider-console-separator" aria-hidden="true" />
<For each={group().providers}>
{(provider) => (
<button
type="button"
class="settings-provider-console-item"
onClick={() => props.onSelectProvider?.(provider.id)}
>
<span>{provider.name.slice(group().prefix.length)}</span>
<Icon
name="chevron-right"
size="small"
class="settings-provider-console-item-chevron"
/>
</button>
)}
</For>
</div>
</Show>
</div>
)}
<Button size="normal" variant="ghost-muted" onClick={() => void disconnect(item.id, item.name)}>
{language.t("common.disconnect")}
</Button>
</Show>
)
}}
</div>
)}
</For>
</Show>
</SettingsList>
@@ -352,19 +180,12 @@ export const SettingsProviders: Component<{
{(item) => (
<div class="settings-provider-row">
<div class="settings-provider-lead">
<Show
when={item.id === "opencode"}
fallback={
<ProviderIcon
id={item.id}
width={PROVIDER_ICON_SIZE}
height={PROVIDER_ICON_SIZE}
class="settings-provider-icon shrink-0"
/>
}
>
<OpenCodeLogo class="settings-provider-icon size-4 shrink-0" />
</Show>
<ProviderIcon
id={item.id}
width={PROVIDER_ICON_SIZE}
height={PROVIDER_ICON_SIZE}
class="settings-provider-icon shrink-0"
/>
<div class="settings-provider-copy">
<div class="settings-provider-main">
<span class="settings-provider-name">{item.name}</span>
-283
View File
@@ -677,134 +677,6 @@
border-bottom: none;
}
[data-component="connected-providers-section"] .settings-provider-row {
min-height: 48px;
padding-block: 10px;
}
.settings-provider-console {
border-bottom: 0.5px solid var(--v2-border-border-base);
}
.settings-provider-console:last-child {
border-bottom: none;
}
.settings-provider-console-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 48px;
padding-block: 10px;
}
.settings-provider-console-summary {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
gap: 4px;
}
.settings-provider-console-toggle {
display: flex;
height: 28px;
align-items: center;
gap: 2px;
padding-inline: 4px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--v2-text-text-muted);
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-compact);
cursor: pointer;
}
@media (hover: hover) {
.settings-provider-console-toggle:hover {
background: var(--v2-overlay-simple-overlay-hover);
color: var(--v2-text-text-base);
}
}
.settings-provider-console-chevron {
color: var(--v2-icon-icon-muted);
transition: transform 150ms ease-out;
}
.settings-provider-console-chevron.open {
transform: rotate(90deg);
}
.settings-provider-console-list {
display: flex;
flex-direction: column;
gap: 2px;
padding-bottom: 10px;
}
.settings-provider-console-separator {
height: 0.5px;
margin-bottom: 6px;
background: var(--v2-border-border-base);
}
.settings-provider-console-item {
display: flex;
width: 100%;
min-height: 24px;
align-items: center;
gap: 2px;
padding-inline: 10px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--v2-text-text-base);
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-compact);
text-align: start;
cursor: pointer;
}
.settings-provider-console-item-chevron {
color: var(--v2-icon-icon-base);
opacity: 0;
}
@media (hover: hover) {
.settings-provider-console-item:hover {
background: var(--v2-overlay-simple-overlay-hover);
}
.settings-provider-console-item:hover .settings-provider-console-item-chevron {
opacity: 1;
}
}
.settings-provider-console-item:focus-visible {
background: var(--v2-overlay-simple-overlay-hover);
}
.settings-provider-console-item:focus-visible .settings-provider-console-item-chevron {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.settings-provider-console-chevron {
transition: none;
}
}
@container settings-panel (max-width: 520px) {
.settings-provider-console-header {
flex-wrap: wrap;
}
}
@media (min-width: 640px) {
.settings-provider-row {
flex-wrap: nowrap;
@@ -1066,18 +938,6 @@
color: var(--v2-icon-icon-muted);
}
.settings-models-group-chevron svg {
transition: transform 150ms ease;
}
.settings-models-group-chevron svg.collapsed {
transform: rotate(-90deg);
}
:dir(rtl) .settings-models-group-chevron svg.collapsed {
transform: rotate(90deg);
}
.settings-models-group-label {
display: flex;
align-items: center;
@@ -1090,149 +950,6 @@
line-height: var(--line-height-compact);
}
.provider-model-groups {
display: flex;
min-width: 0;
flex-direction: column;
gap: 12px;
}
.settings-models-console-groups {
margin-inline-start: 14px;
padding-inline-start: 16px;
border-inline-start: 0.5px solid var(--v2-border-border-base);
}
.provider-model-group {
min-width: 0;
overflow: hidden;
border-radius: 8px;
background-color: var(--v2-background-bg-layer-01);
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted);
}
.provider-model-group-header {
min-width: 0;
}
.provider-model-group-trigger {
display: flex;
width: 100%;
min-height: 48px;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 16px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--v2-text-text-base);
text-align: start;
}
.provider-model-group-trigger:focus-visible {
outline: 2px solid var(--v2-border-border-focus);
outline-offset: -2px;
}
@media (hover: hover) {
.provider-model-group-trigger:not(:disabled):hover {
background-color: var(--v2-background-bg-layer-02);
}
}
.provider-model-group-trigger:disabled {
cursor: default;
}
.provider-model-group-label {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
}
.provider-model-group-title {
min-width: 0;
overflow: hidden;
font-size: 13px;
font-weight: 530;
line-height: var(--line-height-compact);
text-overflow: ellipsis;
white-space: nowrap;
}
.provider-model-group-chevron {
flex-shrink: 0;
margin-inline-start: -2px;
color: var(--v2-icon-icon-muted);
transition: transform 150ms ease;
}
.provider-model-group-chevron.collapsed {
transform: rotate(-90deg);
}
:dir(rtl) .provider-model-group-chevron.collapsed {
transform: rotate(90deg);
}
.provider-model-group-detail {
flex-shrink: 0;
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-compact);
color: var(--v2-text-text-muted);
font-variant-numeric: tabular-nums;
}
.provider-model-group-models {
padding-inline: 16px;
}
.provider-model-group-models > [data-component="settings-list"] {
padding-inline: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
border-top: 0.5px solid var(--v2-border-border-base);
}
.provider-model-group:has(.provider-model-group-trigger:focus-visible)
.provider-model-group-models
> [data-component="settings-list"] {
border-top-color: transparent;
}
.provider-model-group:has(.provider-model-group-trigger:focus-visible) {
outline: 0.5px solid var(--v2-border-border-muted);
outline-offset: -0.5px;
}
@media (hover: hover) {
.provider-model-group:has(.provider-model-group-trigger:hover) {
outline: 0.5px solid var(--v2-border-border-muted);
outline-offset: -0.5px;
}
.provider-model-group:has(.provider-model-group-trigger:hover)
.provider-model-group-models
> [data-component="settings-list"] {
border-top-color: transparent;
}
}
.provider-model-groups--dialog {
padding-block: 2px;
}
@media (prefers-reduced-motion: reduce) {
.settings-models-group-chevron svg,
.provider-model-group-chevron {
transition: none;
}
}
.settings-models [data-component="provider-icon"] {
color: var(--v2-icon-icon-base);
}
+6 -33
View File
@@ -187,10 +187,7 @@ function RootSettings() {
const tabs = useTabs()
const servers = useServerCollectionController()
const inventory = useSettingsServers()
const [state, setState] = createStore({
worktreeFilterReset: 0,
modelProvider: undefined as string | undefined,
})
const [state, setState] = createStore({ worktreeFilterReset: 0 })
const list = servers.collection.items
const singleEntry = createMemo(() => (inventory().length === 1 ? inventory()[0] : undefined))
const single = createMemo(() => singleEntry()?.connection)
@@ -312,21 +309,10 @@ function RootSettings() {
/>
</Tabs.Content>
<Tabs.Content value="providers" class="settings-panel">
<SettingsProviders
directory={undefined}
onSelectProvider={(providerID) => {
setState("modelProvider", providerID)
surface.select("models")
}}
/>
<SettingsProviders directory={undefined} onBack={() => surface.select("providers")} />
</Tabs.Content>
<Tabs.Content value="models" class="settings-panel">
<SettingsModels
active={surface.view().tab === "models"}
autofocus={!surface.search.state.selected}
provider={state.modelProvider}
onReveal={() => setState("modelProvider", undefined)}
/>
<SettingsModels active={surface.view().tab === "models"} autofocus={!surface.search.state.selected} />
</Tabs.Content>
<Tabs.Content value="extensions" class="settings-panel">
<SettingsExtensions subtab={surface.view().subtab} onSubtab={(value) => surface.subtab(value)} />
@@ -350,10 +336,7 @@ function ServerSettings(props: { entry: SettingsServer }) {
const surface = useSettingsSurface()
const activeDirectory = useSettingsDirectory(() => props.entry.connection)
const prefetchWorkspaces = useWorkspacesPrefetch(() => props.entry.connection)
const [state, setState] = createStore({
worktreeFilterReset: 0,
modelProvider: undefined as string | undefined,
})
const [state, setState] = createStore({ worktreeFilterReset: 0 })
const groups = createMemo<SettingsNavGroup[]>(() => [
{
items: nestedServerTabs.map((item) => ({
@@ -408,20 +391,10 @@ function ServerSettings(props: { entry: SettingsServer }) {
/>
</Tabs.Content>
<Tabs.Content value="providers" class="settings-panel">
<SettingsProviders
directory={undefined}
onSelectProvider={(providerID) => {
setState("modelProvider", providerID)
surface.select("models")
}}
/>
<SettingsProviders directory={undefined} onBack={() => surface.select("providers")} />
</Tabs.Content>
<Tabs.Content value="models" class="settings-panel">
<SettingsModels
active={surface.view().tab === "models"}
provider={state.modelProvider}
onReveal={() => setState("modelProvider", undefined)}
/>
<SettingsModels active={surface.view().tab === "models"} />
</Tabs.Content>
<Tabs.Content value="extensions" class="settings-panel">
<SettingsExtensions subtab={surface.view().subtab} onSubtab={(value) => surface.subtab(value)} />
+5 -5
View File
@@ -9,7 +9,7 @@ import { Keymap } from "../../tui/src/context/keymap"
export function ErrorOverlay(props: { component: string; error: unknown; onClose: () => void }) {
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const theme = useTheme().surface("dialog")
const theme = useTheme("elevated")
const focus = renderer.currentFocusedRenderable
onCleanup(Keymap.use().mode.push("modal"))
Keymap.createLayer(() => ({
@@ -36,17 +36,17 @@ export function ErrorOverlay(props: { component: string; error: unknown; onClose
<Dialog centered onClose={props.onClose}>
<box maxHeight={Math.max(1, dimensions().height - 3)} paddingX={2} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between" flexShrink={0}>
<text fg={theme.text.feedback.error.base} attributes={TextAttributes.BOLD}>
<text fg={theme.text.feedback.error.default} attributes={TextAttributes.BOLD}>
Error while hot reloading
</text>
<text fg={theme.text.muted} onMouseUp={props.onClose}>
<text fg={theme.text.subdued} onMouseUp={props.onClose}>
esc
</text>
</box>
<text maxHeight={Math.max(1, dimensions().height - 9)} fg={theme.text.base}>
<text maxHeight={Math.max(1, dimensions().height - 9)} fg={theme.text.default}>
{props.error instanceof Error ? props.error.message : String(props.error)}
</text>
<text flexShrink={0} fg={theme.text.muted}>
<text flexShrink={0} fg={theme.text.subdued}>
{props.component} · Fix the component and save to retry.
</text>
</box>
+61 -65
View File
@@ -8,9 +8,7 @@ export type LocationPublicRef = { directory: string }
export type ModelRef = { id: string; providerID: string; variant?: string }
export type ProviderCompaction = { type: "summary" } | { type: "native" }
export type ProviderTransport = "http" | "websocket"
export type ProviderSettings = { [x: string]: any }
export type AgentColor = string
@@ -220,8 +218,19 @@ export type ModelReasoningField = "reasoning" | "reasoning_content" | "reasoning
export type ModelMaxTokensField = "max_completion_tokens" | "max_tokens"
export type ProviderCompaction = { mode: "local" } | { mode: "provider"; threshold?: number }
export type ProviderTransport = "http" | "websocket"
export type ModelCapabilities = { tools: boolean; input: Array<string>; output: Array<string> }
export type ModelVariant = {
id: string
settings?: { [x: string]: any }
headers?: { [x: string]: string }
body?: { [x: string]: any }
}
export type MoneyUSDPerMillionTokens = number
export type GenerateTextResponse = { data: { text: string } }
@@ -456,19 +465,11 @@ export type V2EventServerConnected = {
data: {}
}
export type ProviderSettings = {
timeout?: number | false
chunkTimeout?: number
compaction?: ProviderCompaction
transport?: ProviderTransport
} & { [x: string]: any }
export type ConfigProviderSettings = {
timeout?: number | false
chunkTimeout?: number
compaction?: ProviderCompaction
transport?: ProviderTransport
} & { [x: string]: JsonValue | null }
export type ProviderRequest = {
settings: ProviderSettings
headers: { [x: string]: string }
body: { [x: string]: any }
}
export type PermissionRule = { action: string; resource: string; effect: PermissionEffect }
@@ -1442,6 +1443,20 @@ export type ModelCompatibility = {
supportsPromptCacheKey?: boolean
}
export type ProviderInfo = {
id: string
canonical?: string
integrationID?: string
name: string
activation: "auto" | "enabled" | "disabled"
package: string
compaction?: ProviderCompaction
transport?: ProviderTransport
settings?: { [x: string]: any }
headers?: { [x: string]: string }
body?: { [x: string]: any }
}
export type ModelCost = {
tier?: { type: "context"; size: number }
input: MoneyUSDPerMillionTokens
@@ -1656,31 +1671,6 @@ export type SessionInboxMove = {
payload: SessionInboxMovePayload
}
export type ProviderRequest = {
settings: ProviderSettings
headers: { [x: string]: string }
body: { [x: string]: any }
}
export type ModelVariant = {
id: string
settings?: ProviderSettings
headers?: { [x: string]: string }
body?: { [x: string]: any }
}
export type ProviderInfo = {
id: string
canonical?: string
integrationID?: string
name: string
activation: "auto" | "enabled" | "disabled"
package: string
settings?: ProviderSettings
headers?: { [x: string]: string }
body?: { [x: string]: any }
}
export type PermissionRuleset = Array<PermissionRule>
export type SessionRevertStaged = {
@@ -1864,6 +1854,29 @@ export type FormField =
export type FormState = { status: "pending" } | { status: "answered"; answer: FormAnswer } | { status: "cancelled" }
export type ModelInfo = {
id: string
modelID: string
providerID: string
canonical?: string
family?: string
name: string
compatibility?: ModelCompatibility
package?: string
compaction?: ProviderCompaction
transport?: ProviderTransport
settings?: { [x: string]: any }
headers?: { [x: string]: string }
body?: { [x: string]: any }
capabilities: ModelCapabilities
variants: Array<ModelVariant>
time: { released: number }
cost: Array<ModelCost>
status: "alpha" | "beta" | "deprecated" | "active"
enabled: boolean
limit: { context: number; input?: number; output: number }
}
export type FormField1 =
| FormStringField1
| FormNumberField1
@@ -1889,27 +1902,6 @@ export type ReferenceInfo = {
source: ReferenceSource
}
export type ModelInfo = {
id: string
modelID: string
providerID: string
canonical?: string
family?: string
name: string
compatibility?: ModelCompatibility
package?: string
settings?: ProviderSettings
headers?: { [x: string]: string }
body?: { [x: string]: any }
capabilities: ModelCapabilities
variants: Array<ModelVariant>
time: { released: number }
cost: Array<ModelCost>
status: "alpha" | "beta" | "deprecated" | "active"
enabled: boolean
limit: { context: number; input?: number; output: number }
}
export type AgentInfo = {
id: string
name: string
@@ -2093,27 +2085,31 @@ export type ConfigEntry =
warming?: boolean | { prompt?: string; interval?: string; duration?: string }
providers?: {
[x: string]: {
compaction?: ProviderCompaction
transport?: ProviderTransport
canonical?: string
name?: string
env?: Array<string>
package?: string
settings?: ConfigProviderSettings
settings?: { [x: string]: JsonValue }
headers?: { [x: string]: string }
body?: { [x: string]: JsonValue }
models?: {
[x: string]: {
compaction?: ProviderCompaction
transport?: ProviderTransport
modelID?: string
family?: string
name?: string
compatibility?: ModelCompatibility
package?: string
settings?: ConfigProviderSettings
settings?: { [x: string]: JsonValue }
headers?: { [x: string]: string }
body?: { [x: string]: JsonValue }
capabilities?: ModelCapabilities
variants?: Array<{
id: string
settings?: ConfigProviderSettings
settings?: { [x: string]: JsonValue }
headers?: { [x: string]: string }
body?: { [x: string]: JsonValue }
}>
+1 -6
View File
@@ -128,8 +128,6 @@ function prepareOptions(model: Info, pkg: string) {
const customFetch = options.fetch
const chunkTimeout = options.chunkTimeout
delete options.chunkTimeout
delete options.compaction
delete options.transport
options.fetch = async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
const opts = { ...(init ?? {}) }
const signals = [
@@ -390,10 +388,7 @@ function requestSettings(settings: Readonly<Record<string, unknown>> | undefined
if (settings === undefined) return undefined
const result = Object.fromEntries(
Object.entries(settings).filter(
([key]) =>
!["apiKey", "authToken", "baseURL", "chunkTimeout", "compaction", "fetch", "timeout", "transport"].includes(
key,
),
([key]) => !["apiKey", "authToken", "baseURL", "chunkTimeout", "fetch", "timeout"].includes(key),
),
)
return Object.keys(result).length === 0 ? undefined : result
@@ -68,6 +68,8 @@ export const Plugin = define({
if (item.canonical !== undefined) provider.canonical = item.canonical
if (item.name !== undefined) provider.name = item.name
if (item.package !== undefined) provider.package = item.package
if (item.compaction !== undefined) provider.compaction = { ...item.compaction }
if (item.transport !== undefined) provider.transport = item.transport
if (item.settings !== undefined) provider.settings = Provider.mergeOverlay(provider.settings, item.settings)
if (item.headers !== undefined) provider.headers = Provider.mergeHeaders(provider.headers, item.headers)
if (item.body !== undefined) provider.body = Provider.mergeOverlay(provider.body, item.body)
@@ -114,6 +116,8 @@ export const Plugin = define({
if (config.compatibility !== undefined)
model.compatibility = { ...model.compatibility, ...config.compatibility }
if (config.package !== undefined) model.package = config.package
if (config.compaction !== undefined) model.compaction = { ...config.compaction }
if (config.transport !== undefined) model.transport = config.transport
if (config.settings !== undefined) model.settings = Provider.mergeOverlay(model.settings, config.settings)
if (config.headers !== undefined) model.headers = Provider.mergeHeaders(model.headers, config.headers)
if (config.body !== undefined) model.body = Provider.mergeOverlay(model.body, config.body)
+14 -61
View File
@@ -36,7 +36,6 @@ export type Status = Background["status"]
const decodeBackground = Schema.decodeUnknownResult(Background)
const backgroundPrefix = "job.background/"
const COMPLETED_LIMIT = 25
export type Info = {
id: string
@@ -58,7 +57,6 @@ type Active = {
scope: Scope.Closeable
blockingSessions: Map<SessionSchema.ID, number>
isBackgrounded: boolean
consumed: boolean
recovery?: Recovery
}
@@ -71,7 +69,6 @@ type FinishResult = {
info?: Info
done?: Deferred.Deferred<Info>
scope?: Scope.Closeable
generation?: Scope.Closeable
}
type BackgroundResult = {
@@ -84,12 +81,11 @@ type StartResult = { info: Info } | { info: Info; scope: Scope.Closeable }
type BlockWait = {
done: Deferred.Deferred<Info>
backgrounded: Deferred.Deferred<Info>
generation: Scope.Closeable
}
type BlockStart =
| { type: "missing" }
| { type: "finished"; info: Info; generation: Scope.Closeable }
| { type: "finished"; info: Info }
| { type: "backgrounded"; info: Info }
| { type: "wait"; wait: BlockWait }
@@ -172,9 +168,6 @@ function decrementSession(input: Map<SessionSchema.ID, number>, sessionID: Sessi
/**
* Makes one scoped, process-local registry. Explicitly recoverable background
* work also owns a durable notification marker until its notification is admitted.
* Unconsumed results survive the start-to-wait handoff. Foreground block/cancel
* and non-recoverable wait results enter a 25-entry consumed history. Recoverable
* wait results stay available for background registration and acknowledgment.
*/
export const make = Effect.gen(function* () {
const kv = yield* KV.Service
@@ -183,19 +176,6 @@ export const make = Effect.gen(function* () {
scope: yield* Scope.Scope,
}
const consume = (id: string, generation: Scope.Closeable) =>
SynchronizedRef.update(state.jobs, (jobs) => {
const job = jobs.get(id)
if (!job || job.scope !== generation || job.info.status === "running" || job.consumed) return jobs
const next = new Map(jobs)
// Order history by first consumption, not by start time or subsequent reads.
next.delete(id)
next.set(id, { ...job, consumed: true })
const completed = [...next].filter(([, job]) => job.consumed && !job.info.notificationID)
for (const [id] of completed.slice(0, -COMPLETED_LIMIT)) next.delete(id)
return next
})
const persistBackground = Effect.fnUntraced(function* (job: Active) {
if (!job.recovery || !job.info.notificationID) return
yield* kv.set(`${backgroundPrefix}${job.info.notificationID}`, {
@@ -280,7 +260,6 @@ export const make = Effect.gen(function* () {
scope,
blockingSessions: new Map<SessionSchema.ID, number>(),
isBackgrounded: false,
consumed: false,
recovery: input.recovery,
}
return [{ info: snapshot(job), scope }, new Map(jobs).set(id, job)]
@@ -301,19 +280,12 @@ export const make = Effect.gen(function* () {
const wait: Interface["wait"] = Effect.fn("Job.wait")(function* (input) {
const job = (yield* SynchronizedRef.get(state.jobs)).get(input.id)
if (!job) return { timedOut: false }
return yield* Effect.gen(function* () {
if (job.info.status !== "running") return { info: snapshot(job), timedOut: false }
if (input.timeout === undefined) return { info: yield* Deferred.await(job.done), timedOut: false }
if (input.timeout <= 0) return { info: snapshot(job), timedOut: true }
const info = yield* Deferred.await(job.done).pipe(Effect.timeoutOption(input.timeout))
if (info._tag === "Some") return { info: info.value, timedOut: false }
return { info: snapshot(job), timedOut: true }
}).pipe(
// Recoverable wait -> background is a supported handoff, even after failure.
Effect.tap((result) =>
result.info.status === "running" || job.recovery ? Effect.void : consume(input.id, job.scope),
),
)
if (job.info.status !== "running") return { info: snapshot(job), timedOut: false }
if (input.timeout === undefined) return { info: yield* Deferred.await(job.done), timedOut: false }
if (input.timeout <= 0) return { info: snapshot(job), timedOut: true }
const info = yield* Deferred.await(job.done).pipe(Effect.timeoutOption(input.timeout))
if (info._tag === "Some") return { info: info.value, timedOut: false }
return { info: snapshot(job), timedOut: true }
})
const removeBlock = Effect.fnUntraced(function* (input: BlockInput) {
@@ -331,10 +303,10 @@ export const make = Effect.gen(function* () {
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [BlockStart, Map<string, Active>] => {
const job = jobs.get(input.id)
if (!job) return [{ type: "missing" }, jobs]
if (job.info.status !== "running") return [{ type: "finished", info: snapshot(job), generation: job.scope }, jobs]
if (job.info.status !== "running") return [{ type: "finished", info: snapshot(job) }, jobs]
if (job.isBackgrounded) return [{ type: "backgrounded", info: snapshot(job) }, jobs]
return [
{ type: "wait", wait: { done: job.done, backgrounded: job.backgrounded, generation: job.scope } },
{ type: "wait", wait: { done: job.done, backgrounded: job.backgrounded } },
new Map(jobs).set(input.id, {
...job,
blockingSessions: incrementSession(job.blockingSessions, input.sessionID),
@@ -342,18 +314,12 @@ export const make = Effect.gen(function* () {
]
})
if (result.type === "missing") return undefined
if (result.type === "finished") {
yield* consume(input.id, result.generation)
return { type: "finished", info: result.info }
}
if (result.type === "finished") return { type: "finished", info: result.info }
if (result.type === "backgrounded") return { type: "backgrounded", info: result.info }
return yield* Effect.raceFirst(
Deferred.await(result.wait.done).pipe(Effect.map((info) => ({ type: "finished" as const, info }))),
Deferred.await(result.wait.backgrounded).pipe(Effect.map((info) => ({ type: "backgrounded" as const, info }))),
).pipe(
Effect.tap((outcome) => (outcome.type === "finished" ? consume(input.id, result.wait.generation) : Effect.void)),
Effect.ensuring(removeBlock(input)),
)
).pipe(Effect.ensuring(removeBlock(input)))
})
const markBackground = Effect.fnUntraced(function* (job: Active) {
@@ -417,7 +383,7 @@ export const make = Effect.gen(function* () {
Effect.fnUntraced(function* (jobs): Effect.fn.Return<readonly [FinishResult, Map<string, Active>]> {
const job = jobs.get(id)
if (!job) return [{}, jobs]
if (job.info.status !== "running") return [{ info: snapshot(job), generation: job.scope }, jobs]
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
const next = {
...job,
blockingSessions: new Map<SessionSchema.ID, number>(),
@@ -428,15 +394,11 @@ export const make = Effect.gen(function* () {
},
}
yield* persistBackground(next)
return [
{ info: snapshot(next), done: job.done, scope: job.scope, generation: job.scope },
new Map(jobs).set(id, next),
]
return [{ info: snapshot(next), done: job.done, scope: job.scope }, new Map(jobs).set(id, next)]
}),
)
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info)
if (result.scope) yield* Scope.close(result.scope, Exit.void)
if (result.generation) yield* consume(id, result.generation)
return result.info
})
@@ -452,16 +414,7 @@ export const make = Effect.gen(function* () {
}).pipe(Effect.withSpan("Job.pendingBackground"))
const completeBackground: Interface["completeBackground"] = Effect.fn("Job.completeBackground")((notificationID) =>
SynchronizedRef.updateEffect(state.jobs, (jobs) =>
Effect.gen(function* () {
yield* kv.remove(`${backgroundPrefix}${notificationID}`)
const entry = [...jobs].find(([, job]) => job.info.notificationID === notificationID)
if (!entry || entry[1].info.status === "running") return jobs
const next = new Map(jobs)
next.delete(entry[0])
return next
}),
),
kv.remove(`${backgroundPrefix}${notificationID}`),
)
return Service.of({
+5 -9
View File
@@ -118,9 +118,9 @@ export interface Resolved {
/** Catalog token limits used by Core for context management. */
readonly limit: Info["limit"]
/** Model policy overrides the provider policy; omitted means local compaction. */
readonly compaction?: Provider.Compaction
readonly compaction?: Info["compaction"]
/** Model transport overrides the provider transport; omitted means HTTP. */
readonly transport?: Provider.Transport
readonly transport?: Info["transport"]
}
export interface Interface {
@@ -178,11 +178,7 @@ export const fromCatalogModel = (
Effect.flatMap((resolved) => validateProviderVariables(model, resolved)),
Effect.flatMap((resolved) => {
// Reject provider compaction policies up front so the misconfiguration surfaces before any step runs.
if (
model.settings?.compaction?.type !== "native" ||
resolved.route.compact?.trigger ||
resolved.route.compact?.endpoint
)
if (model.compaction?.mode !== "provider" || resolved.route.compact?.trigger || resolved.route.compact?.endpoint)
return Effect.succeed(resolved)
return Effect.fail(
new UnsupportedCompactionError({ providerID: model.providerID, modelID: model.id, route: resolved.route.id }),
@@ -381,8 +377,8 @@ export const layer = Layer.effect(
capabilities: selected.capabilities,
cost: selected.cost,
limit: selected.limit,
compaction: runtimeInfo.settings?.compaction,
transport: runtimeInfo.settings?.transport,
compaction: selected.compaction,
transport: selected.transport,
}
})
return Service.of({
+2
View File
@@ -188,6 +188,8 @@ const layer = Layer.effect(
...model,
...(provider?.canonical === undefined ? {} : { canonical: provider.canonical }),
package: model.package ?? provider?.package,
compaction: model.compaction ?? provider?.compaction,
transport: model.transport ?? provider?.transport,
settings: Provider.mergeOverlay(provider?.settings, model.settings),
headers: Provider.mergeHeaders(provider?.headers, model.headers),
body: Provider.mergeOverlay(provider?.body, model.body),
-2
View File
@@ -95,7 +95,6 @@ import { ProviderPlugins } from "./provider.js"
import { WebSearchPlugins } from "./websearch/index.js"
import { SkillPlugin } from "./skill.js"
import { VcsHgPlugin } from "./vcs/hg.js"
import { ToolInputRepairPlugin } from "./tool-input-repair.js"
import { OptimizePlugin } from "./optimize.js"
import { VcsGitPlugin } from "./vcs/git.js"
import { WarmingPlugin } from "./warming.js"
@@ -206,7 +205,6 @@ export const requirements = LayerNode.group([
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
const pre = [
ToolInputRepairPlugin.Plugin,
ConfigWorktreePlugin.Plugin,
BrowserPlugin,
ConfigMcpPlugin.Plugin,
+1 -3
View File
@@ -168,9 +168,7 @@ export const AzurePlugin = define({
resolveResourceName(draft.settings, resourceName) ?? resourceName,
)
if (responsesWebSocketCapable(item.provider, draft))
draft.settings = Provider.mergeOverlay(draft.settings, {
transport: item.provider.settings?.transport ?? "websocket",
})
draft.transport = item.provider.transport ?? "websocket"
})
}
}
+1 -3
View File
@@ -270,9 +270,7 @@ export const OpenAIPlugin = define({
// ChatGPT-plan tokens only authorize codex-eligible models, and the
// subscription covers usage, so hide the rest and zero the cost.
models.update(model.providerID, model.id, (draft) => {
draft.settings = Provider.mergeOverlay(draft.settings, {
transport: models.provider.get(model.providerID)?.provider.settings?.transport ?? "websocket",
})
draft.transport = models.provider.get(model.providerID)?.provider.transport ?? "websocket"
if (!chatgpt) return
if (Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(draft.body?.reasoning)) {
draft.enabled = false
+1 -3
View File
@@ -98,9 +98,7 @@ export const XAIPlugin = define({
yield* ctx.model.transform((models) => {
for (const model of models.list(providerID)) {
models.update(providerID, model.id, (draft) => {
draft.settings = Provider.mergeOverlay(draft.settings, {
transport: models.provider.get(providerID)?.provider.settings?.transport ?? "websocket",
})
draft.transport = models.provider.get(providerID)?.provider.transport ?? "websocket"
})
}
})
@@ -1,177 +0,0 @@
export * as ToolInputRepairPlugin from "./tool-input-repair.js"
import { define } from "@opencode/plugin/effect/plugin"
import type { ToolEditor } from "@opencode/plugin/effect/tool"
import { Effect, JsonSchema, Option, Predicate, Schema } from "effect"
import { definition } from "../tool/runtime.js"
// Repairs apply only when the input schema unambiguously supports them:
// - Stringified root or nested object: '{"limit":"20"}' -> { limit: 20 }
// - Closed object: { limit: "20", extra: true } -> { limit: 20 }
// - Optional null or empty-object placeholder: { limit: null } -> {}
// - Numeric or boolean string: { limit: "20", enabled: "false" } -> { limit: 20, enabled: false }
// - Nullable field: { count: "2" } -> { count: 2 }
// - Stringified array or compatible item: { tags: '["a"]', count: "2" } -> { tags: ["a"], count: [2] }
// - Positional tuple: { pair: ["2", "false"] } -> { pair: [2, false] }
// - Typed dictionary: { counts: { first: "2" } } -> { counts: { first: 2 } }
// - Nested fields and local references: { items: [{ count: "2" }] } -> { items: [{ count: 2 }] }
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
const maxDepth = 6
export const Plugin = define({
id: "opencode.tool.input.repair",
effect: Effect.fn(function* (ctx) {
let get: ToolEditor["get"] = () => undefined
yield* ctx.tool.transform((draft) => {
// The draft sees later tool transforms too; reload replaces this lookup.
get = draft.get
})
yield* ctx.tool.hook("execute.before", (event) =>
Effect.sync(() => {
// The outer Code Mode tool is built per snapshot rather than registered, so it cannot be
// looked up here. Its `{ code }` input is trivial; the tools it calls are repaired normally.
if (event.tool === "execute") return
const tool = get(event.tool)
if (!tool) return
const schema = definition(tool).inputSchema
if (schema.type !== "object") return
event.input = repair(event.input, schema, schema, 0)
}),
)
}),
})
function repair(value: unknown, schema: JsonSchema.JsonSchema, root: JsonSchema.JsonSchema, depth: number): unknown {
if (depth > maxDepth) return value
if (typeof schema.$ref === "string") {
const definitions = /^#\/\$defs\/[^/]+$/.test(schema.$ref)
? root.$defs
: /^#\/definitions\/[^/]+$/.test(schema.$ref)
? root.definitions
: undefined
if (!Predicate.isObject(definitions)) return value
const target = Object.fromEntries(
Object.entries(definitions).filter((entry): entry is [string, JsonSchema.JsonSchema] =>
Predicate.isObject(entry[1]),
),
)[
schema.$ref
.slice(schema.$ref.lastIndexOf("/") + 1)
.replaceAll("~1", "/")
.replaceAll("~0", "~")
]
return target ? repair(value, target, root, depth + 1) : value
}
if (Array.isArray(schema.type)) {
if (value === null && schema.type.includes("null")) return value
if (schema.type.includes(typeof value)) return value
const types = schema.type.filter((type) => type !== "null")
return types.length === 1 ? repair(value, { ...schema, type: types[0] }, root, depth + 1) : value
}
if (schema.type === undefined) {
if (Array.isArray(schema.anyOf) && Array.isArray(schema.oneOf)) return value
const branches = Array.isArray(schema.anyOf) ? schema.anyOf : schema.oneOf
if (!Array.isArray(branches) || value === null) return value
if (branches.some((branch) => !Predicate.isObject(branch) || branch.type === typeof value)) return value
const candidates = branches.filter((branch) => Predicate.isObject(branch) && branch.type !== "null")
return candidates.length === 1 ? repair(value, candidates[0], root, depth + 1) : value
}
if (schema.type === "number" || schema.type === "integer") {
if (typeof value !== "string" || value.trim() === "") return value
const parsed = Number(value)
return Number.isFinite(parsed) && (schema.type !== "integer" || Number.isSafeInteger(parsed)) ? parsed : value
}
if (schema.type === "boolean") return value === "true" ? true : value === "false" ? false : value
if (schema.type === "object") return repairObject(value, schema, root, depth)
if (schema.type === "array") return repairArray(value, schema, root, depth)
return value
}
function repairObject(
value: unknown,
schema: JsonSchema.JsonSchema,
root: JsonSchema.JsonSchema,
depth: number,
): unknown {
const parsed = typeof value === "string" ? Option.getOrUndefined(decodeJson(value)) : value
if (!Predicate.isObject(parsed)) return value
const properties = Predicate.isObject(schema.properties) ? schema.properties : {}
const required = Array.isArray(schema.required) ? schema.required : []
const patterned = Predicate.isObject(schema.patternProperties)
const composed = Array.isArray(schema.allOf) || Array.isArray(schema.anyOf) || Array.isArray(schema.oneOf)
return Object.keys(parsed).reduce<Record<string, unknown>>((result, key) => {
const current = result[key]
const declared = Object.hasOwn(properties, key)
const property = declared ? properties[key] : !patterned ? schema.additionalProperties : undefined
if (!declared && schema.additionalProperties === false && !patterned && !composed) {
const next = { ...result }
delete next[key]
return next
}
if (!Predicate.isObject(property)) return result
// Only a bare single-type property provably rejects null and `{}`. Compositions, enums,
// constants, references and nullable flags may accept them, so those are left alone.
const plain =
typeof property.type === "string" &&
property.type !== "null" &&
!composed &&
["anyOf", "oneOf", "allOf", "enum", "const", "$ref", "nullable"].every((keyword) => !(keyword in property))
const placeholder = Predicate.isObject(current) && Object.keys(current).length === 0 && property.type !== "object"
if (declared && !required.includes(key) && plain && (current === null || placeholder)) {
const next = { ...result }
delete next[key]
return next
}
const repaired = repair(current, property, root, depth + 1)
return repaired === current ? result : { ...result, [key]: repaired }
}, parsed)
}
function repairArray(
value: unknown,
schema: JsonSchema.JsonSchema,
root: JsonSchema.JsonSchema,
depth: number,
): unknown {
const parsed = typeof value === "string" ? Option.getOrUndefined(decodeJson(value)) : value
const tuple = Array.isArray(schema.prefixItems)
? schema.prefixItems
: Array.isArray(schema.items)
? schema.items
: undefined
if (Array.isArray(parsed)) {
const repaired = parsed.map((item, index) => {
const member = tuple
? (tuple[index] ?? (Array.isArray(schema.prefixItems) ? schema.items : schema.additionalItems))
: schema.items
return Predicate.isObject(member) ? repair(item, member, root, depth + 1) : item
})
return repaired.every((item, index) => item === parsed[index]) ? parsed : repaired
}
if (tuple || !Predicate.isObject(schema.items)) return value
const repaired = repair(value, schema.items, root, depth + 1)
const type = schema.items.type
const compatible =
type === "object"
? Predicate.isObject(repaired)
: type === "array"
? Array.isArray(repaired)
: type === "integer"
? typeof repaired === "number" && Number.isSafeInteger(repaired)
: type === "number"
? typeof repaired === "number" && Number.isFinite(repaired)
: (type === "string" || type === "boolean") && typeof repaired === type
return compatible ? [repaired] : value
}
+3 -9
View File
@@ -132,11 +132,11 @@ export const loadPackage = Effect.fn("Provider.loadPackage")(function* (input: s
return yield* importPackage(specifier, entrypoint)
})
/** opencode settings consumed in Core; native packages never receive them. */
const CORE_KEYS = ["chunkTimeout", "compaction", "fetch", "timeout", "transport"] as const
/** opencode transport settings consumed in aisdk.ts; native packages never receive them. */
const TRANSPORT_KEYS = ["chunkTimeout", "fetch", "timeout"] as const
export function nativeSettings(settings: Settings): Settings {
return Struct.omit(settings, CORE_KEYS)
return Struct.omit(settings, TRANSPORT_KEYS)
}
export function mergeOverlay(
@@ -177,12 +177,6 @@ export function mergeHeaders(
export const Request = Provider.Request
export type Request = Provider.Request
export const Compaction = Provider.Compaction
export type Compaction = Provider.Compaction
export const Transport = Provider.Transport
export type Transport = Provider.Transport
export const Settings = Provider.Settings
export type Settings = Provider.Settings
+8 -3
View File
@@ -736,7 +736,7 @@ export const layer = Layer.effect(
const compact = Effect.fn("SessionCompaction.compact")(function* (input: AutoInput): Effect.fn.Return<Outcome> {
const request = { ...input, reason: "auto" as const }
if (input.overflow) return yield* recoverLocally(request)
if (input.context.model.compaction?.type !== "native") return yield* execute(request)
if (input.context.model.compaction?.mode !== "provider") return yield* execute(request)
return yield* executeProvider(request)
})
const required = (input: RequiredInput) => {
@@ -759,7 +759,12 @@ export const layer = Layer.effect(
limit.input === undefined ? Number.POSITIVE_INFINITY : limit.input - config.buffer,
context - Math.max(output, config.buffer),
)
return estimateTokens(input) >= promptCeiling
const policy = input.resolved.compaction
const threshold =
policy?.mode === "provider" && policy.threshold !== undefined
? Math.min(policy.threshold, promptCeiling)
: promptCeiling
return estimateTokens(input) >= threshold
}
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
if (findTailStart(input.messages, state.get().tokens) === undefined)
@@ -787,7 +792,7 @@ export const layer = Layer.effect(
inputID: input.inputID,
started: input.started,
}
return context.model.compaction?.type === "native" ? executeProvider(request) : execute(request)
return context.model.compaction?.mode === "provider" ? executeProvider(request) : execute(request)
},
}),
)
-6
View File
@@ -155,18 +155,14 @@ it.effect("projects request settings, headers, and body overlays", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
let body: unknown
let options: Record<string, unknown> | undefined
yield* aisdk.hook.sdk((event) => {
body = event.options.body
options = event.options
event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
})
const input = model("@ai-sdk/google", {
apiKey: "secret",
thinkingConfig: { thinkingBudget: 1024 },
compaction: { type: "native" },
transport: "websocket",
})
const resolved = yield* aisdk.model({
...input,
@@ -189,8 +185,6 @@ it.effect("projects request settings, headers, and body overlays", () =>
})
expect(prepared.body.headers).toEqual({ "x-test": "header" })
expect(body).toEqual({ safety_setting: "strict" })
expect(options).not.toHaveProperty("compaction")
expect(options).not.toHaveProperty("transport")
}),
)
+25 -16
View File
@@ -34,7 +34,7 @@ function required<T>(value: T | undefined): T {
const decode = Schema.decodeUnknownSync(Info)
describe("ConfigProviderPlugin.Plugin", () => {
it.effect("inherits the provider compaction setting with model overrides and rejects unsupported routes", () =>
it.effect("inherits provider compaction policy with model overrides and rejects unsupported routes", () =>
Effect.gen(function* () {
const models = yield* Model.Service
yield* addPlugin([
@@ -44,10 +44,12 @@ describe("ConfigProviderPlugin.Plugin", () => {
providers: {
custom: {
package: "@opencode/ai/providers/openai/responses",
settings: { compaction: { type: "native" } },
compaction: { mode: "provider", threshold: 120_000 },
models: {
native: {},
local: { settings: { compaction: { type: "summary" } }, package: "@opencode/ai/providers/openai/chat" },
reset: { compaction: { mode: "provider" } },
threshold: { compaction: { mode: "provider", threshold: 90_000 } },
local: { compaction: { mode: "local" }, package: "@opencode/ai/providers/openai/chat" },
unsupported: { package: "@opencode/ai/providers/openai/chat" },
},
},
@@ -60,9 +62,16 @@ describe("ConfigProviderPlugin.Plugin", () => {
const local = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("local")))
const unsupported = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("unsupported")))
const defaultModel = required(yield* models.get(Provider.ID.make("default"), Model.ID.make("chat")))
expect(native.settings?.compaction).toEqual({ type: "native" })
expect(local.settings?.compaction).toEqual({ type: "summary" })
expect(defaultModel.settings?.compaction).toBeUndefined()
expect(native.compaction).toEqual({ mode: "provider", threshold: 120_000 })
expect((yield* models.get(Provider.ID.make("custom"), Model.ID.make("reset")))?.compaction).toEqual({
mode: "provider",
})
expect((yield* models.get(Provider.ID.make("custom"), Model.ID.make("threshold")))?.compaction).toEqual({
mode: "provider",
threshold: 90_000,
})
expect(local.compaction).toEqual({ mode: "local" })
expect(defaultModel.compaction).toBeUndefined()
yield* ModelResolver.fromCatalogModel(native)
yield* ModelResolver.fromCatalogModel(local)
yield* ModelResolver.fromCatalogModel(defaultModel)
@@ -83,8 +92,8 @@ describe("ConfigProviderPlugin.Plugin", () => {
providers: {
custom: {
package: "@opencode/ai/providers/openai/responses",
settings: { transport: "http" },
models: { inherited: {}, override: { settings: { transport: "websocket" } } },
transport: "http",
models: { inherited: {}, override: { transport: "websocket" } },
},
default: { package: "@opencode/ai/providers/openai/responses", models: { untouched: {} } },
},
@@ -94,9 +103,9 @@ describe("ConfigProviderPlugin.Plugin", () => {
const inherited = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("inherited")))
const override = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("override")))
const untouched = required(yield* models.get(Provider.ID.make("default"), Model.ID.make("untouched")))
expect(inherited.settings?.transport).toBe("http")
expect(override.settings?.transport).toBe("websocket")
expect(untouched.settings?.transport).toBeUndefined()
expect(inherited.transport).toBe("http")
expect(override.transport).toBe("websocket")
expect(untouched.transport).toBeUndefined()
}),
)
@@ -122,7 +131,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
editor.models.update(providerID, modelID, () => {})
})
yield* builtin.plugin.effect(host)
expect((yield* models.get(providerID, modelID))?.settings?.transport).toBe("websocket")
expect((yield* models.get(providerID, modelID))?.transport).toBe("websocket")
yield* addPlugin([
new Document({
@@ -130,16 +139,16 @@ describe("ConfigProviderPlugin.Plugin", () => {
info: decode({
providers: {
[builtin.id]: {
settings: { transport: "http" },
models: { override: { modelID: builtin.model, settings: { transport: "websocket" } } },
transport: "http",
models: { override: { modelID: builtin.model, transport: "websocket" } },
},
},
}),
}),
])
expect((yield* models.get(providerID, modelID))?.settings?.transport).toBe("http")
expect((yield* models.get(providerID, Model.ID.make("override")))?.settings?.transport).toBe("websocket")
expect((yield* models.get(providerID, modelID))?.transport).toBe("http")
expect((yield* models.get(providerID, Model.ID.make("override")))?.transport).toBe("websocket")
}),
)
}
@@ -473,10 +473,10 @@ describe("AzurePlugin", () => {
yield* addPlugin()
const responses = required(yield* service.get(Provider.ID.azure, models.responses))
expect(responses.settings?.transport).toBe("websocket")
expect(responses.transport).toBe("websocket")
for (const modelID of [models.chat, models.preview, models.deploymentURL, models.gateway, models.nonAzure]) {
const model = required(yield* service.get(Provider.ID.azure, modelID))
expect(model.settings?.transport).toBeUndefined()
expect(model.transport).toBeUndefined()
}
}),
),
@@ -205,7 +205,7 @@ describe("OpenAIPlugin", () => {
expect(model.package).toBe("@opencode/ai/providers/openai")
expect(model.enabled).toBe(true)
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
expect(model.settings?.transport).toBe("websocket")
expect(model.transport).toBe("websocket")
expect(direct.headers).not.toHaveProperty("originator")
expect(direct.baseURL).toBe("https://api.openai.com/v1")
expect(provider.headers).not.toHaveProperty("x-codex-beta-features")
@@ -236,7 +236,7 @@ describe("OpenAIPlugin", () => {
id: "deployment-responses",
provider: Provider.ID.azure,
})
const prepare = (preference?: Provider.Transport) =>
const prepare = (preference?: Model.Info["transport"]) =>
Effect.gen(function* () {
const model = SessionRunnerModel.resolved(route.model({ id: "gpt-5.5" }), {
capabilities: { tools: true, input: ["text"], output: ["text"] },
@@ -84,7 +84,7 @@ describe("XAIPlugin", () => {
yield* addPlugin()
const model = yield* models.get(providerID, Model.ID.make("grok-4.6"))
expect(model?.settings?.transport).toBe("websocket")
expect(model?.transport).toBe("websocket")
}),
)
})
@@ -1,114 +0,0 @@
import { expect } from "bun:test"
import { Effect, Schema } from "effect"
import { Agent } from "@opencode/core/agent"
import { Plugin } from "@opencode/core/plugin"
import { ToolInputRepairPlugin } from "@opencode/core/plugin/tool-input-repair"
import { Session } from "@opencode/core/session"
import { SessionMessage } from "@opencode/core/session/message"
import { Tool } from "@opencode/core/tool"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const identity = {
sessionID: Session.ID.make("ses_repair"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_repair"),
}
it.effect("repairs tool input before validating its original schema", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const registry = yield* Tool.Service
const executed: unknown[] = []
yield* plugins.activate([
{ ...ToolInputRepairPlugin.Plugin, revision: "1" },
{
id: "repairable-tool",
revision: "1",
effect: (ctx) =>
ctx.tool.transform((draft) =>
draft.add({
name: "repairable",
options: { codemode: false },
description: "Repairable",
input: Schema.Struct({ count: Schema.Int, enabled: Schema.Boolean }),
execute: (input) => Effect.sync(() => executed.push(input)).pipe(Effect.as({ content: "ok" })),
}),
),
},
])
const snapshot = yield* registry.snapshot()
yield* snapshot.execute({
...identity,
call: {
type: "tool-call",
id: "call-repair",
name: "repairable",
input: '{"count":"2","enabled":"true","extra":true}',
},
})
expect(executed).toEqual([{ count: 2, enabled: true }])
yield* registry.transform((draft) => {
draft.update("repairable", (tool) => {
tool.input = Schema.Struct({ count: Schema.Boolean, enabled: Schema.Boolean })
})
})
const updated = yield* registry.snapshot()
yield* updated.execute({
...identity,
call: {
type: "tool-call",
id: "call-updated",
name: "repairable",
input: { count: "false", enabled: "true" },
},
})
expect(executed).toEqual([
{ count: 2, enabled: true },
{ count: false, enabled: true },
])
yield* registry.transform((draft) => draft.remove("repairable"))
const removed = yield* registry.snapshot()
expect(
(yield* removed
.execute({
...identity,
call: { type: "tool-call", id: "call-removed", name: "repairable", input: {} },
})
.pipe(Effect.flip)).message,
).toBe('No tool named "repairable" is currently available. Please use a tool from the available tool list.')
}),
)
it.effect("repairs namespaced inner tool input called from Code Mode", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const registry = yield* Tool.Service
const executed: unknown[] = []
yield* plugins.activate([{ ...ToolInputRepairPlugin.Plugin, revision: "1" }])
yield* registry.transform((draft) =>
draft.add({
name: "count",
options: { namespace: "example" },
description: "Record a count",
input: Schema.Struct({ count: Schema.Int }),
execute: (input) => Effect.sync(() => executed.push(input)).pipe(Effect.as({ content: "ok" })),
}),
)
const snapshot = yield* registry.snapshot()
yield* snapshot.execute({
...identity,
call: {
type: "tool-call",
id: "call-codemode-repair",
name: "execute",
input: { code: 'return await tools.example.count({ count: "3" })' },
},
})
expect(executed).toEqual([{ count: 3 }])
}),
)
@@ -1,466 +0,0 @@
import { describe, expect } from "bun:test"
import { Agent } from "@opencode/core/agent"
import { ToolInputRepairPlugin } from "@opencode/core/plugin/tool-input-repair"
import { Session } from "@opencode/core/session"
import { SessionMessage } from "@opencode/core/session/message"
import type { ToolHooks } from "@opencode/plugin/effect/tool"
import { Tool } from "@opencode/schema/tool"
import { Effect, type JsonSchema } from "effect"
import { it } from "../lib/effect"
import { host } from "./host"
function run(input: unknown, inputSchema: JsonSchema.JsonSchema) {
const event: ToolHooks["execute.before"] = {
tool: "test",
input,
sessionID: Session.ID.make("ses_repair"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_repair"),
id: Tool.CallID.make("call_repair"),
}
const events: ToolHooks = {
"execute.before": event,
"execute.after": { ...event, status: "error", error: new Tool.Error({ message: "unused" }) },
}
const base = host()
return ToolInputRepairPlugin.Plugin.effect(
host({
tool: {
...base.tool,
transform: (callback) =>
Effect.sync(() => {
const tool = {
id: "test",
name: "test",
description: "Test repair",
input: inputSchema,
execute: () => Effect.succeed({ content: "unused" }),
}
callback({
list: () => [tool],
get: (id) => (id === tool.id ? tool : undefined),
add: () => {},
namespace: () => {},
update: () => {},
remove: () => {},
})
return { dispose: Effect.void }
}),
hook: (name, callback) => callback(events[name]).pipe(Effect.orDie, Effect.as({ dispose: Effect.void })),
},
}),
).pipe(Effect.as(event))
}
const object = (properties: Record<string, unknown>, required?: string[]) => ({
type: "object" as const,
properties,
...(required ? { required } : {}),
})
describe("tool input repair plugin", () => {
it.effect("preserves valid input identity, nested containers, and unknown properties", () =>
Effect.gen(function* () {
const nested = { enabled: true }
const items = [2, 3]
const input = { count: 2, nested, items, extra: "keep" }
const event = yield* run(
input,
object({
count: { type: "integer" },
nested: object({ enabled: { type: "boolean" } }),
items: { type: "array", items: { type: "number" } },
}),
)
expect(event.input).toBe(input)
expect((event.input as typeof input).nested).toBe(nested)
expect((event.input as typeof input).items).toBe(items)
}),
)
it.effect("parses root objects and repairs nested stringified containers", () =>
Effect.gen(function* () {
const schema = object({
count: { type: "integer" },
item: object({ enabled: { type: "boolean" } }),
list: { type: "array", items: { type: "integer" } },
})
expect(
(yield* run('{"count":"2","item":"{\\"enabled\\":\\"false\\"}","list":"[\\"3\\"]"}', schema)).input,
).toEqual({
count: 2,
item: { enabled: false },
list: [3],
})
expect((yield* run("{broken", schema)).input).toBe("{broken")
expect((yield* run("[]", schema)).input).toBe("[]")
expect((yield* run(null, schema)).input).toBeNull()
}),
)
it.effect("removes extras only from explicitly closed objects without mutating inputs", () =>
Effect.gen(function* () {
const input = {
known: "2",
extra: true,
closed: { keep: "3", extra: true },
open: { keep: "4", extra: true },
items: [{ keep: "5", extra: true }],
}
const event = yield* run(input, {
...object({
known: { type: "integer" },
closed: { ...object({ keep: { type: "integer" } }), additionalProperties: false },
open: object({ keep: { type: "integer" } }),
items: {
type: "array",
items: { ...object({ keep: { type: "integer" } }), additionalProperties: false },
},
}),
additionalProperties: false,
})
expect(event.input).toEqual({
known: 2,
closed: { keep: 3 },
open: { keep: 4, extra: true },
items: [{ keep: 5 }],
})
expect(input.extra).toBeTrue()
expect(input.closed.extra).toBeTrue()
expect(input.items[0]?.extra).toBeTrue()
expect((yield* run({ extra: true }, { ...object({}), additionalProperties: false })).input).toEqual({})
}),
)
it.effect("preserves unknown keys when patterned ownership cannot be determined", () =>
Effect.gen(function* () {
const input = { known: 1, match: "2", extra: true }
const event = yield* run(input, {
...object({ known: { type: "integer" } }),
additionalProperties: false,
patternProperties: { "^match$": { type: "integer" } },
})
expect(event.input).toBe(input)
expect((event.input as typeof input).match).toBe("2")
expect((event.input as typeof input).extra).toBeTrue()
}),
)
it.effect("preserves properties that may belong to composed object schemas", () =>
Effect.gen(function* () {
const input = { name: "example", extra: true }
for (const keyword of ["allOf", "anyOf", "oneOf"]) {
const event = yield* run(input, {
type: "object",
[keyword]: [object({ name: { type: "string" } })],
additionalProperties: false,
})
expect(event.input).toBe(input)
}
}),
)
it.effect("removes only optional nonnullable nulls and non-object empty placeholders", () =>
Effect.gen(function* () {
const input = {
optional: null,
required: null,
nullable: null,
union: null,
constant: null,
permissive: null,
referenced: null,
placeholder: {},
array: {},
requiredPlaceholder: {},
object: {},
unknown: null,
}
const event = yield* run(
input,
object(
{
optional: { type: "string" },
required: { type: "string" },
nullable: { type: "string", nullable: true },
union: { anyOf: [{ type: "integer" }, { type: "null" }] },
constant: { anyOf: [{ type: "integer" }, { const: null }] },
permissive: { anyOf: [{ type: "integer" }, true] },
referenced: { anyOf: [{ type: "integer" }, { $ref: "#/$defs/nullable" }] },
placeholder: { type: "integer" },
array: { type: "array", items: { type: "string" } },
requiredPlaceholder: { type: "boolean" },
object: { type: "object" },
unknown: {},
},
["required", "requiredPlaceholder"],
),
)
expect(event.input).toEqual({
required: null,
nullable: null,
union: null,
constant: null,
permissive: null,
referenced: null,
requiredPlaceholder: {},
object: {},
unknown: null,
})
expect(input.optional).toBeNull()
expect(input.placeholder).toEqual({})
}),
)
it.effect("preserves nulls whose validity is hidden inside compositions", () =>
Effect.gen(function* () {
const input = { wrapped: null, enumerated: null, permissive: null, constant: null }
const event = yield* run(
input,
object({
wrapped: { anyOf: [{ type: ["string", "null"] }] },
enumerated: { anyOf: [{ enum: [null, "keep"] }] },
permissive: { anyOf: [{}] },
constant: { type: "string", const: "keep" },
}),
)
expect(event.input).toBe(input)
}),
)
it.effect("preserves optional-looking nulls when the parent composes requirements", () =>
Effect.gen(function* () {
const input = { value: null }
const event = yield* run(input, {
...object({ value: { type: "string" } }),
allOf: [{ required: ["value"] }],
})
expect(event.input).toBe(input)
}),
)
it.effect("coerces numeric and boolean strings while preserving invalid and existing values", () =>
Effect.gen(function* () {
const input = {
number: "1.5",
integer: "42",
enabled: "true",
disabled: "false",
valid: 3,
empty: " ",
infinite: "Infinity",
fractional: "1.5",
unsafe: "9007199254740992",
uppercase: "TRUE",
}
const event = yield* run(
input,
object({
number: { type: "number" },
integer: { type: "integer" },
enabled: { type: "boolean" },
disabled: { type: "boolean" },
valid: { type: "integer" },
empty: { type: "number" },
infinite: { type: "number" },
fractional: { type: "integer" },
unsafe: { type: "integer" },
uppercase: { type: "boolean" },
}),
)
expect(event.input).toEqual({ ...input, number: 1.5, integer: 42, enabled: true, disabled: false })
}),
)
it.effect("wraps compatible scalars after repairing array items", () =>
Effect.gen(function* () {
const event = yield* run(
{
text: "one",
integer: "42",
boolean: "false",
item: '{"count":"2"}',
incompatible: 2,
fractional: 1.5,
unconstrained: "4",
},
object({
text: { type: "array", items: { type: "string" } },
integer: { type: "array", items: { type: "integer" } },
boolean: { type: "array", items: { type: "boolean" } },
item: { type: "array", items: object({ count: { type: "integer" } }) },
incompatible: { type: "array", items: { type: "string" } },
fractional: { type: "array", items: { type: "integer" } },
unconstrained: { type: "array" },
}),
)
expect(event.input).toEqual({
text: ["one"],
integer: [42],
boolean: [false],
item: [{ count: 2 }],
incompatible: 2,
fractional: 1.5,
unconstrained: "4",
})
}),
)
it.effect("repairs nested question-like inputs without mutating original containers", () =>
Effect.gen(function* () {
const question = { question: "Pick one", multiple: "false", options: { label: "First", description: null } }
const input = { questions: [question] }
const event = yield* run(
input,
object({
questions: {
type: "array",
items: object({
question: { type: "string" },
multiple: { type: "boolean" },
options: {
type: "array",
items: object({ label: { type: "string" }, description: { type: "string" } }, ["label"]),
},
}),
},
}),
)
expect(event.input).toEqual({
questions: [{ question: "Pick one", multiple: false, options: [{ label: "First" }] }],
})
expect(input).toEqual({ questions: [question] })
expect(question.options.description).toBeNull()
}),
)
it.effect("repairs unique nullable alternatives while preserving accepted union values", () =>
Effect.gen(function* () {
const input = {
number: "2",
boolean: "false",
nullable: null,
typed: "3",
typedBoolean: "true",
accepted: "4",
valid: 5,
}
const event = yield* run(
input,
object({
number: { anyOf: [{ type: "number" }, { type: "null" }] },
boolean: { oneOf: [{ type: "boolean" }, { type: "null" }] },
nullable: { anyOf: [{ type: "number" }, { type: "null" }] },
typed: { type: ["integer", "null"] },
typedBoolean: { type: ["boolean", "null"] },
accepted: { anyOf: [{ type: "string" }, { type: "number" }] },
valid: { type: ["number", "null"] },
}),
)
expect(event.input).toEqual({ ...input, number: 2, boolean: false, typed: 3, typedBoolean: true })
}),
)
it.effect("repairs tuple positions and rest items while preserving valid array identity", () =>
Effect.gen(function* () {
const valid = [2, false]
const input = { prefix: ["2", "false", "3"], draft: '["4","true"]', valid, scalar: "5" }
const event = yield* run(
input,
object({
prefix: {
type: "array",
prefixItems: [{ type: "integer" }, { type: "boolean" }],
items: { type: "number" },
},
draft: { type: "array", items: [{ type: "integer" }, { type: "boolean" }] },
valid: { type: "array", prefixItems: [{ type: "integer" }, { type: "boolean" }] },
scalar: { type: "array", prefixItems: [{ type: "integer" }] },
}),
)
expect(event.input).toEqual({ prefix: [2, false, 3], draft: [4, true], valid, scalar: "5" })
expect((event.input as typeof input).valid).toBe(valid)
expect(input.prefix).toEqual(["2", "false", "3"])
}),
)
it.effect("repairs typed dictionaries and straightforward local references", () =>
Effect.gen(function* () {
const input = {
modern: "2",
legacy: "false",
nested: { count: "3" },
dictionary: { first: "4" },
missing: "5",
pointer: "6",
escaped: "7",
}
const event = yield* run(input, {
...object({
modern: { $ref: "#/$defs/integer" },
legacy: { $ref: "#/definitions/boolean" },
nested: { $ref: "#/$defs/nested" },
dictionary: { type: "object", additionalProperties: { $ref: "#/$defs/integer" } },
missing: { $ref: "#/$defs/missing" },
pointer: { $ref: "#/$defs/nested/properties/count" },
escaped: { $ref: "#/$defs/a~1b~0c" },
}),
$defs: {
integer: { type: "integer" },
"a/b~c": { type: "integer" },
nested: object({ count: { $ref: "#/$defs/integer" } }),
},
definitions: { boolean: { type: "boolean" } },
})
expect(event.input).toEqual({
modern: 2,
legacy: false,
nested: { count: 3 },
dictionary: { first: 4 },
missing: "5",
pointer: "6",
escaped: 7,
})
expect(input.nested.count).toBe("3")
expect(input.dictionary.first).toBe("4")
}),
)
it.effect("leaves ambiguous unions, compositions, and unsupported roots unchanged", () =>
Effect.gen(function* () {
const input = { numeric: "2", objects: { value: "3" }, both: "4", composed: "5", unknown: "6" }
const event = yield* run(
input,
object({
numeric: { anyOf: [{ type: "number" }, { type: "integer" }] },
objects: {
oneOf: [
object({ value: { type: "integer" } }, ["value"]),
object({ value: { type: "number" } }, ["value"]),
],
},
both: { anyOf: [{ type: "integer" }], oneOf: [{ type: "integer" }] },
composed: { allOf: [{ type: "integer" }] },
unknown: {},
}),
)
expect(event.input).toBe(input)
expect((yield* run(input, { properties: { numeric: { type: "integer" } } })).input).toBe(input)
expect((yield* run(input, { allOf: [object({ numeric: { type: "integer" } })] })).input).toBe(input)
}),
)
})
+2 -10
View File
@@ -28,16 +28,8 @@ describe("Provider", () => {
}
})
test("passes flat settings to native packages without Core settings", () => {
expect(
Provider.nativeSettings({
apiKey: "secret",
reasoningEffort: "high",
chunkTimeout: 1000,
compaction: { type: "native" },
transport: "websocket",
}),
).toEqual({
test("passes flat settings to native packages without opencode transport keys", () => {
expect(Provider.nativeSettings({ apiKey: "secret", reasoningEffort: "high", chunkTimeout: 1000 })).toEqual({
apiKey: "secret",
reasoningEffort: "high",
})
+10 -3
View File
@@ -207,13 +207,20 @@ it.effect("auto compaction estimates current content against the buffered prompt
const inputLimited = { context: 400_000, input: 272_000, output: 128_000 }
expect(compaction.required(input(251_999, inputLimited))).toBe(false)
expect(compaction.required(input(252_000, inputLimited))).toBe(true)
const native = (tokens: number, limit: { context: number; input?: number; output: number } = inputLimited) => {
const native = (
tokens: number,
limit: { context: number; input?: number; output: number } = inputLimited,
threshold?: number,
) => {
const selected = input(tokens, limit)
return { ...selected, resolved: { ...selected.resolved, compaction: { type: "native" as const } } }
return { ...selected, resolved: { ...selected.resolved, compaction: { mode: "provider" as const, threshold } } }
}
expect(compaction.required(native(251_999))).toBe(false)
expect(compaction.required(native(252_000))).toBe(true)
expect(compaction.required(native(1_000_000, { context: 0, input: undefined, output: 0 }))).toBe(false)
expect(compaction.required(native(99_999, inputLimited, 100_000))).toBe(false)
expect(compaction.required(native(100_000, inputLimited, 100_000))).toBe(true)
expect(compaction.required(native(252_000, inputLimited, 500_000))).toBe(true)
expect(compaction.required(native(1_000_000, { context: 0, input: undefined, output: 0 }, 100_000))).toBe(false)
const contextLimited = { context: 100_000, output: 10_000 }
expect(compaction.required(input(79_999, contextLimited))).toBe(false)
@@ -164,7 +164,7 @@ const setup = Effect.fnUntraced(function* (options: { endpoint?: boolean; plugin
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
limit: { context: 200_000, output: 32_000 },
compaction: { type: "native" },
compaction: { mode: "provider" },
},
)
const sessionID = SessionSchema.ID.create()
+2 -4
View File
@@ -2863,8 +2863,7 @@ describe("SessionRunnerLLM", () => {
scenario("automatically persists native windows, retains earlier users, and waits for fresh usage", function* (s) {
s.currentModel = LanguageModel.make({ id: "native", provider: "openai", route: OpenAIResponses.route })
modelLimits.set("native", { context: 42_000, output: 32_000 })
s.compaction = { type: "native" }
s.compaction = { mode: "provider", threshold: 10_000 }
const agents = yield* Agent.Service
yield* agents.transform((editor) =>
editor.update(Agent.defaultID, (agent) => {
@@ -2915,8 +2914,7 @@ describe("SessionRunnerLLM", () => {
scenario("recovers an overflowing native window locally from original durable history", function* (s) {
s.currentModel = LanguageModel.make({ id: "native", provider: "openai", route: OpenAIResponses.route })
modelLimits.set("native", { context: 42_000, output: 32_000 })
s.compaction = { type: "native" }
s.compaction = { mode: "provider", threshold: 10_000 }
yield* s.llm.push(TestLLM.textWithUsage("Earlier answer", "before-native", 10_000))
yield* s.runPrompt("Original durable request")
yield* s.llm.push(
-3
View File
@@ -106,9 +106,6 @@ test("Core reuses the canonical shared schemas", async () => {
[coreModel.Info, Model.Info],
[coreProvider.ID, Provider.ID],
[coreProvider.Request, Provider.Request],
[coreProvider.Compaction, Provider.Compaction],
[coreProvider.Transport, Provider.Transport],
[coreProvider.Settings, Provider.Settings],
[coreProvider.Info, Provider.Info],
[corePermission.Effect, Permission.Effect],
[corePermission.Rule, Permission.Rule],
@@ -1,6 +1,4 @@
import { Effect } from "effect"
import { shell } from "electron"
import { resolveExternalURL } from "../files/external-url"
import { FileRpcs } from "../../shared/ipc-rpc"
import { DesktopFiles, openExternalURL, openLocalFileURL } from "../files"
import { IpcPortHandoff } from "../ipc-transport"
@@ -20,22 +18,13 @@ export const fileHandlers = FileRpcs.toLayer(
)
.pipe(Effect.orDie),
FilesReadPickedFile: ({ token, path }, context) =>
files.readPickedFile(sender(handoff, context).id, token, path).pipe(
Effect.map((buffer) => new Uint8Array(buffer)),
Effect.orDie,
),
files
.readPickedFile(sender(handoff, context).id, token, path)
.pipe(Effect.map((buffer) => new Uint8Array(buffer)), Effect.orDie),
FilesReleasePickedFiles: ({ token }, context) =>
Effect.sync(() => files.releasePickedFiles(sender(handoff, context).id, token)),
FilesSaveFile: ({ options, content }) => files.saveFile(options, content).pipe(Effect.orDie),
FilesOpenExternal: ({ url }) => openExternalURL(url),
FilesOpenBrowser: ({ url }) => {
const target = resolveExternalURL(url)
if (!target || !/^https?:/.test(target)) return Effect.succeed(false)
return Effect.tryPromise(() => shell.openExternal(target)).pipe(
Effect.as(true),
Effect.orElseSucceed(() => false),
)
},
FilesOpenLocalFile: ({ url }) => openLocalFileURL(url),
FilesOpenPath: ({ path, application }) =>
files.openPath(path, application).pipe(
@@ -1,17 +0,0 @@
import { describe, expect, test } from "bun:test"
import { consoleReturnWindow } from "./deep-link"
describe("Console return deep links", () => {
test("reads the originating Desktop window", () => {
expect(consoleReturnWindow("opencode://console/authorized?window=window-a")).toBe("window-a")
expect(consoleReturnWindow("opencode://console/authorized?window=window%20b")).toBe("window b")
})
test("rejects unrelated and malformed links", () => {
expect(consoleReturnWindow("opencode://console/other?window=window-a")).toBeUndefined()
expect(consoleReturnWindow("opencode://other/authorized?window=window-a")).toBeUndefined()
expect(consoleReturnWindow("https://console/authorized?window=window-a")).toBeUndefined()
expect(consoleReturnWindow("not a url")).toBeUndefined()
expect(consoleReturnWindow("opencode://console/authorized")).toBeUndefined()
})
})
@@ -1,11 +0,0 @@
export function consoleReturnWindow(value: string) {
try {
const url = new URL(value)
if (url.protocol !== "opencode:" || url.hostname !== "console" || url.pathname !== "/authorized") return
const id = url.searchParams.get("window")
if (!id || id.length > 256 || /[\u0000-\u001f\u007f]/.test(id)) return
return id
} catch {
return
}
}
+8 -18
View File
@@ -8,11 +8,10 @@ import { emitIpcEvent } from "../ipc-events"
import { DesktopLogging, scoped } from "../native/logging"
import { DesktopStorage } from "../storage"
import { safeWebContentsURL } from "../windows/state"
import { getLastFocusedWindow, getWindowByID, makeMainWindows, setAppQuitting, setRelaunchHandler } from "../windows"
import { getLastFocusedWindow, makeMainWindows, setAppQuitting, setRelaunchHandler } from "../windows"
import { acquireApplicationLock, configureApplication } from "./environment"
import { initializeFirstLaunchOnboarding } from "./onboarding"
import { Shutdown } from "./shutdown"
import { consoleReturnWindow } from "./deep-link"
export interface Interface {
readonly relaunch: () => void
@@ -35,23 +34,11 @@ const runtime = Layer.effect(
const pendingDeepLinks: string[] = []
let shutdownReady = false
const prepareToRestart = shutdown.run.pipe(Effect.ensuring(Effect.sync(() => (shutdownReady = true))))
const focusWindow = (win: BrowserWindow | null) => {
if (!win) return
if (win.isMinimized()) win.restore()
win.show()
win.focus()
}
const emitDeepLinks = (urls: string[]) => {
if (!urls.length) return
pendingDeepLinks.push(...urls)
const target = urls.flatMap((url) => {
const id = consoleReturnWindow(url)
const win = id ? getWindowByID(id) : null
return win ? [win] : []
})[0]
const win = target ?? getLastFocusedWindow()
const win = getLastFocusedWindow()
if (win) emitIpcEvent(win.webContents, new DeepLinksOpened({ urls }))
return win
}
const relaunch = () => {
setAppQuitting()
@@ -70,14 +57,17 @@ const runtime = Layer.effect(
const urls = argv.filter((arg) => arg.startsWith("opencode://"))
if (urls.length) {
runFork(Effect.logInfo("deep link received via second-instance", { urls }))
focusWindow(emitDeepLinks(urls) ?? null)
emitDeepLinks(urls)
}
if (!urls.length) focusWindow(getLastFocusedWindow())
const win = getLastFocusedWindow()
if (!win) return
win.show()
win.focus()
}
const openUrl = (event: Event, url: string) => {
event.preventDefault()
runFork(Effect.logInfo("deep link received via open-url", { url }))
focusWindow(emitDeepLinks([url]) ?? null)
emitDeepLinks([url])
}
const beforeQuit = (event: Event) => {
setAppQuitting()
@@ -3,9 +3,11 @@ export * as DesktopCli from "./desktop-cli"
import { execFile, spawn } from "node:child_process"
import { promisify } from "node:util"
import { app } from "electron"
import { Context, Effect, FileSystem, Layer, Path } from "effect"
import { Context, Effect, FileSystem, Layer, Option, Path } from "effect"
import installer from "../../../../../install?raw"
import { DesktopPaths } from "../paths"
import { BUNDLED_CLI_VERSION_KEY } from "../storage/keys"
import { getStore } from "../storage/store"
import { parseCliVersion } from "./cli-version"
const execFileAsync = promisify(execFile)
@@ -79,11 +81,36 @@ const resolveBundledCli = Effect.fn("DesktopCli.resolveBundled")(function* (isol
? path.join(process.resourcesPath, executableName())
: path.join(paths.developmentResourcesRoot, isolated ? developmentExecutableName() : executableName())
yield* Effect.logInfo("v2 CLI executable resolved", { bundled, packaged: app.isPackaged })
const version = parseCliVersion(yield* run(bundled, ["--version"]))
const version = yield* bundledVersion(bundled)
const binary = app.isPackaged || isolated ? yield* installCli(bundled, version) : bundled
return { version, binary, command: [binary] }
})
// Spawning the bundled executable for `--version` costs ~400 ms of startup on a 200 MB binary, so
// the answer is remembered per executable identity and only re-read after an update replaces it.
const bundledVersion = Effect.fn("DesktopCli.bundledVersion")(function* (bundled: string) {
const fs = yield* FileSystem.FileSystem
const stat = yield* fs.stat(bundled).pipe(Effect.orElseSucceed(() => undefined))
const identity = stat ? `${stat.size}:${Option.getOrUndefined(stat.mtime)?.getTime() ?? ""}` : undefined
const store = getStore()
const cached = store.get(BUNDLED_CLI_VERSION_KEY)
if (identity && isVersionCache(cached) && cached.path === bundled && cached.identity === identity) {
yield* Effect.logInfo("v2 CLI version reused", { version: cached.version })
return cached.version
}
const version = parseCliVersion(yield* run(bundled, ["--version"]))
if (identity) store.set(BUNDLED_CLI_VERSION_KEY, { path: bundled, identity, version } satisfies VersionCache)
return version
})
type VersionCache = { path: string; identity: string; version: string }
function isVersionCache(value: unknown): value is VersionCache {
if (!value || typeof value !== "object") return false
const cache = value as Record<string, unknown>
return typeof cache.path === "string" && typeof cache.identity === "string" && typeof cache.version === "string"
}
export const cleanStages = Effect.fn("DesktopCli.cleanStages")(function* (binary: string) {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
@@ -5,3 +5,4 @@ export const WSL_SERVERS_KEY = "wslServers"
export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled"
export const BACKGROUND_COLOR_KEY = "backgroundColor"
export const WINDOW_IDS_KEY = "windowIds"
export const BUNDLED_CLI_VERSION_KEY = "bundledCliVersion"
@@ -70,12 +70,6 @@ export function getLastFocusedWindow() {
return win
}
export function getWindowByID(id: string) {
const win = registry.get(id)
if (!win || win.isDestroyed()) return null
return win
}
export function setWindowThemeReady(win: BrowserWindow) {
themeReady.get(win)?.()
}
@@ -25,8 +25,6 @@ describe("window registry", () => {
app.registry.register("a", { name: "a" })
app.registry.register("b", { name: "b" })
expect(app.state.stored).toEqual(["a", "b"])
expect(app.registry.get("a")).toEqual({ name: "a" })
expect(app.registry.get("missing")).toBeUndefined()
})
test("forgets a deliberately closed window while others remain open", () => {
@@ -1,6 +1,9 @@
// Tracks open windows and the persisted window id list used to restore
// windows (and their per-window persisted state) across app launches.
export function createWindowRegistry<W>(persistence: { read: () => unknown; write: (ids: string[]) => void }) {
export function createWindowRegistry<W>(persistence: {
read: () => unknown
write: (ids: string[]) => void
}) {
const windows = new Map<string, W>()
let quitting = false
let lastFocusedID: string | undefined
@@ -28,9 +31,6 @@ export function createWindowRegistry<W>(persistence: { read: () => unknown; writ
if (!lastFocusedID) return
return windows.get(lastFocusedID)
},
get(id: string) {
return windows.get(id)
},
closed(id: string) {
windows.delete(id)
if (lastFocusedID === id) lastFocusedID = windows.keys().next().value
@@ -63,7 +63,6 @@ export type ElectronAPI = {
getPathForFile(file: File): string
saveFile(opts: SaveFilePickerOptions, content: string): Promise<boolean>
openExternal(url: string): void
openBrowser(url: string): Promise<boolean>
openLocalFile(url: string): void
openPath(path: string, app?: string): Promise<string | undefined>
revealPath(path: string): Promise<boolean>
-1
View File
@@ -122,7 +122,6 @@ export const api: ElectronAPI = {
getPathForFile: (file) => window.electron.getPathForFile(file),
saveFile: (opts, content) => invoke("FilesSaveFile", { options: opts, content }),
openExternal: (url) => send("FilesOpenExternal", { url }),
openBrowser: (url) => invoke("FilesOpenBrowser", { url }),
openLocalFile: (url) => send("FilesOpenLocalFile", { url }),
openPath: (path, app) => invoke("FilesOpenPath", { path, application: app }).then((value) => value ?? undefined),
revealPath: (path) => invoke("FilesRevealPath", { path }),
@@ -21,7 +21,6 @@ function fileApi(events: string[]) {
getPathForFile: () => "fallback",
saveFile: async () => false,
openExternal: () => {},
openBrowser: async () => true,
openLocalFile: () => {},
resolveAppPath: async () => null,
openPath: async () => undefined,
@@ -34,10 +33,6 @@ function fileApi(events: string[]) {
}
describe("desktop attachment files", () => {
test("reports native browser launch failure to the renderer", async () => {
const files = createDesktopFiles({ ...fileApi([]), openBrowser: async () => false }, "macos", [])
expect(await files.openBrowser("https://opencode.ai/console")).toBe(false)
})
test("reads selected files sequentially and releases the token", async () => {
const events: string[] = []
const files = createDesktopFiles(fileApi(events), "windows")
@@ -11,7 +11,6 @@ type DesktopFileAPI = Pick<
| "getPathForFile"
| "saveFile"
| "openExternal"
| "openBrowser"
| "openLocalFile"
| "resolveAppPath"
| "openPath"
@@ -56,7 +55,6 @@ export function createDesktopFiles(api: DesktopFileAPI, os: DesktopOS) {
saveFile: (options: { title?: string; defaultPath?: string }, content: string) =>
api.saveFile({ title: options.title, defaultPath: options.defaultPath }, content),
openExternal: (url: string) => api.openExternal(url),
openBrowser: (url: string) => api.openBrowser(url),
openLocalFile: (url: string) => api.openLocalFile(url),
async openPath(path: string, app?: string) {
if (os !== "windows") {
@@ -43,10 +43,6 @@ export const FilesSaveFile = Rpc.make("FilesSaveFile", {
export const FilesOpenExternal = Rpc.make("FilesOpenExternal", {
payload: { url: Schema.String },
})
export const FilesOpenBrowser = Rpc.make("FilesOpenBrowser", {
payload: { url: Schema.String },
success: Schema.Boolean,
})
export const FilesOpenLocalFile = Rpc.make("FilesOpenLocalFile", {
payload: { url: Schema.String },
})
@@ -72,7 +68,6 @@ export const FileRpcs = RpcGroup.make(
FilesReleasePickedFiles,
FilesSaveFile,
FilesOpenExternal,
FilesOpenBrowser,
FilesOpenLocalFile,
FilesOpenPath,
FilesRevealPath,
+2 -2
View File
@@ -5,8 +5,8 @@ export default Plugin.define({
id: "opencode.latex",
setup(context) {
const render = createLatexCodeBlockRenderer(context.renderer, () => ({
text: context.theme.text.base,
subdued: context.theme.text.muted,
text: context.theme.text.default,
subdued: context.theme.text.subdued,
}))
context.markdown.registerCodeBlockRenderer("latex", render)
context.markdown.registerCodeBlockRenderer("math", render)
+6 -6
View File
@@ -83,15 +83,15 @@ describe("OpenCode diagram palette", () => {
}
const theme = {
text: {
base: rgb([230, 232, 240]),
muted: rgb([114, 120, 138]),
default: rgb([230, 232, 240]),
subdued: rgb([114, 120, 138]),
feedback: {
info: { base: rgb([40, 120, 220]) },
success: { base: rgb([80, 180, 120]) },
warning: { base: rgb([220, 160, 80]) },
info: { default: rgb([40, 120, 220]) },
success: { default: rgb([80, 180, 120]) },
warning: { default: rgb([220, 160, 80]) },
},
},
background: { base: rgb([250, 250, 250]) },
background: { default: rgb([250, 250, 250]) },
categorical: [accent],
}
const palette = resolveOpenCodeDiagramPalette(theme, mode)
+12 -12
View File
@@ -46,27 +46,27 @@ export function createOpenCodeDiagramPalette(input: OpenCodeDiagramPaletteInput)
export function resolveOpenCodeDiagramPalette(
theme: {
readonly text: {
readonly base: RGBA
readonly muted: RGBA
readonly default: RGBA
readonly subdued: RGBA
readonly feedback: {
readonly info: { readonly base: RGBA }
readonly success: { readonly base: RGBA }
readonly warning: { readonly base: RGBA }
readonly info: { readonly default: RGBA }
readonly success: { readonly default: RGBA }
readonly warning: { readonly default: RGBA }
}
}
readonly background: { readonly base: RGBA }
readonly background: { readonly default: RGBA }
readonly categorical: readonly Readonly<Record<200 | 300 | 700 | 800, RGBA>>[]
},
mode: "dark" | "light",
) {
const accent = theme.categorical[3] ?? theme.categorical[0]!
return createOpenCodeDiagramPalette({
text: theme.text.base,
subdued: theme.text.muted,
info: theme.text.feedback.info.base,
success: theme.text.feedback.success.base,
warning: theme.text.feedback.warning.base,
background: theme.background.base,
text: theme.text.default,
subdued: theme.text.subdued,
info: theme.text.feedback.info.default,
success: theme.text.feedback.success.default,
warning: theme.text.feedback.warning.default,
background: theme.background.default,
accent: {
soft: accent[mode === "dark" ? 300 : 700],
clear: accent[mode === "dark" ? 200 : 800],
+38 -76
View File
@@ -13161,7 +13161,7 @@
"properties": {
"action": {
"type": "string",
"enum": ["provider.use", "permission"]
"enum": ["provider.use"]
},
"resource": {
"type": "string"
@@ -13254,6 +13254,12 @@
"Config.ModelEncoded": {
"type": "object",
"properties": {
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"transport": {
"$ref": "#/components/schemas/Provider.Transport"
},
"modelID": {
"type": "string"
},
@@ -13270,7 +13276,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Config.Provider.Settings"
"type": "object"
},
"headers": {
"type": "object",
@@ -13293,7 +13299,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Config.Provider.Settings"
"type": "object"
},
"headers": {
"type": "object",
@@ -13373,47 +13379,15 @@
"required": ["package"],
"additionalProperties": false
},
"Config.Provider.Settings": {
"Config.ProviderEncoded": {
"type": "object",
"properties": {
"timeout": {
"anyOf": [
{
"type": "number"
},
{
"type": "boolean",
"enum": [false]
}
]
},
"chunkTimeout": {
"type": "number"
},
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"transport": {
"$ref": "#/components/schemas/Provider.Transport"
}
},
"allOf": [
{
"type": "object",
"additionalProperties": {
"anyOf": [
{},
{
"type": "null"
}
]
}
}
]
},
"Config.ProviderEncoded": {
"type": "object",
"properties": {
},
"canonical": {
"type": "string"
},
@@ -13430,7 +13404,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Config.Provider.Settings"
"type": "object"
},
"headers": {
"type": "object",
@@ -15507,8 +15481,14 @@
"package": {
"type": "string"
},
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"transport": {
"$ref": "#/components/schemas/Provider.Transport"
},
"settings": {
"$ref": "#/components/schemas/Provider.Settings"
"type": "object"
},
"headers": {
"type": "object",
@@ -15621,7 +15601,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Provider.Settings"
"type": "object"
},
"headers": {
"type": "object",
@@ -16438,23 +16418,27 @@
{
"type": "object",
"properties": {
"type": {
"mode": {
"type": "string",
"enum": ["summary"]
"enum": ["local"]
}
},
"required": ["type"],
"required": ["mode"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"mode": {
"type": "string",
"enum": ["native"]
"enum": ["provider"]
},
"threshold": {
"type": "integer",
"exclusiveMinimum": 0
}
},
"required": ["type"],
"required": ["mode"],
"additionalProperties": false
}
]
@@ -16481,8 +16465,14 @@
"package": {
"type": "string"
},
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"transport": {
"$ref": "#/components/schemas/Provider.Transport"
},
"settings": {
"$ref": "#/components/schemas/Provider.Settings"
"type": "object"
},
"headers": {
"type": "object",
@@ -16517,35 +16507,7 @@
"additionalProperties": false
},
"Provider.Settings": {
"type": "object",
"properties": {
"timeout": {
"anyOf": [
{
"type": "number"
},
{
"type": "boolean",
"enum": [false]
}
]
},
"chunkTimeout": {
"type": "number"
},
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"transport": {
"$ref": "#/components/schemas/Provider.Transport"
}
},
"allOf": [
{
"type": "object",
"additionalProperties": {}
}
]
"type": "object"
},
"Provider.Transport": {
"type": "string",
+10 -12
View File
@@ -6,21 +6,10 @@ import { Capabilities, Compatibility, Family, ID, VariantID } from "../model.js"
import { Provider } from "../provider.js"
import { optional } from "../schema.js"
export const Settings = Schema.StructWithRest(
Schema.Struct({
timeout: Schema.Union([Schema.Finite, Schema.Literal(false)]).pipe(optional),
chunkTimeout: Schema.Finite.pipe(optional),
compaction: Provider.Compaction.pipe(optional),
transport: Provider.Transport.pipe(optional),
}),
[Schema.Record(Schema.String, Schema.UndefinedOr(Schema.Json))],
).annotate({ identifier: "Config.Provider.Settings" })
export type Settings = typeof Settings.Type
const JsonRecord = Schema.Record(Schema.String, Schema.Json)
export const Overlays = {
settings: Settings.pipe(optional),
settings: JsonRecord.pipe(optional),
headers: Schema.Record(Schema.String, Schema.String).pipe(optional),
body: JsonRecord.pipe(optional),
}
@@ -52,6 +41,10 @@ class Limit extends Schema.Class<Limit>("Config.Model.Limit")({
}) {}
class Model extends Schema.Class<Model>("Config.Model")({
compaction: Provider.Compaction.pipe(optional),
transport: Provider.Transport.pipe(optional).annotate({
description: "Session transport for this model. Defaults to the provider transport.",
}),
modelID: ID.pipe(optional),
family: Family.pipe(optional),
name: Schema.String.pipe(optional),
@@ -69,6 +62,11 @@ class Model extends Schema.Class<Model>("Config.Model")({
}) {}
export class Info extends Schema.Class<Info>("Config.Provider")({
compaction: Provider.Compaction.pipe(optional),
transport: Provider.Transport.pipe(optional).annotate({
description:
"Session transport for this provider's models. Defaults to the built-in policy; \"websocket\" on a route without a WebSocket channel warns and falls back to HTTP.",
}),
canonical: Provider.ID.pipe(optional),
name: Schema.String.pipe(optional),
env: Schema.String.pipe(Schema.Array, optional),
+3
View File
@@ -110,6 +110,9 @@ export const Info = Schema.Struct({
name: Schema.String,
compatibility: Compatibility.pipe(optional),
package: Provider.Package.pipe(optional),
compaction: Provider.Compaction.pipe(optional),
/** Session transport; omitted inherits the provider transport, then defaults to HTTP. */
transport: Provider.Transport.pipe(optional),
...Provider.Overlays,
capabilities: Capabilities,
variants: Schema.Array(Variant),
+12 -19
View File
@@ -2,7 +2,7 @@ export * as Provider from "./provider.js"
import { Effect, Schema } from "effect"
import { Integration } from "./integration.js"
import { optional, statics } from "./schema.js"
import { optional, PositiveInt, statics } from "./schema.js"
import { ephemeral, inventory } from "./event.js"
export const ID = Schema.String.pipe(
@@ -32,35 +32,25 @@ export type Package = typeof Package.Type
export const Activation = Schema.Literals(["auto", "enabled", "disabled"])
export type Activation = typeof Activation.Type
export const Compaction = Schema.Union([
Schema.Struct({ type: Schema.Literal("summary") }),
Schema.Struct({ type: Schema.Literal("native") }),
])
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Provider.Compaction" })
export type Compaction = typeof Compaction.Type
export const Compaction = Schema.Union([
Schema.Struct({ mode: Schema.Literal("local") }),
Schema.Struct({ mode: Schema.Literal("provider"), threshold: PositiveInt.pipe(optional) }),
]).annotate({ identifier: "Provider.Compaction" })
/** "websocket" on a route without a WebSocket channel warns and falls back to HTTP. */
export const Transport = Schema.Literals(["http", "websocket"]).annotate({ identifier: "Provider.Transport" })
export type Transport = typeof Transport.Type
export const Settings = Schema.StructWithRest(
Schema.Struct({
timeout: Schema.Union([Schema.Finite, Schema.Literal(false)]).pipe(optional),
chunkTimeout: Schema.Finite.pipe(optional),
compaction: Compaction.pipe(optional),
transport: Transport.pipe(optional),
}),
[Schema.Record(Schema.String, Schema.Any)],
).annotate({ identifier: "Provider.Settings" })
export type Settings = typeof Settings.Type
export const Overlays = {
settings: Settings.pipe(optional),
settings: Schema.Record(Schema.String, Schema.Any).pipe(optional),
headers: Schema.Record(Schema.String, Schema.String).pipe(optional),
body: Schema.Record(Schema.String, Schema.Any).pipe(optional),
}
export const Settings = Schema.Record(Schema.String, Schema.Any).annotate({ identifier: "Provider.Settings" })
export type Settings = typeof Settings.Type
export interface Request extends Schema.Schema.Type<typeof Request> {}
export const Request = Schema.Struct({
settings: Settings.pipe(Schema.withConstructorDefault(Effect.succeed({}))),
@@ -76,6 +66,9 @@ export const Info = Schema.Struct({
name: Schema.String,
activation: Activation,
package: Package,
compaction: Compaction.pipe(optional),
/** Session transport for this provider's models; omitted means HTTP. */
transport: Transport.pipe(optional),
...Overlays,
})
.annotate({ identifier: "Provider.Info" })
@@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test"
import { DateTime, Schema } from "effect"
import { Agent } from "../src/agent.js"
import { ConfigAgent } from "../src/config/agent.js"
import { ConfigProvider } from "../src/config/provider.js"
import { FileSystem } from "../src/filesystem.js"
import { Form } from "../src/form.js"
import { Mcp } from "../src/mcp.js"
@@ -169,7 +168,6 @@ describe("contract hygiene", () => {
test("reusable public identifiers are stable and unique", () => {
const identifiers = [
Agent.Color,
ConfigProvider.Settings,
FileSystem.Submatch,
Form.Field,
Form.Fields,
@@ -228,7 +226,7 @@ describe("contract hygiene", () => {
}
})
test("current source limits Any to reviewed boundaries and avoids mutable contract wrappers", async () => {
test("current source limits Any to provider options and avoids mutable contract wrappers", async () => {
const files = [...new Bun.Glob("*.ts").scanSync(new URL("../src", import.meta.url).pathname)].filter(
(file) => !file.endsWith("-v1.ts"),
)
@@ -239,12 +237,11 @@ describe("contract hygiene", () => {
expect(
sources
.filter((item) => item.file !== "provider.ts" && item.file !== "integration.ts")
.filter((item) => item.file !== "provider.ts")
.map((item) => item.source)
.join("\n"),
).not.toContain("Schema.Any")
expect(sources.find((item) => item.file === "provider.ts")?.source.match(/Schema\.Any/g)).toHaveLength(3)
expect(sources.find((item) => item.file === "integration.ts")?.source.match(/Schema\.Any/g)).toHaveLength(2)
expect(sources.find((item) => item.file === "provider.ts")?.source.match(/Schema\.Any/g)).toHaveLength(4)
expect(source).not.toContain("Schema.mutable")
})
+19 -14
View File
@@ -56,16 +56,23 @@ describe("Model.Compatibility", () => {
})
describe("Model.Info", () => {
test("provider compaction policy is a typed setting", () => {
test("provider compaction policy is optional and uses the canonical closed schema", () => {
const model = Model.Info.default(Provider.ID.openai, Model.ID.make("gpt-5.4-mini"))
expect(Schema.encodeSync(Model.Info)({ ...model, settings: { compaction: undefined } }).settings).toEqual({})
expect(
Schema.decodeUnknownSync(Model.Info)({ ...model, settings: { compaction: { type: "native" } } }).settings,
).toEqual({
compaction: { type: "native" },
expect(Schema.encodeSync(Model.Info)({ ...model, compaction: undefined })).not.toHaveProperty("compaction")
expect(Schema.decodeUnknownSync(Model.Info)({ ...model, compaction: { mode: "provider" } }).compaction).toEqual({
mode: "provider",
})
expect(Schema.decodeUnknownSync(Provider.Compaction)({ type: "summary" })).toEqual({ type: "summary" })
expect(() => Schema.decodeUnknownSync(Provider.Compaction)({ type: "automatic" })).toThrow()
expect(Schema.decodeUnknownSync(Provider.Compaction)({ mode: "local" })).toEqual({ mode: "local" })
expect(Schema.encodeSync(Provider.Compaction)({ mode: "provider", threshold: undefined })).toEqual({
mode: "provider",
})
expect(Schema.decodeUnknownSync(Provider.Compaction)({ mode: "provider", threshold: 120_000 })).toEqual({
mode: "provider",
threshold: 120_000,
})
for (const threshold of [0, -1, 1.5])
expect(() => Schema.decodeUnknownSync(Provider.Compaction)({ mode: "provider", threshold })).toThrow()
expect(() => Schema.decodeUnknownSync(Provider.Compaction)({ mode: "automatic" })).toThrow()
})
test("uses practical token limits for unknown models", () => {
@@ -76,12 +83,10 @@ describe("Model.Info", () => {
})
describe("Model.Capabilities", () => {
test("decodes the optional transport setting", () => {
test("decodes the optional transport preference", () => {
const model = Model.Info.default(Provider.ID.openai, Model.ID.make("gpt-5.4-mini"))
expect(Schema.encodeSync(Model.Info)({ ...model, settings: { transport: undefined } }).settings).toEqual({})
expect(Schema.decodeUnknownSync(Model.Info)({ ...model, settings: { transport: "websocket" } }).settings).toEqual({
transport: "websocket",
})
expect(() => Schema.decodeUnknownSync(Model.Info)({ ...model, settings: { transport: "sse" } })).toThrow()
expect(Schema.encodeSync(Model.Info)({ ...model, transport: undefined })).not.toHaveProperty("transport")
expect(Schema.decodeUnknownSync(Model.Info)({ ...model, transport: "websocket" }).transport).toBe("websocket")
expect(() => Schema.decodeUnknownSync(Model.Info)({ ...model, transport: "sse" })).toThrow()
})
})
@@ -43,9 +43,9 @@ story("renders aliased and long custom model notices", async ({ mount, page }) =
await expect(short.getByText(`Switched to ${shortName}`, { exact: true })).toBeVisible()
await expect(short.locator('[data-slot="session-timeline-notice-variant"]')).toHaveText("xhigh")
await expect(timeline.getByText("fast-nano", { exact: true })).toHaveCount(0)
await expect(short.locator('[data-component="logo-mark"]')).toBeVisible()
await expect(short.locator('[data-component="provider-icon"]')).toBeVisible()
await expect(long).toBeVisible()
await expect(long.locator('[data-component="logo-mark"]')).toBeVisible()
await expect(long.locator('[data-component="provider-icon"]')).toBeVisible()
await expect(long.locator('[data-slot="session-timeline-notice-variant"]')).toHaveCount(0)
await expect(long.locator("[title]")).toHaveAttribute("title", `Switched to ${longName}`)
await expect.poll(() => long.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
@@ -1,15 +1,13 @@
import { Mark } from "@opencode/ui/logo"
import { ProviderIcon } from "@opencode/ui/provider-icon"
import { Show } from "solid-js"
export function TimelineSeparator(props: { label: string; logo?: boolean; variant?: string }) {
export function TimelineSeparator(props: { label: string; providerID?: string; variant?: string }) {
return (
<div class="flex h-8 w-full items-center gap-3 text-v2-text-text-faint">
<span class="h-px min-w-0 flex-1 bg-v2-border-border-strong" />
<span class="flex min-w-0 items-center gap-1 text-[13px] font-[440] leading-text-compact tracking-[-0.04px]">
<Show when={props.logo}>
<span class="flex size-4 shrink-0 items-center justify-center" aria-hidden="true">
<Mark class="h-4 w-[13px]" />
</span>
<Show when={props.providerID}>
{(providerID) => <ProviderIcon id={providerID()} class="text-v2-icon-icon-faint" aria-hidden="true" />}
</Show>
<span class="flex min-w-0 items-center gap-1.5">
<bdi dir="auto" class="truncate" title={props.label}>
@@ -392,6 +392,7 @@ export function createSessionTimelineRowRenderer(input: {
if (value?.type !== "model-switched") return undefined
const match = data.store.provider?.all?.get(value.model.providerID)
return {
providerID: value.model.providerID,
variant: value.model.variant,
label: i18n.t("ui.sessionTimeline.notice.modelSwitched", {
model: match?.models?.[value.model.id]?.name ?? value.model.id,
@@ -507,7 +508,7 @@ export function createSessionTimelineRowRenderer(input: {
>
{(model) => (
<div data-slot="session-timeline-notice" data-type="model-switched" class={`w-full py-2 ${inset()}`}>
<TimelineSeparator label={model().label} logo variant={model().variant} />
<TimelineSeparator label={model().label} providerID={model().providerID} variant={model().variant} />
</div>
)}
</Show>
+185 -179
View File
@@ -1,4 +1,4 @@
import type { BaseThemeDefinition, HueName, Mode, ThemeDefinition, ThemeDocument } from "./schema.js"
import type { HueName, ThemeDocument } from "./schema.js"
export const DEFAULT_CATEGORICAL = [
"blue",
@@ -9,96 +9,97 @@ export const DEFAULT_CATEGORICAL = [
"cyan",
] as const satisfies readonly HueName[]
const modes = {
export const DEFAULT_THEME = {
version: 2,
light: {
hue: {
gray: {
100: "#111827",
200: "#1f2937",
300: "#374151",
400: "#4b5563",
100: "#f3f4f6",
200: "#e5e7eb",
300: "#d1d5db",
400: "#9ca3af",
500: "#6b7280",
600: "#9ca3af",
700: "#d1d5db",
800: "#e5e7eb",
900: "#f3f4f6",
600: "#4b5563",
700: "#374151",
800: "#1f2937",
900: "#111827",
},
red: {
100: "#7f1d1d",
200: "#991b1b",
300: "#b91c1c",
400: "#dc2626",
100: "#fee2e2",
200: "#fecaca",
300: "#fca5a5",
400: "#f87171",
500: "#ef4444",
600: "#f87171",
700: "#fca5a5",
800: "#fecaca",
900: "#fee2e2",
600: "#dc2626",
700: "#b91c1c",
800: "#991b1b",
900: "#7f1d1d",
},
orange: {
100: "#7c2d12",
200: "#9a3412",
300: "#c2410c",
400: "#ea580c",
100: "#ffedd5",
200: "#fed7aa",
300: "#fdba74",
400: "#fb923c",
500: "#f97316",
600: "#fb923c",
700: "#fdba74",
800: "#fed7aa",
900: "#ffedd5",
600: "#ea580c",
700: "#c2410c",
800: "#9a3412",
900: "#7c2d12",
},
yellow: {
100: "#713f12",
200: "#854d0e",
300: "#a16207",
400: "#ca8a04",
100: "#fef9c3",
200: "#fef08a",
300: "#fde047",
400: "#facc15",
500: "#eab308",
600: "#facc15",
700: "#fde047",
800: "#fef08a",
900: "#fef9c3",
600: "#ca8a04",
700: "#a16207",
800: "#854d0e",
900: "#713f12",
},
green: {
100: "#14532d",
200: "#166534",
300: "#15803d",
400: "#16a34a",
100: "#dcfce7",
200: "#bbf7d0",
300: "#86efac",
400: "#4ade80",
500: "#22c55e",
600: "#4ade80",
700: "#86efac",
800: "#bbf7d0",
900: "#dcfce7",
600: "#16a34a",
700: "#15803d",
800: "#166534",
900: "#14532d",
},
cyan: {
100: "#164e63",
200: "#155e75",
300: "#0e7490",
400: "#0891b2",
100: "#cffafe",
200: "#a5f3fc",
300: "#67e8f9",
400: "#22d3ee",
500: "#06b6d4",
600: "#22d3ee",
700: "#67e8f9",
800: "#a5f3fc",
900: "#cffafe",
600: "#0891b2",
700: "#0e7490",
800: "#155e75",
900: "#164e63",
},
blue: {
100: "#1e3a8a",
200: "#1e40af",
300: "#1d4ed8",
400: "#2563eb",
100: "#dbeafe",
200: "#bfdbfe",
300: "#93c5fd",
400: "#60a5fa",
500: "#3b82f6",
600: "#60a5fa",
700: "#93c5fd",
800: "#bfdbfe",
900: "#dbeafe",
600: "#2563eb",
700: "#1d4ed8",
800: "#1e40af",
900: "#1e3a8a",
},
purple: {
100: "#581c87",
200: "#6b21a8",
300: "#7e22ce",
400: "#9333ea",
100: "#f3e8ff",
200: "#e9d5ff",
300: "#d8b4fe",
400: "#c084fc",
500: "#a855f7",
600: "#c084fc",
700: "#d8b4fe",
800: "#e9d5ff",
900: "#f3e8ff",
600: "#9333ea",
700: "#7e22ce",
800: "#6b21a8",
900: "#581c87",
},
accent: "$hue.blue",
interactive: "$hue.blue",
@@ -106,122 +107,129 @@ const modes = {
},
categorical: DEFAULT_CATEGORICAL,
text: {
base: "$hue.neutral.200",
muted: "$hue.neutral.400",
default: "$hue.neutral.800",
subdued: "$hue.neutral.600",
action: {
primary: { base: "$hue.neutral.800", $disabled: "$hue.neutral.500" },
secondary: { base: "$text.muted", $hovered: "$text.base" },
destructive: { base: "$hue.red.800", $disabled: "$hue.neutral.500" },
primary: { default: "$hue.neutral.200", $disabled: "$hue.neutral.500" },
secondary: { default: "$text.subdued", $hovered: "$text.default" },
destructive: { default: "$hue.red.200", $disabled: "$hue.neutral.500" },
},
formfield: {
base: "$hue.neutral.200",
$focused: "$text.action.primary.base",
$pressed: "$hue.neutral.800",
default: "$hue.neutral.800",
$focused: "$text.action.primary.default",
$pressed: "$hue.neutral.200",
$disabled: "$hue.neutral.500",
$selected: "$hue.interactive.300",
$selected: "$hue.interactive.700",
},
status: {
running: "$hue.interactive.200",
running: "$hue.interactive.800",
question: "$text.status.unread",
permission: "$text.status.unread",
unread: "$hue.accent.200",
unread: "$hue.accent.800",
},
feedback: {
error: { base: "$hue.red.300", muted: "$hue.red.400" },
warning: { base: "$hue.yellow.200", muted: "$hue.yellow.300" },
success: { base: "$hue.green.300", muted: "$hue.green.400" },
info: { base: "$hue.cyan.300", muted: "$hue.cyan.400" },
error: { default: "$hue.red.700", subdued: "$hue.red.600" },
warning: { default: "$hue.yellow.800", subdued: "$hue.yellow.700" },
success: { default: "$hue.green.700", subdued: "$hue.green.600" },
info: { default: "$hue.cyan.700", subdued: "$hue.cyan.600" },
},
},
background: {
base: "$hue.neutral.800",
default: "$hue.neutral.200",
raised: {
base: "$hue.neutral.700",
high: "$hue.neutral.600",
base: "$hue.neutral.300",
high: "$hue.neutral.400",
max: "$hue.neutral.500",
},
action: {
primary: {
base: "$hue.interactive.400",
$hovered: "$hue.interactive.300",
$focused: "$hue.interactive.300",
$pressed: "$hue.interactive.200",
$selected: "$hue.interactive.300",
$disabled: "$hue.neutral.700",
default: "$hue.interactive.600",
$hovered: "$hue.interactive.700",
$focused: "$hue.interactive.700",
$pressed: "$hue.interactive.800",
$selected: "$hue.interactive.700",
$disabled: "$hue.neutral.300",
},
secondary: { base: "transparent" },
secondary: { default: "transparent" },
destructive: {
base: "$hue.red.400",
$hovered: "$hue.red.300",
$focused: "$hue.red.300",
$pressed: "$hue.red.200",
$selected: "$hue.red.300",
$disabled: "$hue.neutral.700",
default: "$hue.red.600",
$hovered: "$hue.red.700",
$focused: "$hue.red.700",
$pressed: "$hue.red.800",
$selected: "$hue.red.700",
$disabled: "$hue.neutral.300",
},
},
formfield: {
base: "$background.base",
default: "$background.default",
$hovered: "$background.raised.base",
$focused: "$background.action.primary.base",
$pressed: "$hue.interactive.200",
$disabled: "$background.base",
$selected: "$background.formfield.base",
$focused: "$background.action.primary.default",
$pressed: "$hue.interactive.800",
$disabled: "$background.default",
$selected: "$background.formfield.default",
},
feedback: {
error: { base: "$background.base" },
warning: { base: "$background.base" },
success: { base: "$background.base" },
info: { base: "$background.base" },
error: { default: "$background.default" },
warning: { default: "$background.default" },
success: { default: "$background.default" },
info: { default: "$background.default" },
},
},
border: { base: "$hue.neutral.700" },
scrollbar: { base: "$hue.neutral.600" },
border: { default: "$hue.neutral.300" },
scrollbar: { default: "$hue.neutral.400" },
diff: {
text: {
added: "$hue.green.300",
removed: "$hue.red.300",
context: "$hue.neutral.100",
hunkHeader: "$hue.purple.400",
added: "$hue.green.700",
removed: "$hue.red.700",
context: "$hue.neutral.900",
hunkHeader: "$hue.purple.600",
},
background: { added: "$hue.green.900", removed: "$hue.red.900", context: "$hue.neutral.900" },
highlight: { added: "$hue.green.400", removed: "$hue.red.400" },
background: { added: "$hue.green.100", removed: "$hue.red.100", context: "$hue.neutral.100" },
highlight: { added: "$hue.green.600", removed: "$hue.red.600" },
lineNumber: {
text: "$hue.neutral.400",
background: { added: "$hue.green.800", removed: "$hue.red.800" },
text: "$hue.neutral.600",
background: { added: "$hue.green.200", removed: "$hue.red.200" },
},
},
syntax: {
comment: "$hue.neutral.400",
keyword: "$hue.purple.400",
function: "$hue.accent.400",
variable: "$hue.neutral.100",
string: "$hue.green.300",
number: "$hue.yellow.200",
comment: "$hue.neutral.600",
keyword: "$hue.purple.600",
function: "$hue.accent.600",
variable: "$hue.neutral.900",
string: "$hue.green.700",
number: "$hue.yellow.800",
type: "$hue.yellow.500",
operator: "$hue.cyan.400",
punctuation: "$hue.neutral.100",
operator: "$hue.cyan.600",
punctuation: "$hue.neutral.900",
},
markdown: {
text: "$hue.neutral.100",
heading: "$hue.purple.400",
link: "$hue.accent.400",
linkText: "$hue.cyan.400",
code: "$hue.green.300",
blockQuote: "$hue.neutral.400",
text: "$hue.neutral.900",
heading: "$hue.purple.600",
link: "$hue.accent.600",
linkText: "$hue.cyan.600",
code: "$hue.green.700",
blockQuote: "$hue.neutral.600",
emphasis: "$hue.yellow.500",
strong: "$hue.neutral.100",
horizontalRule: "$hue.neutral.700",
listItem: "$hue.accent.400",
listEnumeration: "$hue.cyan.400",
image: "$hue.accent.400",
imageText: "$hue.cyan.400",
codeBlock: "$hue.neutral.100",
strong: "$hue.neutral.900",
horizontalRule: "$hue.neutral.300",
listItem: "$hue.accent.600",
listEnumeration: "$hue.cyan.600",
image: "$hue.accent.600",
imageText: "$hue.cyan.600",
codeBlock: "$hue.neutral.900",
},
"@dialog": {
text: { action: { primary: { base: "$hue.neutral.900" } } },
"@context:elevated": {
text: { action: { primary: { default: "$hue.neutral.100" } } },
background: {
base: "$background.raised.base",
action: { primary: { base: "$hue.interactive.500", $hovered: "$background.raised.high" } },
default: "$background.raised.base",
action: { primary: { default: "$hue.interactive.500", $hovered: "$background.raised.high" } },
},
},
"@context:overlay": {
text: { action: { primary: { default: "$hue.neutral.100" } } },
background: {
default: "$background.raised.high",
action: { primary: { default: "$hue.interactive.500" } },
},
},
},
@@ -321,16 +329,16 @@ const modes = {
},
categorical: DEFAULT_CATEGORICAL,
text: {
base: "$hue.neutral.200",
muted: "$hue.neutral.400",
default: "$hue.neutral.200",
subdued: "$hue.neutral.400",
action: {
primary: { base: "$hue.neutral.200", $disabled: "$hue.neutral.500" },
secondary: { base: "$text.muted", $hovered: "$text.base" },
destructive: { base: "$hue.red.200", $disabled: "$hue.neutral.500" },
primary: { default: "$hue.neutral.200", $disabled: "$hue.neutral.500" },
secondary: { default: "$text.subdued", $hovered: "$text.default" },
destructive: { default: "$hue.red.200", $disabled: "$hue.neutral.500" },
},
formfield: {
base: "$hue.neutral.200",
$focused: "$text.action.primary.base",
default: "$hue.neutral.200",
$focused: "$text.action.primary.default",
$pressed: "$hue.neutral.200",
$disabled: "$hue.neutral.500",
$selected: "$hue.interactive.500",
@@ -342,14 +350,14 @@ const modes = {
unread: "$hue.accent.200",
},
feedback: {
error: { base: "$hue.red.300", muted: "$hue.red.400" },
warning: { base: "$hue.yellow.200", muted: "$hue.yellow.300" },
success: { base: "$hue.green.300", muted: "$hue.green.400" },
info: { base: "$hue.cyan.300", muted: "$hue.cyan.400" },
error: { default: "$hue.red.300", subdued: "$hue.red.400" },
warning: { default: "$hue.yellow.200", subdued: "$hue.yellow.300" },
success: { default: "$hue.green.300", subdued: "$hue.green.400" },
info: { default: "$hue.cyan.300", subdued: "$hue.cyan.400" },
},
},
background: {
base: "$hue.neutral.800",
default: "$hue.neutral.800",
raised: {
base: "$hue.neutral.700",
high: "$hue.neutral.600",
@@ -357,16 +365,16 @@ const modes = {
},
action: {
primary: {
base: "$hue.interactive.500",
default: "$hue.interactive.500",
$hovered: "$hue.interactive.600",
$focused: "$hue.interactive.600",
$pressed: "$hue.interactive.800",
$selected: "$hue.interactive.600",
$disabled: "$hue.neutral.800",
},
secondary: { base: "transparent" },
secondary: { default: "transparent" },
destructive: {
base: "$hue.red.600",
default: "$hue.red.600",
$hovered: "$hue.red.700",
$focused: "$hue.red.700",
$pressed: "$hue.red.800",
@@ -375,22 +383,22 @@ const modes = {
},
},
formfield: {
base: "$background.base",
default: "$background.default",
$hovered: "$background.raised.base",
$focused: "$background.action.primary.base",
$focused: "$background.action.primary.default",
$pressed: "$hue.interactive.800",
$disabled: "$background.base",
$selected: "$background.formfield.base",
$disabled: "$background.default",
$selected: "$background.formfield.default",
},
feedback: {
error: { base: "$background.base" },
warning: { base: "$background.base" },
success: { base: "$background.base" },
info: { base: "$background.base" },
error: { default: "$background.default" },
warning: { default: "$background.default" },
success: { default: "$background.default" },
info: { default: "$background.default" },
},
},
border: { base: "$hue.neutral.700" },
scrollbar: { base: "$hue.neutral.600" },
border: { default: "$hue.neutral.700" },
scrollbar: { default: "$hue.neutral.600" },
diff: {
text: {
added: "$hue.green.300",
@@ -432,21 +440,19 @@ const modes = {
imageText: "$hue.cyan.400",
codeBlock: "$hue.neutral.100",
},
"@dialog": {
text: { action: { primary: { base: "$hue.neutral.200" } } },
"@context:elevated": {
text: { action: { primary: { default: "$hue.neutral.200" } } },
background: {
base: "$background.raised.base",
action: { primary: { base: "$hue.interactive.400", $hovered: "$background.raised.high" } },
default: "$background.raised.base",
action: { primary: { default: "$hue.interactive.400", $hovered: "$background.raised.high" } },
},
},
"@context:overlay": {
text: { action: { primary: { default: "$hue.neutral.200" } } },
background: {
default: "$background.raised.high",
action: { primary: { default: "$hue.interactive.400" } },
},
},
},
} satisfies Record<Mode, ThemeDefinition>
const { hue: _, ...base } = modes.light
export const DEFAULT_THEME = {
version: 2,
base: base satisfies BaseThemeDefinition,
light: { hue: modes.light.hue },
dark: modes.dark,
} satisfies ThemeDocument
+12 -8
View File
@@ -11,7 +11,11 @@ export function expandTheme<Definition extends ModeDefinition>(definition: Defin
return {
...definition,
...expandTokens(definition),
...(definition["@dialog"] ? { "@dialog": expandTokens(definition["@dialog"]) } : {}),
...Object.fromEntries(
Object.entries(definition)
.filter(([key]) => key.startsWith("@context:"))
.map(([key, value]) => [key, expandTokens(value as ThemeTokensDefinition)]),
),
}
}
@@ -27,7 +31,7 @@ export function mergeTheme(...values: unknown[]): Record<string, unknown> {
return values.reduce<Record<string, unknown>>((result, value) => {
if (!isRecord(value)) return result
return Object.entries(value).reduce<Record<string, unknown>>((next, [key, item]) => {
if (item === undefined) return next
if (item === undefined || key === "mergeMode") return next
return {
...next,
[key]: isRecord(item) ? mergeTheme(next[key], item) : item,
@@ -40,7 +44,7 @@ function expandText(definition: TextDefinition | undefined): TextDefinition | un
if (!definition) return
return {
...definition,
muted: definition.muted ?? (definition.base ? "$text.base" : undefined),
subdued: definition.subdued ?? (definition.default ? "$text.default" : undefined),
action: expandActions(definition.action, "text.action"),
formfield: expandFormfield(definition.formfield, "text.formfield"),
feedback: definition.feedback
@@ -50,7 +54,7 @@ function expandText(definition: TextDefinition | undefined): TextDefinition | un
kind,
{
...feedback,
muted: feedback.muted ?? (feedback.base ? `$text.feedback.${kind}.base` : undefined),
subdued: feedback.subdued ?? (feedback.default ? `$text.feedback.${kind}.default` : undefined),
},
]
}),
@@ -69,11 +73,11 @@ function expandBackground(definition: BackgroundDefinition | undefined): Backgro
}
function expandFormfield(definition: StatefulColorDefinition | undefined, path: string) {
if (!definition?.base) return definition
if (!definition?.default) return definition
return {
...definition,
...Object.fromEntries(
ActionState.literals.map((state) => [`$${state}`, definition[`$${state}`] ?? `$${path}.base`]),
ActionState.literals.map((state) => [`$${state}`, definition[`$${state}`] ?? `$${path}.default`]),
),
}
}
@@ -85,13 +89,13 @@ function expandActions<Definition extends Partial<Record<string, StatefulColorDe
if (!definition) return
return Object.fromEntries(
Object.entries(definition).map(([variant, value]) => {
if (!value?.base) return [variant, value]
if (!value?.default) return [variant, value]
return [
variant,
{
...value,
...Object.fromEntries(
ActionState.literals.map((state) => [`$${state}`, value[`$${state}`] ?? `$${path}.${variant}.base`]),
ActionState.literals.map((state) => [`$${state}`, value[`$${state}`] ?? `$${path}.${variant}.default`]),
),
},
]
+59
View File
@@ -0,0 +1,59 @@
import type { Mode, ThemeTokensDefinition } from "./index.js"
import { DEFAULT_THEME } from "./defaults.js"
import { ActionVariant, FeedbackKind } from "./schema.js"
export function fallback(mode: Mode): ThemeTokensDefinition {
const red = "#ff0000"
return {
text: {
default: red,
action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])),
formfield: { default: red },
status: DEFAULT_THEME[mode].text.status,
feedback: Object.fromEntries(FeedbackKind.literals.map((kind) => [kind, { default: red }])),
},
background: {
default: red,
raised: { base: red, high: red, max: red },
action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])),
formfield: { default: red },
feedback: Object.fromEntries(FeedbackKind.literals.map((kind) => [kind, { default: red }])),
},
border: { default: red },
scrollbar: { default: red },
diff: {
text: { added: red, removed: red, context: red, hunkHeader: red },
background: { added: red, removed: red, context: red },
highlight: { added: red, removed: red },
lineNumber: { text: red, background: { added: red, removed: red } },
},
syntax: {
comment: red,
keyword: red,
function: red,
variable: red,
string: red,
number: red,
type: red,
operator: red,
punctuation: red,
},
markdown: {
text: red,
heading: red,
link: red,
linkText: red,
code: red,
blockQuote: red,
emphasis: red,
strong: red,
horizontalRule: red,
listItem: red,
listEnumeration: red,
image: red,
imageText: red,
codeBlock: red,
},
}
}
+5 -3
View File
@@ -3,7 +3,6 @@ export {
type ActionStateKey,
ActionVariant,
BaseHue,
BaseThemeDefinition,
CategoricalDefinition,
FeedbackKind,
FormfieldState,
@@ -14,24 +13,27 @@ export {
MarkdownDefinition,
MarkdownToken,
ModeDefinition,
SurfaceName,
SyntaxDefinition,
SyntaxToken,
ThemeDefinition,
ThemeDocument,
type BackgroundDefinition,
type DiffDefinition,
type FileThemeDefinition,
type FormfieldColorDefinition,
type HueDefinition,
type HueOverrideDefinition,
type MergeModeDefinition,
type Mode,
type StatefulColorDefinition,
type ContextKey,
type TextDefinition,
type ThemeTokensDefinition,
} from "./schema.js"
export type {
ActionStates,
Categorical,
ContextName,
FormfieldColor,
Hue,
HueSource,
+44 -57
View File
@@ -1,30 +1,30 @@
import { RGBA } from "@opentui/core"
import { Schema } from "effect"
import { expandTheme, mergeTheme } from "./expand.js"
import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js"
import { expandTheme, expandTokens, mergeTheme } from "./expand.js"
import { fallback } from "./fallback.js"
import {
ActionState,
ActionVariant,
BaseHue,
FeedbackKind,
HueAlias,
HueStep,
SurfaceName,
ThemeDefinition,
ThemeDocument,
} from "./schema.js"
import type {
ActionStateKey,
ActionStates,
ContextName,
HueDefinition,
HueScale,
Mode,
ResolvedActionState,
ResolvedTheme,
ResolvedThemeTokens,
StatefulColor,
StatefulColorDefinition,
ThemeTokensDefinition,
} from "./index.js"
import { selectThemeMode } from "./select.js"
import { selectTheme, selectThemeMode } from "./select.js"
const decodeThemeDefinitionSchema = Schema.decodeUnknownSync(ThemeDefinition, { reportInput: true })
@@ -42,10 +42,17 @@ export function themeDecodeError(error: unknown, name: string) {
return new Error(`Invalid theme: ${name} ${value} is an invalid value`, { cause: error })
}
export function resolveThemeDocument(document: ThemeDocument, mode?: Mode) {
export function resolveThemeDocument(document: ThemeDocument, mode?: "light" | "dark") {
const selected = selectThemeMode(document, mode)
const definition = expandTheme(selected.theme)
return resolveExpandedTheme(definition)
const definition = selected.expanded ? selected.theme : expandTheme(selected.theme)
const defaults = expandTheme(selectTheme(DEFAULT_THEME, selected.mode))
const core = expandTokens(fallback(selected.mode))
const merged = document.standalone ? mergeTheme(core, definition) : mergeTheme(core, defaults, definition)
if (!merged["hue"]) throw new Error("Standalone themes must provide hues")
return resolveExpandedTheme({
...merged,
categorical: merged["categorical"] ?? DEFAULT_CATEGORICAL,
} as ThemeDefinition)
}
export function resolveTheme(definition: ThemeDefinition): ResolvedTheme {
@@ -54,16 +61,21 @@ export function resolveTheme(definition: ThemeDefinition): ResolvedTheme {
function resolveExpandedTheme(definition: ThemeDefinition): ResolvedTheme {
const hue = resolveHue(definition.hue)
const categorical = definition.categorical.map((name) => hue[name])
const categorical = (definition.categorical ?? DEFAULT_CATEGORICAL).map((name) => hue[name])
const hueSteps = compileHueSteps(hue)
const base = tokens(definition)
const views = {} as Record<SurfaceName, ResolvedTheme>
const view = (tokens: ThemeTokensDefinition): ResolvedTheme => ({
...resolveView(tokens, hue, categorical, hueSteps),
surface: (name) => views[name],
})
views.dialog = definition["@dialog"] ? view(contextualize(base, definition["@dialog"])) : view(base)
return view(base)
const resolved = resolveView(base, hue, categorical, hueSteps)
const context = (name: ContextName) => {
const override = definition[`@context:${name}`]
if (!override) return resolved
return resolveView(contextualize(base, override), hue, categorical, hueSteps)
}
const contextual = {
elevated: context("elevated"),
overlay: context("overlay"),
}
return { ...resolved, contextual } as ResolvedTheme
}
function tokens(definition: ThemeDefinition): ThemeTokensDefinition {
@@ -80,34 +92,38 @@ function tokens(definition: ThemeDefinition): ThemeTokensDefinition {
function contextualize(base: ThemeTokensDefinition, override: ThemeTokensDefinition) {
const result = mergeTheme(base, override)
const baseText = base.text?.action
const contextText = override.text?.action
const baseBackground = base.background?.action
const contextBackground = override.background?.action
const text = result["text"] as NonNullable<ThemeTokensDefinition["text"]>
const background = result["background"] as NonNullable<ThemeTokensDefinition["background"]>
return {
...result,
text: { ...text, action: contextualActions(base.text?.action, override.text?.action) },
background: { ...background, action: contextualActions(base.background?.action, override.background?.action) },
text: { ...text, action: contextualActions(baseText, contextText) },
background: { ...background, action: contextualActions(baseBackground, contextBackground) },
} as ThemeTokensDefinition
}
function contextualActions(
base: Partial<Record<ActionVariant, StatefulColorDefinition>> | undefined,
surface: Partial<Record<ActionVariant, StatefulColorDefinition>> | undefined,
context: Partial<Record<ActionVariant, StatefulColorDefinition>> | undefined,
) {
return Object.fromEntries(
ActionVariant.literals.map((variant) => {
const baseVariant = base?.[variant]
const surfaceVariant = surface?.[variant]
const contextVariant = context?.[variant]
return [
variant,
Object.fromEntries(
(["base", ...ActionState.literals] as readonly ResolvedActionState[]).map((state) => {
const key = state === "base" ? undefined : (`$${state}` as ActionStateKey)
(["default", ...ActionState.literals] as readonly ResolvedActionState[]).map((state) => {
const key = state === "default" ? undefined : (`$${state}` as ActionStateKey)
return [
key ?? "base",
(key ? surfaceVariant?.[key] : undefined) ??
surfaceVariant?.base ??
key ?? "default",
(key ? contextVariant?.[key] : undefined) ??
contextVariant?.default ??
(key ? baseVariant?.[key] : undefined) ??
baseVariant?.base,
baseVariant?.default,
]
}),
),
@@ -123,36 +139,7 @@ function resolveView(
hueSteps: Pick<ResolvedThemeTokens, "source" | "increase" | "decrease">,
): ResolvedThemeTokens {
const source: Record<string, unknown> = { hue, ...definition }
const resolved = createResolver(source)(source, "theme") as ResolvedThemeTokens
return {
...resolved,
hue,
categorical,
text: {
...resolved.text,
action: statefulActions(resolved.text.action),
formfield: statefulColor(resolved.text.formfield),
},
background: {
...resolved.background,
action: statefulActions(resolved.background.action),
formfield: statefulColor(resolved.background.formfield),
},
...hueSteps,
}
}
function statefulActions(actions: Readonly<Record<ActionVariant, StatefulColor>>) {
return Object.fromEntries(ActionVariant.literals.map((variant) => [variant, statefulColor(actions[variant])])) as Readonly<
Record<ActionVariant, StatefulColor>
>
}
function statefulColor(color: StatefulColor): StatefulColor {
return {
...color,
state: (states: ActionStates) => color[ActionState.literals.find((state) => states[state]) ?? "base"],
}
return { ...(createResolver(source)(source, "theme") as ResolvedThemeTokens), hue, categorical, ...hueSteps }
}
function compileHueSteps(
+50 -101
View File
@@ -16,9 +16,6 @@ export const ActionState = Schema.Literals(["disabled", "pressed", "focused", "s
export type ActionState = Schema.Schema.Type<typeof ActionState>
export type ActionStateKey = `$${ActionState}`
export const SurfaceName = Schema.Literal("dialog")
export type SurfaceName = Schema.Schema.Type<typeof SurfaceName>
export const FormfieldState = ActionState
export type FormfieldState = ActionState
export type FormfieldStateKey = `$${FormfieldState}`
@@ -43,6 +40,9 @@ export const CategoricalDefinition = Schema.Array(HueName).check(Schema.isMinLen
export type CategoricalDefinition = Schema.Schema.Type<typeof CategoricalDefinition>
const HueColorValue = Schema.Union([HexColor, Schema.TemplateLiteral(["$hue.", HueName, ".", HueStep])])
const ContextKey = Schema.Literals(["@context:elevated", "@context:overlay"])
export type ContextKey = Schema.Schema.Type<typeof ContextKey>
const HueScaleDefinition = Schema.Record(HueStep, HexColor)
const HueValueDefinition = Schema.Union([Schema.TemplateLiteral(["$hue.", HueName]), HueScaleDefinition])
@@ -61,8 +61,23 @@ const HueDefinition = Schema.Struct({
})
export type HueDefinition = Schema.Schema.Type<typeof HueDefinition>
const HueOverrideDefinition = Schema.Struct({
gray: Schema.optional(HueValueDefinition),
red: Schema.optional(HueValueDefinition),
orange: Schema.optional(HueValueDefinition),
yellow: Schema.optional(HueValueDefinition),
green: Schema.optional(HueValueDefinition),
cyan: Schema.optional(HueValueDefinition),
blue: Schema.optional(HueValueDefinition),
purple: Schema.optional(HueValueDefinition),
accent: Schema.optional(HueValueDefinition),
interactive: Schema.optional(HueValueDefinition),
neutral: Schema.optional(HueValueDefinition),
})
export type HueOverrideDefinition = Schema.Schema.Type<typeof HueOverrideDefinition>
const StatefulColorDefinition = Schema.Struct({
base: Schema.optional(ColorValue),
default: Schema.optional(ColorValue),
$hovered: Schema.optional(ColorValue),
$focused: Schema.optional(ColorValue),
$pressed: Schema.optional(ColorValue),
@@ -80,17 +95,17 @@ const ActionColorDefinition = Schema.Struct({
})
const TextFeedbackDefinition = Schema.Struct({
base: Schema.optional(ColorValue),
muted: Schema.optional(ColorValue),
default: Schema.optional(ColorValue),
subdued: Schema.optional(ColorValue),
})
const BackgroundFeedbackDefinition = Schema.Struct({
base: Schema.optional(ColorValue),
default: Schema.optional(ColorValue),
})
const TextDefinition = Schema.Struct({
base: Schema.optional(ColorValue),
muted: Schema.optional(ColorValue),
default: Schema.optional(ColorValue),
subdued: Schema.optional(ColorValue),
action: Schema.optional(ActionColorDefinition),
formfield: Schema.optional(StatefulColorDefinition),
status: Schema.optional(
@@ -113,7 +128,7 @@ const TextDefinition = Schema.Struct({
export type TextDefinition = Schema.Schema.Type<typeof TextDefinition>
const BackgroundDefinition = Schema.Struct({
base: Schema.optional(ColorValue),
default: Schema.optional(ColorValue),
raised: Schema.optional(
Schema.Struct({
base: Schema.optional(ColorValue),
@@ -202,118 +217,52 @@ export type DiffDefinition = Schema.Schema.Type<typeof DiffDefinition>
const ThemeTokensDefinition = Schema.Struct({
text: Schema.optional(TextDefinition),
background: Schema.optional(BackgroundDefinition),
border: Schema.optional(Schema.Struct({ base: Schema.optional(ColorValue) })),
scrollbar: Schema.optional(Schema.Struct({ base: Schema.optional(ColorValue) })),
border: Schema.optional(Schema.Struct({ default: Schema.optional(ColorValue) })),
scrollbar: Schema.optional(Schema.Struct({ default: Schema.optional(ColorValue) })),
diff: Schema.optional(DiffDefinition),
syntax: Schema.optional(SyntaxDefinition),
markdown: Schema.optional(MarkdownDefinition),
})
export type ThemeTokensDefinition = Schema.Schema.Type<typeof ThemeTokensDefinition>
const CompleteStatefulColorDefinition = Schema.Struct({
base: ColorValue,
$hovered: Schema.optional(ColorValue),
$focused: Schema.optional(ColorValue),
$pressed: Schema.optional(ColorValue),
$selected: Schema.optional(ColorValue),
$disabled: Schema.optional(ColorValue),
})
const CompleteActionColorDefinition = Schema.Struct({
primary: CompleteStatefulColorDefinition,
secondary: CompleteStatefulColorDefinition,
destructive: CompleteStatefulColorDefinition,
})
const CompleteTextFeedbackDefinition = Schema.Struct({ base: ColorValue, muted: Schema.optional(ColorValue) })
const CompleteBackgroundFeedbackDefinition = Schema.Struct({ base: ColorValue })
const CompleteThemeTokensDefinition = Schema.Struct({
text: Schema.Struct({
base: ColorValue,
muted: ColorValue,
action: CompleteActionColorDefinition,
formfield: CompleteStatefulColorDefinition,
status: Schema.Struct({
running: ColorValue,
question: ColorValue,
permission: ColorValue,
unread: ColorValue,
}),
feedback: Schema.Struct({
error: CompleteTextFeedbackDefinition,
warning: CompleteTextFeedbackDefinition,
success: CompleteTextFeedbackDefinition,
info: CompleteTextFeedbackDefinition,
}),
}),
background: Schema.Struct({
base: ColorValue,
raised: Schema.Struct({ base: ColorValue, high: ColorValue, max: ColorValue }),
action: CompleteActionColorDefinition,
formfield: CompleteStatefulColorDefinition,
feedback: Schema.Struct({
error: CompleteBackgroundFeedbackDefinition,
warning: CompleteBackgroundFeedbackDefinition,
success: CompleteBackgroundFeedbackDefinition,
info: CompleteBackgroundFeedbackDefinition,
}),
}),
border: Schema.Struct({ base: ColorValue }),
scrollbar: Schema.Struct({ base: ColorValue }),
diff: Schema.Struct({
text: Schema.Struct({ added: ColorValue, removed: ColorValue, context: ColorValue, hunkHeader: ColorValue }),
background: Schema.Struct({ added: ColorValue, removed: ColorValue, context: ColorValue }),
highlight: Schema.Struct({ added: ColorValue, removed: ColorValue }),
lineNumber: Schema.Struct({
text: ColorValue,
background: Schema.Struct({ added: ColorValue, removed: ColorValue }),
}),
}),
syntax: Schema.Record(SyntaxToken, HueColorValue),
markdown: Schema.Record(MarkdownToken, HueColorValue),
})
const ThemeDefinitionFields = Schema.Struct({
hue: HueDefinition,
categorical: CategoricalDefinition,
...CompleteThemeTokensDefinition.fields,
"@dialog": Schema.optional(ThemeTokensDefinition),
categorical: Schema.optional(CategoricalDefinition),
...ThemeTokensDefinition.fields,
"@context:elevated": Schema.optional(ThemeTokensDefinition),
"@context:overlay": Schema.optional(ThemeTokensDefinition),
})
export const ThemeDefinition = ThemeDefinitionFields
export type ThemeDefinition = Schema.Schema.Type<typeof ThemeDefinition>
export const BaseThemeDefinition = Schema.Struct({
categorical: CategoricalDefinition,
...CompleteThemeTokensDefinition.fields,
"@dialog": Schema.optional(ThemeTokensDefinition),
})
export type BaseThemeDefinition = Schema.Schema.Type<typeof BaseThemeDefinition>
export const ModeDefinition = Schema.Struct({
hue: HueDefinition,
const FileThemeDefinition = Schema.Struct({
hue: Schema.optional(HueOverrideDefinition),
categorical: Schema.optional(CategoricalDefinition),
...ThemeTokensDefinition.fields,
"@dialog": Schema.optional(ThemeTokensDefinition),
"@context:elevated": Schema.optional(ThemeTokensDefinition),
"@context:overlay": Schema.optional(ThemeTokensDefinition),
})
export type FileThemeDefinition = Schema.Schema.Type<typeof FileThemeDefinition>
const MergeModeDefinition = Schema.Struct({
mergeMode: Schema.Literal(true),
hue: Schema.optional(HueOverrideDefinition),
categorical: Schema.optional(CategoricalDefinition),
...ThemeTokensDefinition.fields,
"@context:elevated": Schema.optional(ThemeTokensDefinition),
"@context:overlay": Schema.optional(ThemeTokensDefinition),
})
export type MergeModeDefinition = Schema.Schema.Type<typeof MergeModeDefinition>
export const ModeDefinition = Schema.Union([MergeModeDefinition, FileThemeDefinition])
export type ModeDefinition = Schema.Schema.Type<typeof ModeDefinition>
const FileMetadata = {
$schema: Schema.optional(Schema.String),
version: Schema.Literal(2),
standalone: Schema.optional(Schema.Boolean),
}
export const ThemeDocument = Schema.Union([
Schema.Struct({
...FileMetadata,
base: BaseThemeDefinition,
light: ModeDefinition,
dark: Schema.optional(ModeDefinition),
}),
Schema.Struct({
...FileMetadata,
base: BaseThemeDefinition,
light: Schema.optional(ModeDefinition),
dark: ModeDefinition,
}),
Schema.Struct({ ...FileMetadata, light: ModeDefinition, dark: Schema.optional(ModeDefinition) }),
Schema.Struct({ ...FileMetadata, light: Schema.optional(ModeDefinition), dark: ModeDefinition }),
])
export type ThemeDocument = Schema.Schema.Type<typeof ThemeDocument>
+31 -5
View File
@@ -1,25 +1,51 @@
import { mergeTheme } from "./expand.js"
import type { Mode, ThemeDefinition, ThemeDocument } from "./index.js"
import { expandTheme, mergeTheme } from "./expand.js"
import type {
FileThemeDefinition,
MergeModeDefinition,
Mode,
ModeDefinition,
ThemeDefinition,
ThemeDocument,
} from "./index.js"
export function selectTheme(document: ThemeDocument, mode?: Mode): ThemeDefinition {
export function selectTheme(
document: ThemeDocument & { light: ThemeDefinition; dark: ThemeDefinition },
mode?: Mode,
): ThemeDefinition
export function selectTheme(document: ThemeDocument, mode?: Mode): FileThemeDefinition
export function selectTheme(document: ThemeDocument, mode?: Mode) {
return selectThemeMode(document, mode).theme
}
export function selectThemeMode(
document: ThemeDocument,
mode: Mode = "light",
): { theme: ThemeDefinition; mode: Mode } {
): { theme: FileThemeDefinition; mode: Mode; expanded: boolean } {
const modes = themeModes(document)
const selectedMode = modes.includes(mode) ? mode : modes[0]
const selected = document[selectedMode]
if (!selected) throw new Error("Theme must provide at least one mode")
return { theme: mergeTheme(document.base, selected) as ThemeDefinition, mode: selectedMode }
if (merges(document.light) && merges(document.dark)) throw new Error("Light and dark themes cannot both merge modes")
if (!merges(selected)) return { theme: selected, mode: selectedMode, expanded: false }
const otherMode = selectedMode === "light" ? "dark" : "light"
const other = document[otherMode]
if (!other) throw new Error(`The ${selectedMode} theme cannot merge without a ${otherMode} theme`)
const merged = mergeTheme(expandTheme(other), expandTheme(selected))
if (!merged["hue"]) throw new Error(`The ${otherMode} theme must provide hues when ${selectedMode} merges modes`)
return { theme: merged as FileThemeDefinition, mode: selectedMode, expanded: true }
}
export function themeModes(document: ThemeDocument): readonly Mode[] {
if (merges(document.light) && !document.dark) throw new Error("The light theme cannot merge without a dark theme")
if (merges(document.dark) && !document.light) throw new Error("The dark theme cannot merge without a light theme")
return (["light", "dark"] as const).filter((mode) => document[mode] !== undefined)
}
export function supportsThemeMode(document: ThemeDocument, mode: Mode) {
return themeModes(document).includes(mode)
}
function merges(definition: ModeDefinition | undefined): definition is MergeModeDefinition {
return definition !== undefined && "mergeMode" in definition && definition.mergeMode === true
}

Some files were not shown because too many files have changed in this diff Show More