mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-18 23:06:25 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c2eaade2c |
@@ -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
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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")
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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/**",
|
||||
],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
}>
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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`]),
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user