Compare commits

...
Author SHA1 Message Date
usrnk1 c1ee2de651 fix(desktop): hide transitional Console provider 2026-09-18 16:48:32 +02:00
usrnk1 80c37bf94a Merge remote-tracking branch 'origin/v2' into desktop-onboarding 2026-09-18 16:41:44 +02:00
usrnk1 941afdb999 fix(desktop): clear reconnected provider suppression 2026-09-18 16:41:43 +02:00
Dax Raad c2067d59af fix(tui): simplify btw dialog sizing 2026-09-18 10:27:57 -04:00
usrnk1 3efbc5e309 test(desktop): cover existing provider connection 2026-09-18 16:27:29 +02:00
usrnk1 323472458e Merge remote-tracking branch 'origin/v2' into desktop-onboarding 2026-09-18 16:23:26 +02:00
usrnk1 dc2dedac18 feat(desktop): refine Console provider flows 2026-09-18 16:23:16 +02:00
usrnk1 59c0e9b369 feat(ui): support persistent dialog backdrops 2026-09-18 16:23:03 +02:00
Shoubhit Dash f5d40d6f30 fix(ai): keep valid Responses provider options when a sibling is malformed (#49769) 2026-09-18 18:59:16 +05:30
Shoubhit Dash c8e654410d refactor(ai): decode Anthropic and Gemini provider options with one schema (#49760) 2026-09-18 18:14:20 +05:30
usrnk1 ed899d5845 fix(desktop): keep Console retry feedback visible 2026-09-18 14:24:47 +02:00
usrnk1 a57ea33b54 fix(desktop): align Console labels and tokens 2026-09-18 14:21:38 +02:00
usrnk1 8e17cd4284 fix(desktop): align connected model hover 2026-09-18 14:16:30 +02:00
usrnk1 0acae92b7a fix(desktop): align Console model rail 2026-09-18 14:14:09 +02:00
usrnk1 6d465f02e4 fix(desktop): unify connected model surfaces 2026-09-18 14:12:31 +02:00
usrnk1 4b379a2bf1 Merge remote-tracking branch 'origin/v2' into desktop-onboarding 2026-09-18 14:10:03 +02:00
usrnk1 154360948a fix(desktop): show managed provider icons 2026-09-18 13:14:01 +02:00
usrnk1 1bf036ab30 fix(desktop): submit hidden Console defaults 2026-09-18 13:08:03 +02:00
usrnk1 357b3cb5b3 fix(session-ui): use OpenCode logo in model notices 2026-09-18 12:37:31 +02:00
usrnk1 77799084bf feat(desktop): group Console model providers 2026-09-18 12:34:47 +02:00
usrnk1 b1f62b9fda Merge remote-tracking branch 'origin/v2' into desktop-onboarding
# Conflicts:
#	packages/app/src/runtime/i18n/en.ts
2026-09-18 12:24:46 +02:00
usrnk1 6a30ff19a5 Merge remote-tracking branch 'origin/v2' into desktop-onboarding 2026-09-17 11:52:39 +02:00
usrnk1 1c8f2910fd refactor(desktop): keep onboarding in provider tip 2026-09-17 11:52:39 +02:00
usrnk1 d65a5f1372 Merge branch 'v2' into desktop-onboarding 2026-09-16 13:48:26 +02:00
usrnk1 8e72d42142 fix(desktop): isolate onboarding documents 2026-09-15 15:43:48 +02:00
usrnk1 3e6d220a3d refactor(app): align connected model picker 2026-09-15 15:43:48 +02:00
usrnk1 87f516d219 test(desktop): cover Console onboarding 2026-09-15 15:43:48 +02:00
usrnk1 ec7425d6f0 feat(desktop): focus window after Console auth 2026-09-15 15:43:48 +02:00
usrnk1 461017dcab feat(app): group Console providers in settings 2026-09-15 15:43:48 +02:00
usrnk1 f36788288f feat(desktop): add Console onboarding flow 2026-09-15 15:35:22 +02:00
58 changed files with 3683 additions and 767 deletions
+2 -2
View File
@@ -8,7 +8,7 @@ import { OpenResponsesOptions } from "./utils/open-responses-options.js"
export type ReasoningEffort = OpenResponsesOptions.ReasoningEffort
const Options = Schema.Struct({
reasoningEffort: OpenResponsesOptions.Options.fields.reasoningEffort,
reasoningEffort: Schema.optional(OpenResponsesOptions.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: OpenResponsesOptions.Options.fields.parallelToolCalls,
parallelToolCalls: Schema.optional(Schema.Boolean),
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: OpenResponsesOptions.Options.fields.reasoningEffort,
reasoningEffort: Schema.optional(OpenResponsesOptions.ReasoningEffort),
enableThinking: Schema.optional(Schema.Boolean),
store: OpenResponsesOptions.Options.fields.store,
store: Schema.optional(Schema.Boolean),
previousResponseId: Schema.optional(Schema.String),
conversation: Schema.optional(Schema.String),
})
+79 -167
View File
@@ -1,5 +1,5 @@
import { Buffer } from "node:buffer"
import { Effect, Option, Schema } from "effect"
import { Effect, Option, Schema, SchemaGetter } from "effect"
import { Tool } from "@opencode/schema/tool"
import { Route } from "../route/client.js"
import { Auth } from "../route/auth.js"
@@ -21,11 +21,12 @@ import {
type JsonSchema,
type MediaPart,
type ProviderMetadata,
type ProviderOptions,
type ToolCallPart,
type ToolDefinition,
type ToolResultPart,
} from "../schema/index.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { JsonObject, knownString, 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"
@@ -52,57 +53,10 @@ const SSE_EVENTS = new Set([
])
export const framing = Framing.sseEvents(SSE_EVENTS)
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 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 ProviderOptionsInput = OptionsInput
export const ContextManagement = Schema.Struct({
@@ -129,6 +83,7 @@ const AnthropicCacheControl = Schema.Struct({
type: Schema.tag("ephemeral"),
ttl: Schema.optional(Schema.Literals(["5m", "1h"])),
})
const AnthropicServiceTier = Schema.Literals(["auto", "standard_only"])
const AnthropicTextBlock = Schema.Struct({
type: Schema.tag("text"),
@@ -317,25 +272,21 @@ const AnthropicToolChoice = Schema.Union([
])
const AnthropicThinkingBlockBinding = Schema.Struct({
prefix_mismatch_behavior: Schema.optional(Schema.String),
prefix_mismatch_behavior: Schema.optional(knownString<"error" | "drop_block">()),
})
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"),
}),
])
const AnthropicThinkingFields = {
display: Schema.optional(Schema.Literals(["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])
type AnthropicThinking = typeof AnthropicThinking.Type
// SDK OutputConfig:2684 {effort?: "low"|"medium"|"high"|"xhigh"|"max"|null, format?: JSONOutputFormat:2399}
@@ -360,6 +311,53 @@ 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({
@@ -391,7 +389,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(Schema.Literals(["auto", "standard_only"])),
service_tier: Schema.optional(AnthropicServiceTier),
}
export const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields)
export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesBody>
@@ -1001,64 +999,6 @@ 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(
@@ -1097,35 +1037,12 @@ 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 management = yield* ProviderShared.validateWith(
Schema.decodeUnknownEffect(Schema.UndefinedOr(ContextManagement)),
)(request.providerOptions?.contextManagement)
const options = yield* resolveOptions(request)
const updates = resolveEffortUpdates(request, options.effort)
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 generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
// Allocate the 4-breakpoint budget in invalidation order: tools → system →
@@ -1161,12 +1078,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
)
}
const output_config =
updates.effort === undefined && options.format === undefined
? undefined
: {
...(updates.effort === undefined ? {} : { effort: updates.effort }),
...(options.format === undefined ? {} : { format: options.format }),
}
updates.effort === undefined && format === undefined ? undefined : { effort: updates.effort, format }
const body = {
model: request.model.id,
system,
@@ -1179,14 +1091,14 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
top_p: generation?.topP,
top_k: generation?.topK,
stop_sequences: generation?.stop,
thinking: options.thinking,
thinking: applyThinkingBindingDefault(request.model, options.thinking),
output_config,
// top-level passthrough per SDK MessageCreateParamsBase:4638,4643,4649,4654,4670
cache_control: options.cache_control,
cache_control: options.cache_control ?? options.cacheControl,
container: options.container,
inference_geo: options.inference_geo,
inference_geo: options.inference_geo ?? options.inferenceGeo ?? undefined,
metadata: options.metadata,
service_tier: options.service_tier,
service_tier: options.service_tier ?? options.serviceTier,
}
if (!management) return body
return {
+45 -68
View File
@@ -14,12 +14,13 @@ 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, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { JsonObject, knownString, lenient, 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"
@@ -50,35 +51,8 @@ const omitsFunctionCallIds = (modelID: string) => {
return match !== null && Number(match[1]) < 3
}
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 & {})
}
}
/** Caller-facing provider options; unknown keys are accepted and ignored. */
export type OptionsInput = ProviderOptions & typeof Options.Encoded
export type ProviderOptionsInput = OptionsInput
// =============================================================================
@@ -161,17 +135,50 @@ 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(Schema.String),
thinkingLevel: Schema.optional(GeminiThinkingLevel),
})
const GeminiSafetySetting = Schema.Struct({
category: Schema.String,
threshold: Schema.String,
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"
>(),
})
// =============================================================================
// 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),
@@ -431,44 +438,11 @@ 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 = resolveOptions(request)
const options = yield* decodeOptions(request.providerOptions ?? {})
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const generationConfig = {
maxOutputTokens: generation?.maxTokens,
@@ -479,7 +453,10 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
presencePenalty: generation?.presencePenalty,
seed: generation?.seed,
stopSequences: generation?.stop,
thinkingConfig: options.thinkingConfig,
thinkingConfig:
options.thinkingConfig === undefined
? undefined
: { ...options.thinkingConfig, includeThoughts: options.thinkingConfig.includeThoughts ?? true },
}
return {
+11 -1
View File
@@ -1,6 +1,6 @@
import { Buffer } from "node:buffer"
import { Tool } from "@opencode/schema/tool"
import { Effect, Schema, Stream } from "effect"
import { Effect, Option, Schema, Stream } from "effect"
import * as Sse from "effect/unstable/encoding/Sse"
import { Headers, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import {
@@ -29,6 +29,16 @@ 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,5 +1,6 @@
import { Option, Schema } from "effect"
import { Schema } from "effect"
import { ReasoningEffort, ReasoningEfforts, type LLMRequest } from "../../schema/index.js"
import { lenient } from "../shared.js"
export { ReasoningEffort, ReasoningEfforts }
@@ -49,21 +50,22 @@ 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: 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),
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),
})
export type Options = typeof Options.Type
@@ -71,11 +73,10 @@ export type Resolved = Omit<Options, "allowedTools"> & {
readonly allowedTools?: AllowedTools & { readonly mode: NonNullable<AllowedTools["mode"]> }
}
const decodeOptions = Schema.decodeUnknownOption(Options)
const decodeOptions = Schema.decodeUnknownSync(Options)
export const resolve = (request: LLMRequest): Resolved => {
const input = Option.getOrUndefined(decodeOptions(request.providerOptions))
if (!input) return {}
const input = decodeOptions(request.providerOptions ?? {})
return {
...input,
include: input.include?.length ? input.include : undefined,
@@ -166,7 +166,71 @@ describe("Anthropic Messages route", () => {
}),
).pipe(Effect.flip)
expect(error.message).toContain("Anthropic thinking provider option requires budgetTokens")
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("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: "fast" },
{ metadata: { user_id: 42 } },
{ cache_control: { type: "ephemeral", ttl: "2h" } },
{ 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"))
}),
)
@@ -248,6 +248,21 @@ 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,6 +1945,30 @@ 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(
@@ -0,0 +1,772 @@
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: "Connect OpenCode Console account" }).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 Console" })).toBeVisible()
const list = dialog.getByRole("radiogroup", { name: "Models available from OpenCode Console" })
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("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 Console" })).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 Console" })).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 Console" })).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")
})
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 Console" })).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 Console" })).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 Console" })).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 Console" })).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 Console" })).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 Console" })).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 Console" })).toBeVisible()
expect(state.starts).toBe(1)
})
test("remote disclosure precedes authorization and all auth requests target that server", async ({ page }) => {
const { state, dialog } = await fixture(page, true)
await expect(dialog.getByRole("note")).toContainText("Connecting on “Production server”")
await expect(dialog.getByRole("note")).toContainText("credentials will be stored on this server")
expect(state.starts).toBe(0)
const request = page.waitForRequest(
(request) => request.method() === "POST" && request.url().includes("/connect/oauth"),
)
await dialog.getByRole("button", { name: "Continue to OpenCode Console" }).click()
expect(new URL((await request).url()).origin).toBe("http://production.example:4096")
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
const cancelled = page.waitForRequest(
(request) => request.method() === "DELETE" && request.url().includes("/connect/oauth"),
)
await dialog.getByRole("button", { name: "Close", exact: true }).click()
expect(new URL((await cancelled).url()).origin).toBe("http://production.example:4096")
})
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body class="overflow-hidden bg-v2-background-bg-deep">
<div id="root" class="flex h-dvh flex-col p-px"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
+46
View File
@@ -0,0 +1,46 @@
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,
)
@@ -0,0 +1,19 @@
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="provider-icon"]')).toBeVisible()
await expect(shortNotice.locator('[data-component="logo-mark"]')).toBeVisible()
await expect(longNotice).toBeVisible()
await expect(longNotice.locator('[data-component="provider-icon"]')).toBeVisible()
await expect(longNotice.locator('[data-component="logo-mark"]')).toBeVisible()
await expect(longNotice.locator('[data-slot="session-timeline-notice-variant"]')).toHaveCount(0)
await expect(longNotice.locator("[title]")).toHaveAttribute("title", `Switched to ${longName}`)
await expect.poll(() => longNotice.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
+1
View File
@@ -19,6 +19,7 @@ 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/**",
],
+20 -4
View File
@@ -19,6 +19,7 @@ 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"
@@ -152,6 +153,8 @@ export function NewSessionView(props: {
</div>
</div>
<NewSessionTips
selection={props.composer.model.selection}
onDone={props.composer.restoreFocus}
workspaceEligible={
!!props.project.selected() &&
props.workspace.bar.visible() &&
@@ -165,13 +168,19 @@ export function NewSessionView(props: {
)
}
function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () => void }) {
function NewSessionTips(props: {
selection: ComposerModel["model"]["selection"]
onDone: () => void
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"),
Persist.global("new-session.provider-tip-v3"),
ProviderTipSchema,
{ dismissedAt: 0 },
)
@@ -190,7 +199,10 @@ function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () =>
() =>
providers.ready() &&
providerReady() &&
providers.paid().length === 0 &&
!integrations.list().some((integration) => integration.connections.length > 0) &&
!providers
.connected()
.some((provider) => provider.id !== "opencode" && Object.keys(provider.models).length > 0) &&
Date.now() - providerState.dismissedAt >= providerTipDismissalDuration,
)
const tip = createMemo<"workspace" | "provider" | undefined>(() => {
@@ -212,7 +224,11 @@ function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () =>
return
}
void import("@/providers/connect/dialog").then(({ DialogConnectProvider }) => {
void dialog.show(() => <DialogConnectProvider directory={sdk().directory} />)
void dialog.show(
() => <DialogConnectProvider directory={sdk().directory} selection={props.selection} onDone={props.onDone} />,
undefined,
{ dismissOnBackdrop: false },
)
})
}
const dismiss = () => {
@@ -0,0 +1,19 @@
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)
})
@@ -0,0 +1,26 @@
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,6 +45,10 @@ 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: () => {
@@ -72,15 +76,12 @@ export function useProviders(directory: Accessor<string | undefined>) {
},
paid: () => {
const connected = new Set(providers().connected)
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
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))]
},
}
}
@@ -0,0 +1,68 @@
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>
)
}
+171 -32
View File
@@ -9,22 +9,41 @@ 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()
const location = () => {
const directory = options.directory()
return directory ? { directory } : undefined
}
// 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 [integration] = createResource(
() => ({ provider: options.provider(), directory: options.directory() }),
() => ({ provider: integrationID, directory }),
(input) =>
serverSDK.api.integration
.get({ integrationID: input.provider, location: location() })
@@ -41,8 +60,12 @@ export function createProviderConnectionController(options: {
methodIndex: undefined as number | undefined,
authorization: undefined as Authorization | undefined,
formAnswer: undefined as FormAnswer | undefined,
state: "pending" as "pending" | "complete" | "error" | "form" | undefined,
state: "pending" as "pending" | "waiting" | "refreshing" | "ready" | "error" | "form" | undefined,
error: undefined as string | undefined,
connected: false,
browserFailed: false,
statusFailed: false,
selectingIndex: undefined as number | undefined,
})
const polling = {
generation: 0,
@@ -59,7 +82,7 @@ export function createProviderConnectionController(options: {
| { type: "auth.form" }
| { type: "auth.answer"; answer: FormAnswer | undefined }
| { type: "auth.pending" }
| { type: "auth.complete"; authorization: Authorization }
| { type: "auth.authorized"; index: number; authorization: Authorization }
| { type: "auth.error"; error: string }
const dispatch = (action: Action) => {
@@ -71,6 +94,10 @@ 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") {
@@ -79,6 +106,10 @@ 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") {
@@ -95,12 +126,15 @@ export function createProviderConnectionController(options: {
if (action.type === "auth.pending") {
draft.state = "pending"
draft.error = undefined
draft.selectingIndex = undefined
return
}
if (action.type === "auth.complete") {
draft.state = "complete"
if (action.type === "auth.authorized") {
draft.methodIndex = action.index
draft.state = "waiting"
draft.authorization = action.authorization
draft.error = undefined
draft.selectingIndex = undefined
return
}
draft.state = "error"
@@ -115,24 +149,64 @@ 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)
await Promise.all([
const refreshed = await Promise.all([
data.location.integration.sync(ref),
data.location.provider.sync(ref),
data.location.model.sync(ref),
]).catch(() => undefined)
if (polling.disposed) return
])
.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")
options.onComplete()
}
const poll = async (authorization: Authorization, generation: number) => {
const result = await serverSDK.api.integration.oauth
.status({
integrationID: options.provider(),
integrationID,
attemptID: authorization.attemptID,
location: location(),
})
@@ -140,9 +214,14 @@ 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: result.error instanceof Error ? result.error.message : String(result.error),
error: desktopConsole
? language.t("provider.connect.console.statusFailed")
: result.error instanceof Error
? result.error.message
: String(result.error),
})
return
}
@@ -151,21 +230,41 @@ export function createProviderConnectionController(options: {
return
}
if (result.status.status === "failed") {
dispatch({ type: "auth.error", error: result.status.message })
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,
})
return
}
if (result.status.status === "expired") {
dispatch({ type: "auth.error", error: language.t("common.requestFailed") })
dispatch({
type: "auth.error",
error: language.t(desktopConsole ? "provider.connect.console.expired" : "common.requestFailed"),
})
return
}
polling.timer = setTimeout(() => void poll(authorization, generation), options.pollInterval ?? 1_000)
polling.timer = setTimeout(
() => void poll(authorization, generation),
options.pollInterval ?? (desktopConsole ? 500 : 1_000),
)
}
const select = async (index: number, answer?: FormAnswer) => {
cancelPolling()
cancelAttempt()
const generation = polling.generation
const selected = methods()[index]
dispatch({ type: "method.select", index })
if (selected.form?.length && !answer) {
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: "auth.form" })
return
}
@@ -175,41 +274,62 @@ export function createProviderConnectionController(options: {
}
if (selected.type !== "oauth") return
if (selected.form?.some((field) => field.type !== "string")) {
dispatch({ type: "auth.error", error: "This authentication form contains unsupported fields" })
dispatch({ type: "auth.error", error: language.t("provider.connect.form.unsupported") })
return
}
dispatch({ type: "auth.pending" })
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" })
}
const result = await serverSDK.api.integration.oauth
.connect({
integrationID: options.provider(),
integrationID,
methodID: selected.id,
...(answer ? { answer } : {}),
...(Object.keys(resolvedAnswer).length > 0 ? { answer: resolvedAnswer } : {}),
location: location(),
})
.then((response) => {
if (options.provider() === "opencode" && platform.platform === "desktop") {
if (integrationID === "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) return
if (!result.ok) {
dispatch({ type: "auth.error", error: String(result.error) })
if (polling.disposed || generation !== polling.generation) {
if (result.ok) cancelAttempt(result.authorization)
return
}
dispatch({ type: "auth.complete", authorization: result.authorization })
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),
})
return
}
dispatch({ type: "auth.authorized", index, authorization: result.authorization })
if (desktopConsole && selected.id === "device") void openBrowser()
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: options.provider(),
integrationID,
location: location(),
key,
...(store.formAnswer ? { answer: store.formAnswer } : {}),
@@ -221,7 +341,7 @@ export function createProviderConnectionController(options: {
if (!authorization) return language.t("provider.connect.oauth.code.invalid")
const result = await serverSDK.api.integration.oauth
.complete({
integrationID: options.provider(),
integrationID,
attemptID: authorization.attemptID,
location: location(),
code,
@@ -238,13 +358,20 @@ export function createProviderConnectionController(options: {
let auto = false
createEffect(() => {
if (auto || integration.loading || methods().length !== 1) return
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
auto = true
void select(0)
void select(index)
})
onCleanup(() => {
polling.disposed = true
cancelPolling()
cancelAttempt()
})
return {
@@ -254,6 +381,9 @@ 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,
@@ -261,6 +391,15 @@ 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)
},
},
}
}
@@ -8,7 +8,7 @@ import { DialogConnectProvider, useProviderConnectController } from "./dialog"
function ConnectProviderDialogStory() {
const dialog = useDialog()
const open = () => dialog.show(() => <DialogConnectProvider />)
const open = () => dialog.show(() => <DialogConnectProvider />, undefined, { dismissOnBackdrop: false })
onMount(open)
@@ -24,7 +24,8 @@ function ProviderConnectionDialogStory(props) {
const dialog = useDialog()
const controller = useProviderConnectController()
controller.select(props.provider)
const open = () => dialog.show(() => <DialogConnectProvider controller={controller} />)
const open = () =>
dialog.show(() => <DialogConnectProvider controller={controller} />, undefined, { dismissOnBackdrop: false })
onMount(open)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,72 @@
.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;
}
@@ -0,0 +1,30 @@
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")
})
@@ -0,0 +1,30 @@
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>
)
}
+168 -65
View File
@@ -1,4 +1,5 @@
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"
@@ -6,7 +7,7 @@ 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 { For, Show, type Component } from "solid-js"
import { createMemo, 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,9 +17,19 @@ 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 } 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()
@@ -28,7 +39,9 @@ export const DialogManageModels: Component = () => {
const directory = () => decode64(local.slug())
const handleConnectProvider = () => {
void dialog.show(() => <DialogConnectProvider directory={directory()} />)
void dialog.show(() => <DialogConnectProvider directory={directory()} />, undefined, {
dismissOnBackdrop: false,
})
}
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
const providerVisible = (providerID: string) =>
@@ -54,9 +67,57 @@ export const DialogManageModels: Component = () => {
const bPopular = bRank >= 0
if (aPopular && !bPopular) return -1
if (!aPopular && bPopular) return 1
return aRank - bRank
if (aPopular && bPopular) return aRank - bRank
return a.items[0].provider.name.localeCompare(b.items[0].provider.name)
},
})
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 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">
@@ -100,7 +161,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-4 pb-4">
<div class="settings-panel settings-models h-full px-4 pt-1 pb-4">
<Show
when={!list.grouped.loading}
fallback={
@@ -121,68 +182,110 @@ export const DialogManageModels: Component = () => {
</div>
}
>
<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 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>
<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">
<ProviderIcon id={group().category} width={16} height={16} class="shrink-0" />
<bdi class="settings-models-group-title">{group().items[0].provider.name}</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>
</div>
)
}}
}
>
{(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>
</div>
)}
</Show>
)}
</For>
</Show>
</Show>
@@ -0,0 +1,66 @@
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"
export function ProviderModelGroup(props: {
provider: { id: string; canonical?: string; name: string }
name?: string
expanded: boolean
disabled?: boolean
detail?: JSX.Element
children: JSX.Element
ref?: (element: HTMLElement) => void
onExpandedChange: (expanded: boolean) => void
}) {
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 (
<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">
<Show
when={props.provider.id === "opencode"}
fallback={<ProviderIcon id={icon()} width={16} height={16} class="shrink-0" />}
>
<OpenCodeLogo class="size-4 shrink-0" />
</Show>
<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,7 +6,7 @@ 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, DialogTitle } from "@opencode/ui/dialog"
import { Dialog, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode/ui/dialog"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { ScrollView } from "@opencode/ui/scroll-view"
@@ -22,6 +22,9 @@ 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 } from "@/providers/models/provider-group"
import "@/settings/settings.css"
const isFree = (provider: string, cost: { input: number } | undefined) =>
@@ -29,9 +32,15 @@ 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)
@@ -62,9 +71,29 @@ const ModelList: Component<{
collapsed: {} as Record<string, boolean>,
})
const models = createMemo(() => controller.models(store.search))
const groups = createMemo(() => controller.groups(models()))
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 expanded = (provider: string) => store.search.length > 0 || !store.collapsed[provider]
const visibleModels = () => models().filter((item) => expanded(item.provider.id))
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)),
)
let scrollRef: HTMLDivElement | undefined
const setSearch = (value: string) => {
@@ -88,6 +117,53 @@ 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">
@@ -137,87 +213,98 @@ 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-4 pb-4">
<div ref={(element) => (scrollRef = element)} class="settings-panel settings-models h-full px-4 pt-1 pb-4">
<Show
when={models().length > 0}
fallback={<div class="settings-models-status">{language.t("dialog.model.empty")}</div>}
>
<For each={groups()}>
{(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>
{(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">
<ProviderIcon id={group().category} width={16} height={16} class="shrink-0" />
<bdi class="settings-models-group-title">{group().items[0].provider.name}</bdi>
</span>
</button>
</h3>
<Show when={open()}>
<ModelRows items={group().items} />
</Show>
</section>
)
}}
</Show>
</section>
)
}}
}
>
{(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>
)}
</For>
</Show>
</div>
@@ -272,6 +359,7 @@ 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
@@ -535,7 +623,9 @@ export const DialogSelectModel: Component<{ provider?: string; model?: ModelStat
const provider = () => {
void import("@/providers/connect/dialog").then((x) => {
void dialog.show(() => <x.DialogConnectProvider directory={directory()} />)
void dialog.show(() => <x.DialogConnectProvider directory={directory()} />, undefined, {
dismissOnBackdrop: false,
})
})
}
@@ -548,7 +638,7 @@ export const DialogSelectModel: Component<{ provider?: string; model?: ModelStat
return (
<Dialog size="large" variant="settings">
<DialogHeader hideClose closeLabel={language.t("common.close")}>
<DialogTitle>{language.t("dialog.model.select.title")}</DialogTitle>
<DialogTitleGroup title={language.t("dialog.model.select.title")} />
<Button icon="plus" onClick={provider}>
{language.t("command.provider.connect")}
</Button>
+3 -1
View File
@@ -37,7 +37,9 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
void import("@/providers/connect/dialog").then((x) => {
const controller = x.useProviderConnectController()
controller.select(provider)
void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory()} />)
void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory()} />, undefined, {
dismissOnBackdrop: false,
})
})
}
@@ -0,0 +1,17 @@
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>
)
}
+51
View File
@@ -192,6 +192,53 @@ export const dict = {
"dialog.provider.viewAll": "Show more providers",
"provider.connect.title": "Connect {{provider}}",
"provider.connect.opencode.name": "OpenCode",
"provider.connect.opencode.errorTitle": "Connect to OpenCode",
"provider.connect.console.title": "Connect OpenCode Console account",
"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",
@@ -1330,6 +1377,8 @@ 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",
@@ -1338,6 +1387,8 @@ 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,6 +37,9 @@ 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,6 +51,8 @@ export function normalizeProviderList(
for (const provider of providers) {
all.set(provider.id, {
id: provider.id,
canonical: provider.canonical,
integrationID: provider.integrationID,
name: provider.name,
source: "custom",
env: [],
+2
View File
@@ -127,6 +127,8 @@ export type Model = {
export type Provider = {
id: string
canonical?: string
integrationID?: string
name: string
source: "env" | "config" | "custom" | "api"
env: string[]
@@ -83,7 +83,9 @@ export function useUsageExceededDialogs() {
void import("@/providers/connect/dialog").then((x) => {
const controller = x.useProviderConnectController()
controller.select("opencode-go")
void dialog.show(() => <x.DialogConnectProvider controller={controller} />)
void dialog.show(() => <x.DialogConnectProvider controller={controller} />, undefined, {
dismissOnBackdrop: false,
})
})
}
}}
+198 -76
View File
@@ -1,10 +1,11 @@
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, For, on, onCleanup, Show } from "solid-js"
import { type Component, createEffect, createMemo, For, on, onCleanup, Show } from "solid-js"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { useLanguage } from "@/runtime/i18n/language"
@@ -14,17 +15,31 @@ 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 } 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 PROVIDER_ICON_SIZE = 16
const CONSOLE_GROUP_KEY = "console:opencode"
export const ModelProvidersSchema = Schema.Struct({
collapsed: Persistence.record(Persistence.fallback(Schema.Boolean, () => false)),
})
export const SettingsModels: Component<{ active?: boolean; autofocus?: boolean }> = (props) => {
export const SettingsModels: Component<{
active?: boolean
autofocus?: boolean
provider?: string
onReveal?: () => void
}> = (props) => {
const language = useLanguage()
const models = useModels()
const serverSdk = useServerSDK()
@@ -47,6 +62,7 @@ export const SettingsModels: Component<{ active?: boolean; autofocus?: boolean }
ModelProvidersSchema,
{ collapsed: {} },
)
const sections = new Map<string, HTMLElement>()
const list = useFilteredList<ModelItem>({
items: (_filter) => models.list(),
@@ -69,6 +85,86 @@ export const SettingsModels: Component<{ active?: boolean; autofocus?: boolean }
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 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 (
<>
@@ -127,83 +223,109 @@ export const SettingsModels: Component<{ active?: boolean; autofocus?: boolean }
</div>
}
>
<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"
<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) }}
/>
</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>
</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"
/>
<bdi class="settings-models-group-title">{group().items[0].provider.name}</bdi>
</span>
</button>
</h3>
<Show when={expanded(group().category)}>
<ModelRows items={group().items} />
</Show>
</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>
</div>
)}
</Show>
</div>
)
}}
}
>
{(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>
)}
</For>
</Show>
</Show>
+218 -37
View File
@@ -1,13 +1,18 @@
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 { createMemo, type Component, For, Show } from "solid-js"
import { createEffect, createMemo, type Component, For, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
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"
@@ -31,30 +36,104 @@ const PROVIDER_ICON_SIZE = 16
export const SettingsProviders: Component<{
directory: string | undefined
onBack?: () => void
onSelectProvider?: (providerID: string) => 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({ onBack: props.onBack })
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 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} controller={providerConnect} />)
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)
},
{ dismissOnBackdrop: false },
)
}
const connected = createMemo(() => {
return providers
.connected()
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
.filter(
(provider) =>
provider.id !== "opencode" || Object.values(provider.models).some((model) => model.cost.input > 0),
provider.id !== "opencode" ||
(!consoleTransition &&
(managedConsole !== undefined ||
consoleConnected ||
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
@@ -98,6 +177,10 @@ 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 })
@@ -107,6 +190,7 @@ 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",
@@ -115,6 +199,7 @@ 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 })
})
@@ -136,38 +221,127 @@ export const SettingsProviders: Component<{
<h3 class="settings-section-title">{language.t("settings.providers.section.connected")}</h3>
<SettingsList variant="catalog">
<Show
when={connected().length > 0}
when={displayed().length > 0}
fallback={<div class="settings-provider-empty">{language.t("settings.providers.connected.empty")}</div>}
>
<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>
<For each={displayed()}>
{(item) => {
const console = () => (consoleGroup()?.root.id === item.id ? consoleGroup() : undefined)
return (
<Show
when={canDisconnect(item)}
when={console()}
fallback={
<span class="settings-provider-env-hint">
{language.t("settings.providers.connected.environmentDescription")}
</span>
<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>
}
>
<Button size="normal" variant="ghost-muted" onClick={() => void disconnect(item.id, item.name)}>
{language.t("common.disconnect")}
</Button>
{(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>
)}
</Show>
</div>
)}
)
}}
</For>
</Show>
</SettingsList>
@@ -180,12 +354,19 @@ export const SettingsProviders: Component<{
{(item) => (
<div class="settings-provider-row">
<div class="settings-provider-lead">
<ProviderIcon
id={item.id}
width={PROVIDER_ICON_SIZE}
height={PROVIDER_ICON_SIZE}
class="settings-provider-icon shrink-0"
/>
<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-copy">
<div class="settings-provider-main">
<span class="settings-provider-name">{item.name}</span>
+283
View File
@@ -677,6 +677,134 @@
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;
@@ -938,6 +1066,18 @@
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;
@@ -950,6 +1090,149 @@
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);
}
+35 -6
View File
@@ -187,7 +187,10 @@ function RootSettings() {
const tabs = useTabs()
const servers = useServerCollectionController()
const inventory = useSettingsServers()
const [state, setState] = createStore({ worktreeFilterReset: 0 })
const [state, setState] = createStore({
worktreeFilterReset: 0,
modelProvider: undefined as string | undefined,
})
const list = servers.collection.items
const singleEntry = createMemo(() => (inventory().length === 1 ? inventory()[0] : undefined))
const single = createMemo(() => singleEntry()?.connection)
@@ -309,10 +312,22 @@ function RootSettings() {
/>
</Tabs.Content>
<Tabs.Content value="providers" class="settings-panel">
<SettingsProviders directory={undefined} onBack={() => surface.select("providers")} />
<SettingsProviders
directory={undefined}
onBack={() => surface.select("providers")}
onSelectProvider={(providerID) => {
setState("modelProvider", providerID)
surface.select("models")
}}
/>
</Tabs.Content>
<Tabs.Content value="models" class="settings-panel">
<SettingsModels active={surface.view().tab === "models"} autofocus={!surface.search.state.selected} />
<SettingsModels
active={surface.view().tab === "models"}
autofocus={!surface.search.state.selected}
provider={state.modelProvider}
onReveal={() => setState("modelProvider", undefined)}
/>
</Tabs.Content>
<Tabs.Content value="extensions" class="settings-panel">
<SettingsExtensions subtab={surface.view().subtab} onSubtab={(value) => surface.subtab(value)} />
@@ -336,7 +351,10 @@ 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 })
const [state, setState] = createStore({
worktreeFilterReset: 0,
modelProvider: undefined as string | undefined,
})
const groups = createMemo<SettingsNavGroup[]>(() => [
{
items: nestedServerTabs.map((item) => ({
@@ -391,10 +409,21 @@ function ServerSettings(props: { entry: SettingsServer }) {
/>
</Tabs.Content>
<Tabs.Content value="providers" class="settings-panel">
<SettingsProviders directory={undefined} onBack={() => surface.select("providers")} />
<SettingsProviders
directory={undefined}
onBack={() => surface.select("providers")}
onSelectProvider={(providerID) => {
setState("modelProvider", providerID)
surface.select("models")
}}
/>
</Tabs.Content>
<Tabs.Content value="models" class="settings-panel">
<SettingsModels active={surface.view().tab === "models"} />
<SettingsModels
active={surface.view().tab === "models"}
provider={state.modelProvider}
onReveal={() => setState("modelProvider", undefined)}
/>
</Tabs.Content>
<Tabs.Content value="extensions" class="settings-panel">
<SettingsExtensions subtab={surface.view().subtab} onSubtab={(value) => surface.subtab(value)} />
@@ -1,4 +1,6 @@
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"
@@ -18,13 +20,22 @@ 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(
@@ -0,0 +1,17 @@
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()
})
})
@@ -0,0 +1,11 @@
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
}
}
+18 -8
View File
@@ -8,10 +8,11 @@ import { emitIpcEvent } from "../ipc-events"
import { DesktopLogging, scoped } from "../native/logging"
import { DesktopStorage } from "../storage"
import { safeWebContentsURL } from "../windows/state"
import { getLastFocusedWindow, makeMainWindows, setAppQuitting, setRelaunchHandler } from "../windows"
import { getLastFocusedWindow, getWindowByID, 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
@@ -34,11 +35,23 @@ 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 win = getLastFocusedWindow()
const target = urls.flatMap((url) => {
const id = consoleReturnWindow(url)
const win = id ? getWindowByID(id) : null
return win ? [win] : []
})[0]
const win = target ?? getLastFocusedWindow()
if (win) emitIpcEvent(win.webContents, new DeepLinksOpened({ urls }))
return win
}
const relaunch = () => {
setAppQuitting()
@@ -57,17 +70,14 @@ 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 }))
emitDeepLinks(urls)
focusWindow(emitDeepLinks(urls) ?? null)
}
const win = getLastFocusedWindow()
if (!win) return
win.show()
win.focus()
if (!urls.length) focusWindow(getLastFocusedWindow())
}
const openUrl = (event: Event, url: string) => {
event.preventDefault()
runFork(Effect.logInfo("deep link received via open-url", { url }))
emitDeepLinks([url])
focusWindow(emitDeepLinks([url]) ?? null)
}
const beforeQuit = (event: Event) => {
setAppQuitting()
@@ -70,6 +70,12 @@ 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,6 +25,8 @@ 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,9 +1,6 @@
// 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
@@ -31,6 +28,9 @@ export function createWindowRegistry<W>(persistence: {
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,6 +63,7 @@ export type ElectronAPI = {
getPathForFile(file: File): string
saveFile(opts: SaveFilePickerOptions, content: string): Promise<boolean>
openExternal(url: string): void
openBrowser(url: string): Promise<boolean>
openLocalFile(url: string): void
openPath(path: string, app?: string): Promise<string | undefined>
revealPath(path: string): Promise<boolean>
+1
View File
@@ -122,6 +122,7 @@ 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,6 +21,7 @@ function fileApi(events: string[]) {
getPathForFile: () => "fallback",
saveFile: async () => false,
openExternal: () => {},
openBrowser: async () => true,
openLocalFile: () => {},
resolveAppPath: async () => null,
openPath: async () => undefined,
@@ -33,6 +34,10 @@ 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,6 +11,7 @@ type DesktopFileAPI = Pick<
| "getPathForFile"
| "saveFile"
| "openExternal"
| "openBrowser"
| "openLocalFile"
| "resolveAppPath"
| "openPath"
@@ -55,6 +56,7 @@ 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,6 +43,10 @@ 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 },
})
@@ -68,6 +72,7 @@ export const FileRpcs = RpcGroup.make(
FilesReleasePickedFiles,
FilesSaveFile,
FilesOpenExternal,
FilesOpenBrowser,
FilesOpenLocalFile,
FilesOpenPath,
FilesRevealPath,
@@ -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="provider-icon"]')).toBeVisible()
await expect(short.locator('[data-component="logo-mark"]')).toBeVisible()
await expect(long).toBeVisible()
await expect(long.locator('[data-component="provider-icon"]')).toBeVisible()
await expect(long.locator('[data-component="logo-mark"]')).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,13 +1,15 @@
import { ProviderIcon } from "@opencode/ui/provider-icon"
import { Mark } from "@opencode/ui/logo"
import { Show } from "solid-js"
export function TimelineSeparator(props: { label: string; providerID?: string; variant?: string }) {
export function TimelineSeparator(props: { label: string; logo?: boolean; 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.providerID}>
{(providerID) => <ProviderIcon id={providerID()} class="text-v2-icon-icon-faint" aria-hidden="true" />}
<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>
<span class="flex min-w-0 items-center gap-1.5">
<bdi dir="auto" class="truncate" title={props.label}>
@@ -392,7 +392,6 @@ 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,
@@ -508,7 +507,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} providerID={model().providerID} variant={model().variant} />
<TimelineSeparator label={model().label} logo variant={model().variant} />
</div>
)}
</Show>
+10 -10
View File
@@ -1,7 +1,7 @@
import { Plugin } from "@opencode/plugin/tui"
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { createMemo, createSignal, Show } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { createSignal, Show } from "solid-js"
import { Spinner } from "../../component/spinner"
import { useConfig } from "../../config"
import { useClipboard } from "../../context/clipboard"
@@ -90,8 +90,6 @@ function Answer(props: { question: string; answer: string }) {
const overlay = useTheme("overlay")
const syntax = useThemes().currentSyntax
const config = useConfig().data
const dimensions = useTerminalDimensions()
const maxHeight = createMemo(() => Math.max(3, Math.floor(dimensions().height / 2)))
const [copied, setCopied] = createSignal(false)
let scroll: ScrollBoxRenderable | undefined
@@ -111,8 +109,8 @@ function Answer(props: { question: string; answer: string }) {
if (!scroll) return
if (event.name === "up") return scroll.scrollBy(-1)
if (event.name === "down") return scroll.scrollBy(1)
if (event.name === "pageup") return scroll.scrollBy(-maxHeight())
if (event.name === "pagedown") return scroll.scrollBy(maxHeight())
if (event.name === "pageup") return scroll.scrollBy(-20)
if (event.name === "pagedown") return scroll.scrollBy(20)
if (event.name === "home") return scroll.scrollTo(0)
if (event.name === "end") return scroll.scrollTo(scroll.scrollHeight)
})
@@ -128,13 +126,15 @@ function Answer(props: { question: string; answer: string }) {
esc
</text>
</box>
<text fg={theme.text.subdued} wrapMode="word">
{props.question}
</text>
<box paddingTop={1}>
<text fg={theme.text.subdued} wrapMode="word">
{props.question}
</text>
</box>
</box>
<scrollbox
ref={(element: ScrollBoxRenderable) => (scroll = element)}
maxHeight={maxHeight()}
maxHeight={20}
backgroundColor={overlay.background.default}
scrollbarOptions={{ visible: false }}
scrollAcceleration={getScrollAcceleration(config)}
+26 -11
View File
@@ -17,6 +17,12 @@ import { Dialog as Kobalte } from "@kobalte/core/dialog"
import { makeEventListener } from "@solid-primitives/event-listener"
type DialogElement = () => JSX.Element
export type DialogOptions = { dismissOnBackdrop?: boolean }
const DialogOptionsContext = createContext<DialogOptions>({})
export function useDialogOptions() {
return useContext(DialogOptionsContext)
}
type Active = {
id: string
@@ -75,7 +81,13 @@ function init() {
makeEventListener(window, "keydown", onKeyDown, { capture: true })
})
const mount = (element: DialogElement, owner: Owner, onClose: (() => void) | undefined, layer: number) => {
const mount = (
element: DialogElement,
owner: Owner,
onClose: (() => void) | undefined,
layer: number,
options: DialogOptions = {},
) => {
const id = Math.random().toString(36).slice(2)
const zIndex = 50 + layer * 10
let dispose: (() => void) | undefined
@@ -100,7 +112,10 @@ function init() {
<Kobalte.Overlay
data-component="dialog-overlay"
style={{ "z-index": String(zIndex) }}
onClick={() => close(id)}
onClick={() => {
if (options.dismissOnBackdrop === false) return
close(id)
}}
/>
<div
data-dialog-layer={layer}
@@ -114,7 +129,7 @@ function init() {
"pointer-events": "none",
}}
>
{element()}
<DialogOptionsContext.Provider value={options}>{element()}</DialogOptionsContext.Provider>
</div>
</Kobalte.Portal>
</Kobalte>
@@ -128,16 +143,16 @@ function init() {
setStack((items) => [...items, active])
}
const push = (element: DialogElement, owner: Owner, onClose?: () => void) => {
const push = (element: DialogElement, owner: Owner, onClose?: () => void, options?: DialogOptions) => {
if (timer.current !== undefined) {
clearTimeout(timer.current)
timer.current = undefined
}
lock.value = false
mount(element, owner, onClose, stack().length)
mount(element, owner, onClose, stack().length, options)
}
const show = (element: DialogElement, owner: Owner, onClose?: () => void) => {
const show = (element: DialogElement, owner: Owner, onClose?: () => void, options?: DialogOptions) => {
for (const item of stack()) item.dispose()
setStack([])
if (timer.current !== undefined) {
@@ -145,7 +160,7 @@ function init() {
timer.current = undefined
}
lock.value = false
mount(element, owner, onClose, 0)
mount(element, owner, onClose, 0, options)
}
return {
@@ -183,13 +198,13 @@ export function useDialog() {
get active() {
return ctx.stack().at(-1)
},
show(element: DialogElement, onClose?: () => void) {
show(element: DialogElement, onClose?: () => void, options?: DialogOptions) {
const base = ctx.stack().at(-1)?.owner ?? owner
return startTransition(() => ctx.show(element, base, onClose))
return startTransition(() => ctx.show(element, base, onClose, options))
},
push(element: DialogElement, onClose?: () => void) {
push(element: DialogElement, onClose?: () => void, options?: DialogOptions) {
const base = ctx.stack().at(-1)?.owner ?? owner
return startTransition(() => ctx.push(element, base, onClose))
return startTransition(() => ctx.push(element, base, onClose, options))
},
close() {
ctx.close()
+19 -3
View File
@@ -1,6 +1,7 @@
import { CloseButton, Content, Description, Title } from "@kobalte/core/dialog"
import { type ComponentProps, type JSXElement, type ParentProps, Show, children, splitProps } from "solid-js"
import { useI18n } from "../../context/i18n"
import { useDialogOptions } from "../../context/dialog"
import "./dialog.css"
export interface DialogProps extends ParentProps {
@@ -10,6 +11,7 @@ export interface DialogProps extends ParentProps {
containerClass?: ComponentProps<"div">["class"]
classList?: ComponentProps<"div">["classList"]
fit?: boolean
onCloseAutoFocus?: ComponentProps<typeof Content>["onCloseAutoFocus"]
}
export interface DialogHeaderProps extends ParentProps {
@@ -19,7 +21,7 @@ export interface DialogHeaderProps extends ParentProps {
export interface DialogTitleGroupProps {
title?: JSXElement
description: JSXElement
description?: JSXElement
}
export function DialogFooter(props: ParentProps) {
@@ -46,7 +48,7 @@ export function DialogTitleGroup(props: DialogTitleGroupProps) {
return (
<div data-slot="dialog-title-group">
<Show when={title()}>{(t) => <Title data-slot="dialog-title">{t()}</Title>}</Show>
<Description data-slot="dialog-description">{description()}</Description>
<Show when={description()}>{(value) => <Description data-slot="dialog-description">{value()}</Description>}</Show>
</div>
)
}
@@ -82,7 +84,17 @@ export function DialogHeader(props: DialogHeaderProps) {
}
export function Dialog(props: DialogProps) {
const [local] = splitProps(props, ["size", "variant", "class", "containerClass", "classList", "fit", "children"])
const options = useDialogOptions()
const [local] = splitProps(props, [
"size",
"variant",
"class",
"containerClass",
"classList",
"fit",
"children",
"onCloseAutoFocus",
])
return (
<div
@@ -94,6 +106,10 @@ export function Dialog(props: DialogProps) {
<div data-slot="dialog-container" class={local.containerClass}>
<Content
data-slot="dialog-content"
onCloseAutoFocus={local.onCloseAutoFocus}
onPointerDownOutside={(event) => {
if (options.dismissOnBackdrop === false) event.preventDefault()
}}
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,