Compare commits

...
112 changed files with 2076 additions and 1229 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(
+1 -1
View File
@@ -9,7 +9,7 @@ import { Keymap } from "../../tui/src/context/keymap"
export function ErrorOverlay(props: { component: string; error: unknown; onClose: () => void }) {
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const theme = useTheme("elevated")
const theme = useTheme().surface("dialog")
const focus = renderer.currentFocusedRenderable
onCleanup(Keymap.use().mode.push("modal"))
Keymap.createLayer(() => ({
+65 -61
View File
@@ -8,7 +8,9 @@ export type LocationPublicRef = { directory: string }
export type ModelRef = { id: string; providerID: string; variant?: string }
export type ProviderSettings = { [x: string]: any }
export type ProviderCompaction = { type: "summary" } | { type: "native" }
export type ProviderTransport = "http" | "websocket"
export type AgentColor = string
@@ -218,19 +220,8 @@ export type ModelReasoningField = "reasoning" | "reasoning_content" | "reasoning
export type ModelMaxTokensField = "max_completion_tokens" | "max_tokens"
export type ProviderCompaction = { mode: "local" } | { mode: "provider"; threshold?: number }
export type ProviderTransport = "http" | "websocket"
export type ModelCapabilities = { tools: boolean; input: Array<string>; output: Array<string> }
export type ModelVariant = {
id: string
settings?: { [x: string]: any }
headers?: { [x: string]: string }
body?: { [x: string]: any }
}
export type MoneyUSDPerMillionTokens = number
export type GenerateTextResponse = { data: { text: string } }
@@ -465,11 +456,19 @@ export type V2EventServerConnected = {
data: {}
}
export type ProviderRequest = {
settings: ProviderSettings
headers: { [x: string]: string }
body: { [x: string]: any }
}
export type ProviderSettings = {
timeout?: number | false
chunkTimeout?: number
compaction?: ProviderCompaction
transport?: ProviderTransport
} & { [x: string]: any }
export type ConfigProviderSettings = {
timeout?: number | false
chunkTimeout?: number
compaction?: ProviderCompaction
transport?: ProviderTransport
} & { [x: string]: JsonValue | null }
export type PermissionRule = { action: string; resource: string; effect: PermissionEffect }
@@ -1443,20 +1442,6 @@ export type ModelCompatibility = {
supportsPromptCacheKey?: boolean
}
export type ProviderInfo = {
id: string
canonical?: string
integrationID?: string
name: string
activation: "auto" | "enabled" | "disabled"
package: string
compaction?: ProviderCompaction
transport?: ProviderTransport
settings?: { [x: string]: any }
headers?: { [x: string]: string }
body?: { [x: string]: any }
}
export type ModelCost = {
tier?: { type: "context"; size: number }
input: MoneyUSDPerMillionTokens
@@ -1671,6 +1656,31 @@ export type SessionInboxMove = {
payload: SessionInboxMovePayload
}
export type ProviderRequest = {
settings: ProviderSettings
headers: { [x: string]: string }
body: { [x: string]: any }
}
export type ModelVariant = {
id: string
settings?: ProviderSettings
headers?: { [x: string]: string }
body?: { [x: string]: any }
}
export type ProviderInfo = {
id: string
canonical?: string
integrationID?: string
name: string
activation: "auto" | "enabled" | "disabled"
package: string
settings?: ProviderSettings
headers?: { [x: string]: string }
body?: { [x: string]: any }
}
export type PermissionRuleset = Array<PermissionRule>
export type SessionRevertStaged = {
@@ -1854,29 +1864,6 @@ export type FormField =
export type FormState = { status: "pending" } | { status: "answered"; answer: FormAnswer } | { status: "cancelled" }
export type ModelInfo = {
id: string
modelID: string
providerID: string
canonical?: string
family?: string
name: string
compatibility?: ModelCompatibility
package?: string
compaction?: ProviderCompaction
transport?: ProviderTransport
settings?: { [x: string]: any }
headers?: { [x: string]: string }
body?: { [x: string]: any }
capabilities: ModelCapabilities
variants: Array<ModelVariant>
time: { released: number }
cost: Array<ModelCost>
status: "alpha" | "beta" | "deprecated" | "active"
enabled: boolean
limit: { context: number; input?: number; output: number }
}
export type FormField1 =
| FormStringField1
| FormNumberField1
@@ -1902,6 +1889,27 @@ export type ReferenceInfo = {
source: ReferenceSource
}
export type ModelInfo = {
id: string
modelID: string
providerID: string
canonical?: string
family?: string
name: string
compatibility?: ModelCompatibility
package?: string
settings?: ProviderSettings
headers?: { [x: string]: string }
body?: { [x: string]: any }
capabilities: ModelCapabilities
variants: Array<ModelVariant>
time: { released: number }
cost: Array<ModelCost>
status: "alpha" | "beta" | "deprecated" | "active"
enabled: boolean
limit: { context: number; input?: number; output: number }
}
export type AgentInfo = {
id: string
name: string
@@ -2085,31 +2093,27 @@ export type ConfigEntry =
warming?: boolean | { prompt?: string; interval?: string; duration?: string }
providers?: {
[x: string]: {
compaction?: ProviderCompaction
transport?: ProviderTransport
canonical?: string
name?: string
env?: Array<string>
package?: string
settings?: { [x: string]: JsonValue }
settings?: ConfigProviderSettings
headers?: { [x: string]: string }
body?: { [x: string]: JsonValue }
models?: {
[x: string]: {
compaction?: ProviderCompaction
transport?: ProviderTransport
modelID?: string
family?: string
name?: string
compatibility?: ModelCompatibility
package?: string
settings?: { [x: string]: JsonValue }
settings?: ConfigProviderSettings
headers?: { [x: string]: string }
body?: { [x: string]: JsonValue }
capabilities?: ModelCapabilities
variants?: Array<{
id: string
settings?: { [x: string]: JsonValue }
settings?: ConfigProviderSettings
headers?: { [x: string]: string }
body?: { [x: string]: JsonValue }
}>
+6 -1
View File
@@ -128,6 +128,8 @@ function prepareOptions(model: Info, pkg: string) {
const customFetch = options.fetch
const chunkTimeout = options.chunkTimeout
delete options.chunkTimeout
delete options.compaction
delete options.transport
options.fetch = async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
const opts = { ...(init ?? {}) }
const signals = [
@@ -388,7 +390,10 @@ function requestSettings(settings: Readonly<Record<string, unknown>> | undefined
if (settings === undefined) return undefined
const result = Object.fromEntries(
Object.entries(settings).filter(
([key]) => !["apiKey", "authToken", "baseURL", "chunkTimeout", "fetch", "timeout"].includes(key),
([key]) =>
!["apiKey", "authToken", "baseURL", "chunkTimeout", "compaction", "fetch", "timeout", "transport"].includes(
key,
),
),
)
return Object.keys(result).length === 0 ? undefined : result
@@ -68,8 +68,6 @@ export const Plugin = define({
if (item.canonical !== undefined) provider.canonical = item.canonical
if (item.name !== undefined) provider.name = item.name
if (item.package !== undefined) provider.package = item.package
if (item.compaction !== undefined) provider.compaction = { ...item.compaction }
if (item.transport !== undefined) provider.transport = item.transport
if (item.settings !== undefined) provider.settings = Provider.mergeOverlay(provider.settings, item.settings)
if (item.headers !== undefined) provider.headers = Provider.mergeHeaders(provider.headers, item.headers)
if (item.body !== undefined) provider.body = Provider.mergeOverlay(provider.body, item.body)
@@ -116,8 +114,6 @@ export const Plugin = define({
if (config.compatibility !== undefined)
model.compatibility = { ...model.compatibility, ...config.compatibility }
if (config.package !== undefined) model.package = config.package
if (config.compaction !== undefined) model.compaction = { ...config.compaction }
if (config.transport !== undefined) model.transport = config.transport
if (config.settings !== undefined) model.settings = Provider.mergeOverlay(model.settings, config.settings)
if (config.headers !== undefined) model.headers = Provider.mergeHeaders(model.headers, config.headers)
if (config.body !== undefined) model.body = Provider.mergeOverlay(model.body, config.body)
+61 -14
View File
@@ -36,6 +36,7 @@ export type Status = Background["status"]
const decodeBackground = Schema.decodeUnknownResult(Background)
const backgroundPrefix = "job.background/"
const COMPLETED_LIMIT = 25
export type Info = {
id: string
@@ -57,6 +58,7 @@ type Active = {
scope: Scope.Closeable
blockingSessions: Map<SessionSchema.ID, number>
isBackgrounded: boolean
consumed: boolean
recovery?: Recovery
}
@@ -69,6 +71,7 @@ type FinishResult = {
info?: Info
done?: Deferred.Deferred<Info>
scope?: Scope.Closeable
generation?: Scope.Closeable
}
type BackgroundResult = {
@@ -81,11 +84,12 @@ type StartResult = { info: Info } | { info: Info; scope: Scope.Closeable }
type BlockWait = {
done: Deferred.Deferred<Info>
backgrounded: Deferred.Deferred<Info>
generation: Scope.Closeable
}
type BlockStart =
| { type: "missing" }
| { type: "finished"; info: Info }
| { type: "finished"; info: Info; generation: Scope.Closeable }
| { type: "backgrounded"; info: Info }
| { type: "wait"; wait: BlockWait }
@@ -168,6 +172,9 @@ function decrementSession(input: Map<SessionSchema.ID, number>, sessionID: Sessi
/**
* Makes one scoped, process-local registry. Explicitly recoverable background
* work also owns a durable notification marker until its notification is admitted.
* Unconsumed results survive the start-to-wait handoff. Foreground block/cancel
* and non-recoverable wait results enter a 25-entry consumed history. Recoverable
* wait results stay available for background registration and acknowledgment.
*/
export const make = Effect.gen(function* () {
const kv = yield* KV.Service
@@ -176,6 +183,19 @@ export const make = Effect.gen(function* () {
scope: yield* Scope.Scope,
}
const consume = (id: string, generation: Scope.Closeable) =>
SynchronizedRef.update(state.jobs, (jobs) => {
const job = jobs.get(id)
if (!job || job.scope !== generation || job.info.status === "running" || job.consumed) return jobs
const next = new Map(jobs)
// Order history by first consumption, not by start time or subsequent reads.
next.delete(id)
next.set(id, { ...job, consumed: true })
const completed = [...next].filter(([, job]) => job.consumed && !job.info.notificationID)
for (const [id] of completed.slice(0, -COMPLETED_LIMIT)) next.delete(id)
return next
})
const persistBackground = Effect.fnUntraced(function* (job: Active) {
if (!job.recovery || !job.info.notificationID) return
yield* kv.set(`${backgroundPrefix}${job.info.notificationID}`, {
@@ -260,6 +280,7 @@ export const make = Effect.gen(function* () {
scope,
blockingSessions: new Map<SessionSchema.ID, number>(),
isBackgrounded: false,
consumed: false,
recovery: input.recovery,
}
return [{ info: snapshot(job), scope }, new Map(jobs).set(id, job)]
@@ -280,12 +301,19 @@ export const make = Effect.gen(function* () {
const wait: Interface["wait"] = Effect.fn("Job.wait")(function* (input) {
const job = (yield* SynchronizedRef.get(state.jobs)).get(input.id)
if (!job) return { timedOut: false }
if (job.info.status !== "running") return { info: snapshot(job), timedOut: false }
if (input.timeout === undefined) return { info: yield* Deferred.await(job.done), timedOut: false }
if (input.timeout <= 0) return { info: snapshot(job), timedOut: true }
const info = yield* Deferred.await(job.done).pipe(Effect.timeoutOption(input.timeout))
if (info._tag === "Some") return { info: info.value, timedOut: false }
return { info: snapshot(job), timedOut: true }
return yield* Effect.gen(function* () {
if (job.info.status !== "running") return { info: snapshot(job), timedOut: false }
if (input.timeout === undefined) return { info: yield* Deferred.await(job.done), timedOut: false }
if (input.timeout <= 0) return { info: snapshot(job), timedOut: true }
const info = yield* Deferred.await(job.done).pipe(Effect.timeoutOption(input.timeout))
if (info._tag === "Some") return { info: info.value, timedOut: false }
return { info: snapshot(job), timedOut: true }
}).pipe(
// Recoverable wait -> background is a supported handoff, even after failure.
Effect.tap((result) =>
result.info.status === "running" || job.recovery ? Effect.void : consume(input.id, job.scope),
),
)
})
const removeBlock = Effect.fnUntraced(function* (input: BlockInput) {
@@ -303,10 +331,10 @@ export const make = Effect.gen(function* () {
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [BlockStart, Map<string, Active>] => {
const job = jobs.get(input.id)
if (!job) return [{ type: "missing" }, jobs]
if (job.info.status !== "running") return [{ type: "finished", info: snapshot(job) }, jobs]
if (job.info.status !== "running") return [{ type: "finished", info: snapshot(job), generation: job.scope }, jobs]
if (job.isBackgrounded) return [{ type: "backgrounded", info: snapshot(job) }, jobs]
return [
{ type: "wait", wait: { done: job.done, backgrounded: job.backgrounded } },
{ type: "wait", wait: { done: job.done, backgrounded: job.backgrounded, generation: job.scope } },
new Map(jobs).set(input.id, {
...job,
blockingSessions: incrementSession(job.blockingSessions, input.sessionID),
@@ -314,12 +342,18 @@ export const make = Effect.gen(function* () {
]
})
if (result.type === "missing") return undefined
if (result.type === "finished") return { type: "finished", info: result.info }
if (result.type === "finished") {
yield* consume(input.id, result.generation)
return { type: "finished", info: result.info }
}
if (result.type === "backgrounded") return { type: "backgrounded", info: result.info }
return yield* Effect.raceFirst(
Deferred.await(result.wait.done).pipe(Effect.map((info) => ({ type: "finished" as const, info }))),
Deferred.await(result.wait.backgrounded).pipe(Effect.map((info) => ({ type: "backgrounded" as const, info }))),
).pipe(Effect.ensuring(removeBlock(input)))
).pipe(
Effect.tap((outcome) => (outcome.type === "finished" ? consume(input.id, result.wait.generation) : Effect.void)),
Effect.ensuring(removeBlock(input)),
)
})
const markBackground = Effect.fnUntraced(function* (job: Active) {
@@ -383,7 +417,7 @@ export const make = Effect.gen(function* () {
Effect.fnUntraced(function* (jobs): Effect.fn.Return<readonly [FinishResult, Map<string, Active>]> {
const job = jobs.get(id)
if (!job) return [{}, jobs]
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
if (job.info.status !== "running") return [{ info: snapshot(job), generation: job.scope }, jobs]
const next = {
...job,
blockingSessions: new Map<SessionSchema.ID, number>(),
@@ -394,11 +428,15 @@ export const make = Effect.gen(function* () {
},
}
yield* persistBackground(next)
return [{ info: snapshot(next), done: job.done, scope: job.scope }, new Map(jobs).set(id, next)]
return [
{ info: snapshot(next), done: job.done, scope: job.scope, generation: job.scope },
new Map(jobs).set(id, next),
]
}),
)
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info)
if (result.scope) yield* Scope.close(result.scope, Exit.void)
if (result.generation) yield* consume(id, result.generation)
return result.info
})
@@ -414,7 +452,16 @@ export const make = Effect.gen(function* () {
}).pipe(Effect.withSpan("Job.pendingBackground"))
const completeBackground: Interface["completeBackground"] = Effect.fn("Job.completeBackground")((notificationID) =>
kv.remove(`${backgroundPrefix}${notificationID}`),
SynchronizedRef.updateEffect(state.jobs, (jobs) =>
Effect.gen(function* () {
yield* kv.remove(`${backgroundPrefix}${notificationID}`)
const entry = [...jobs].find(([, job]) => job.info.notificationID === notificationID)
if (!entry || entry[1].info.status === "running") return jobs
const next = new Map(jobs)
next.delete(entry[0])
return next
}),
),
)
return Service.of({
+9 -5
View File
@@ -118,9 +118,9 @@ export interface Resolved {
/** Catalog token limits used by Core for context management. */
readonly limit: Info["limit"]
/** Model policy overrides the provider policy; omitted means local compaction. */
readonly compaction?: Info["compaction"]
readonly compaction?: Provider.Compaction
/** Model transport overrides the provider transport; omitted means HTTP. */
readonly transport?: Info["transport"]
readonly transport?: Provider.Transport
}
export interface Interface {
@@ -178,7 +178,11 @@ export const fromCatalogModel = (
Effect.flatMap((resolved) => validateProviderVariables(model, resolved)),
Effect.flatMap((resolved) => {
// Reject provider compaction policies up front so the misconfiguration surfaces before any step runs.
if (model.compaction?.mode !== "provider" || resolved.route.compact?.trigger || resolved.route.compact?.endpoint)
if (
model.settings?.compaction?.type !== "native" ||
resolved.route.compact?.trigger ||
resolved.route.compact?.endpoint
)
return Effect.succeed(resolved)
return Effect.fail(
new UnsupportedCompactionError({ providerID: model.providerID, modelID: model.id, route: resolved.route.id }),
@@ -377,8 +381,8 @@ export const layer = Layer.effect(
capabilities: selected.capabilities,
cost: selected.cost,
limit: selected.limit,
compaction: selected.compaction,
transport: selected.transport,
compaction: runtimeInfo.settings?.compaction,
transport: runtimeInfo.settings?.transport,
}
})
return Service.of({
-2
View File
@@ -188,8 +188,6 @@ const layer = Layer.effect(
...model,
...(provider?.canonical === undefined ? {} : { canonical: provider.canonical }),
package: model.package ?? provider?.package,
compaction: model.compaction ?? provider?.compaction,
transport: model.transport ?? provider?.transport,
settings: Provider.mergeOverlay(provider?.settings, model.settings),
headers: Provider.mergeHeaders(provider?.headers, model.headers),
body: Provider.mergeOverlay(provider?.body, model.body),
+2
View File
@@ -95,6 +95,7 @@ import { ProviderPlugins } from "./provider.js"
import { WebSearchPlugins } from "./websearch/index.js"
import { SkillPlugin } from "./skill.js"
import { VcsHgPlugin } from "./vcs/hg.js"
import { ToolInputRepairPlugin } from "./tool-input-repair.js"
import { OptimizePlugin } from "./optimize.js"
import { VcsGitPlugin } from "./vcs/git.js"
import { WarmingPlugin } from "./warming.js"
@@ -205,6 +206,7 @@ export const requirements = LayerNode.group([
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
const pre = [
ToolInputRepairPlugin.Plugin,
ConfigWorktreePlugin.Plugin,
BrowserPlugin,
ConfigMcpPlugin.Plugin,
+3 -1
View File
@@ -168,7 +168,9 @@ export const AzurePlugin = define({
resolveResourceName(draft.settings, resourceName) ?? resourceName,
)
if (responsesWebSocketCapable(item.provider, draft))
draft.transport = item.provider.transport ?? "websocket"
draft.settings = Provider.mergeOverlay(draft.settings, {
transport: item.provider.settings?.transport ?? "websocket",
})
})
}
}
+3 -1
View File
@@ -270,7 +270,9 @@ export const OpenAIPlugin = define({
// ChatGPT-plan tokens only authorize codex-eligible models, and the
// subscription covers usage, so hide the rest and zero the cost.
models.update(model.providerID, model.id, (draft) => {
draft.transport = models.provider.get(model.providerID)?.provider.transport ?? "websocket"
draft.settings = Provider.mergeOverlay(draft.settings, {
transport: models.provider.get(model.providerID)?.provider.settings?.transport ?? "websocket",
})
if (!chatgpt) return
if (Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(draft.body?.reasoning)) {
draft.enabled = false
+3 -1
View File
@@ -98,7 +98,9 @@ export const XAIPlugin = define({
yield* ctx.model.transform((models) => {
for (const model of models.list(providerID)) {
models.update(providerID, model.id, (draft) => {
draft.transport = models.provider.get(providerID)?.provider.transport ?? "websocket"
draft.settings = Provider.mergeOverlay(draft.settings, {
transport: models.provider.get(providerID)?.provider.settings?.transport ?? "websocket",
})
})
}
})
@@ -0,0 +1,177 @@
export * as ToolInputRepairPlugin from "./tool-input-repair.js"
import { define } from "@opencode/plugin/effect/plugin"
import type { ToolEditor } from "@opencode/plugin/effect/tool"
import { Effect, JsonSchema, Option, Predicate, Schema } from "effect"
import { definition } from "../tool/runtime.js"
// Repairs apply only when the input schema unambiguously supports them:
// - Stringified root or nested object: '{"limit":"20"}' -> { limit: 20 }
// - Closed object: { limit: "20", extra: true } -> { limit: 20 }
// - Optional null or empty-object placeholder: { limit: null } -> {}
// - Numeric or boolean string: { limit: "20", enabled: "false" } -> { limit: 20, enabled: false }
// - Nullable field: { count: "2" } -> { count: 2 }
// - Stringified array or compatible item: { tags: '["a"]', count: "2" } -> { tags: ["a"], count: [2] }
// - Positional tuple: { pair: ["2", "false"] } -> { pair: [2, false] }
// - Typed dictionary: { counts: { first: "2" } } -> { counts: { first: 2 } }
// - Nested fields and local references: { items: [{ count: "2" }] } -> { items: [{ count: 2 }] }
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
const maxDepth = 6
export const Plugin = define({
id: "opencode.tool.input.repair",
effect: Effect.fn(function* (ctx) {
let get: ToolEditor["get"] = () => undefined
yield* ctx.tool.transform((draft) => {
// The draft sees later tool transforms too; reload replaces this lookup.
get = draft.get
})
yield* ctx.tool.hook("execute.before", (event) =>
Effect.sync(() => {
// The outer Code Mode tool is built per snapshot rather than registered, so it cannot be
// looked up here. Its `{ code }` input is trivial; the tools it calls are repaired normally.
if (event.tool === "execute") return
const tool = get(event.tool)
if (!tool) return
const schema = definition(tool).inputSchema
if (schema.type !== "object") return
event.input = repair(event.input, schema, schema, 0)
}),
)
}),
})
function repair(value: unknown, schema: JsonSchema.JsonSchema, root: JsonSchema.JsonSchema, depth: number): unknown {
if (depth > maxDepth) return value
if (typeof schema.$ref === "string") {
const definitions = /^#\/\$defs\/[^/]+$/.test(schema.$ref)
? root.$defs
: /^#\/definitions\/[^/]+$/.test(schema.$ref)
? root.definitions
: undefined
if (!Predicate.isObject(definitions)) return value
const target = Object.fromEntries(
Object.entries(definitions).filter((entry): entry is [string, JsonSchema.JsonSchema] =>
Predicate.isObject(entry[1]),
),
)[
schema.$ref
.slice(schema.$ref.lastIndexOf("/") + 1)
.replaceAll("~1", "/")
.replaceAll("~0", "~")
]
return target ? repair(value, target, root, depth + 1) : value
}
if (Array.isArray(schema.type)) {
if (value === null && schema.type.includes("null")) return value
if (schema.type.includes(typeof value)) return value
const types = schema.type.filter((type) => type !== "null")
return types.length === 1 ? repair(value, { ...schema, type: types[0] }, root, depth + 1) : value
}
if (schema.type === undefined) {
if (Array.isArray(schema.anyOf) && Array.isArray(schema.oneOf)) return value
const branches = Array.isArray(schema.anyOf) ? schema.anyOf : schema.oneOf
if (!Array.isArray(branches) || value === null) return value
if (branches.some((branch) => !Predicate.isObject(branch) || branch.type === typeof value)) return value
const candidates = branches.filter((branch) => Predicate.isObject(branch) && branch.type !== "null")
return candidates.length === 1 ? repair(value, candidates[0], root, depth + 1) : value
}
if (schema.type === "number" || schema.type === "integer") {
if (typeof value !== "string" || value.trim() === "") return value
const parsed = Number(value)
return Number.isFinite(parsed) && (schema.type !== "integer" || Number.isSafeInteger(parsed)) ? parsed : value
}
if (schema.type === "boolean") return value === "true" ? true : value === "false" ? false : value
if (schema.type === "object") return repairObject(value, schema, root, depth)
if (schema.type === "array") return repairArray(value, schema, root, depth)
return value
}
function repairObject(
value: unknown,
schema: JsonSchema.JsonSchema,
root: JsonSchema.JsonSchema,
depth: number,
): unknown {
const parsed = typeof value === "string" ? Option.getOrUndefined(decodeJson(value)) : value
if (!Predicate.isObject(parsed)) return value
const properties = Predicate.isObject(schema.properties) ? schema.properties : {}
const required = Array.isArray(schema.required) ? schema.required : []
const patterned = Predicate.isObject(schema.patternProperties)
const composed = Array.isArray(schema.allOf) || Array.isArray(schema.anyOf) || Array.isArray(schema.oneOf)
return Object.keys(parsed).reduce<Record<string, unknown>>((result, key) => {
const current = result[key]
const declared = Object.hasOwn(properties, key)
const property = declared ? properties[key] : !patterned ? schema.additionalProperties : undefined
if (!declared && schema.additionalProperties === false && !patterned && !composed) {
const next = { ...result }
delete next[key]
return next
}
if (!Predicate.isObject(property)) return result
// Only a bare single-type property provably rejects null and `{}`. Compositions, enums,
// constants, references and nullable flags may accept them, so those are left alone.
const plain =
typeof property.type === "string" &&
property.type !== "null" &&
!composed &&
["anyOf", "oneOf", "allOf", "enum", "const", "$ref", "nullable"].every((keyword) => !(keyword in property))
const placeholder = Predicate.isObject(current) && Object.keys(current).length === 0 && property.type !== "object"
if (declared && !required.includes(key) && plain && (current === null || placeholder)) {
const next = { ...result }
delete next[key]
return next
}
const repaired = repair(current, property, root, depth + 1)
return repaired === current ? result : { ...result, [key]: repaired }
}, parsed)
}
function repairArray(
value: unknown,
schema: JsonSchema.JsonSchema,
root: JsonSchema.JsonSchema,
depth: number,
): unknown {
const parsed = typeof value === "string" ? Option.getOrUndefined(decodeJson(value)) : value
const tuple = Array.isArray(schema.prefixItems)
? schema.prefixItems
: Array.isArray(schema.items)
? schema.items
: undefined
if (Array.isArray(parsed)) {
const repaired = parsed.map((item, index) => {
const member = tuple
? (tuple[index] ?? (Array.isArray(schema.prefixItems) ? schema.items : schema.additionalItems))
: schema.items
return Predicate.isObject(member) ? repair(item, member, root, depth + 1) : item
})
return repaired.every((item, index) => item === parsed[index]) ? parsed : repaired
}
if (tuple || !Predicate.isObject(schema.items)) return value
const repaired = repair(value, schema.items, root, depth + 1)
const type = schema.items.type
const compatible =
type === "object"
? Predicate.isObject(repaired)
: type === "array"
? Array.isArray(repaired)
: type === "integer"
? typeof repaired === "number" && Number.isSafeInteger(repaired)
: type === "number"
? typeof repaired === "number" && Number.isFinite(repaired)
: (type === "string" || type === "boolean") && typeof repaired === type
return compatible ? [repaired] : value
}
+9 -3
View File
@@ -132,11 +132,11 @@ export const loadPackage = Effect.fn("Provider.loadPackage")(function* (input: s
return yield* importPackage(specifier, entrypoint)
})
/** opencode transport settings consumed in aisdk.ts; native packages never receive them. */
const TRANSPORT_KEYS = ["chunkTimeout", "fetch", "timeout"] as const
/** opencode settings consumed in Core; native packages never receive them. */
const CORE_KEYS = ["chunkTimeout", "compaction", "fetch", "timeout", "transport"] as const
export function nativeSettings(settings: Settings): Settings {
return Struct.omit(settings, TRANSPORT_KEYS)
return Struct.omit(settings, CORE_KEYS)
}
export function mergeOverlay(
@@ -177,6 +177,12 @@ export function mergeHeaders(
export const Request = Provider.Request
export type Request = Provider.Request
export const Compaction = Provider.Compaction
export type Compaction = Provider.Compaction
export const Transport = Provider.Transport
export type Transport = Provider.Transport
export const Settings = Provider.Settings
export type Settings = Provider.Settings
+3 -8
View File
@@ -736,7 +736,7 @@ export const layer = Layer.effect(
const compact = Effect.fn("SessionCompaction.compact")(function* (input: AutoInput): Effect.fn.Return<Outcome> {
const request = { ...input, reason: "auto" as const }
if (input.overflow) return yield* recoverLocally(request)
if (input.context.model.compaction?.mode !== "provider") return yield* execute(request)
if (input.context.model.compaction?.type !== "native") return yield* execute(request)
return yield* executeProvider(request)
})
const required = (input: RequiredInput) => {
@@ -759,12 +759,7 @@ export const layer = Layer.effect(
limit.input === undefined ? Number.POSITIVE_INFINITY : limit.input - config.buffer,
context - Math.max(output, config.buffer),
)
const policy = input.resolved.compaction
const threshold =
policy?.mode === "provider" && policy.threshold !== undefined
? Math.min(policy.threshold, promptCeiling)
: promptCeiling
return estimateTokens(input) >= threshold
return estimateTokens(input) >= promptCeiling
}
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
if (findTailStart(input.messages, state.get().tokens) === undefined)
@@ -792,7 +787,7 @@ export const layer = Layer.effect(
inputID: input.inputID,
started: input.started,
}
return context.model.compaction?.mode === "provider" ? executeProvider(request) : execute(request)
return context.model.compaction?.type === "native" ? executeProvider(request) : execute(request)
},
}),
)
+6
View File
@@ -155,14 +155,18 @@ it.effect("projects request settings, headers, and body overlays", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
let body: unknown
let options: Record<string, unknown> | undefined
yield* aisdk.hook.sdk((event) => {
body = event.options.body
options = event.options
event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
})
const input = model("@ai-sdk/google", {
apiKey: "secret",
thinkingConfig: { thinkingBudget: 1024 },
compaction: { type: "native" },
transport: "websocket",
})
const resolved = yield* aisdk.model({
...input,
@@ -185,6 +189,8 @@ it.effect("projects request settings, headers, and body overlays", () =>
})
expect(prepared.body.headers).toEqual({ "x-test": "header" })
expect(body).toEqual({ safety_setting: "strict" })
expect(options).not.toHaveProperty("compaction")
expect(options).not.toHaveProperty("transport")
}),
)
+16 -25
View File
@@ -34,7 +34,7 @@ function required<T>(value: T | undefined): T {
const decode = Schema.decodeUnknownSync(Info)
describe("ConfigProviderPlugin.Plugin", () => {
it.effect("inherits provider compaction policy with model overrides and rejects unsupported routes", () =>
it.effect("inherits the provider compaction setting with model overrides and rejects unsupported routes", () =>
Effect.gen(function* () {
const models = yield* Model.Service
yield* addPlugin([
@@ -44,12 +44,10 @@ describe("ConfigProviderPlugin.Plugin", () => {
providers: {
custom: {
package: "@opencode/ai/providers/openai/responses",
compaction: { mode: "provider", threshold: 120_000 },
settings: { compaction: { type: "native" } },
models: {
native: {},
reset: { compaction: { mode: "provider" } },
threshold: { compaction: { mode: "provider", threshold: 90_000 } },
local: { compaction: { mode: "local" }, package: "@opencode/ai/providers/openai/chat" },
local: { settings: { compaction: { type: "summary" } }, package: "@opencode/ai/providers/openai/chat" },
unsupported: { package: "@opencode/ai/providers/openai/chat" },
},
},
@@ -62,16 +60,9 @@ describe("ConfigProviderPlugin.Plugin", () => {
const local = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("local")))
const unsupported = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("unsupported")))
const defaultModel = required(yield* models.get(Provider.ID.make("default"), Model.ID.make("chat")))
expect(native.compaction).toEqual({ mode: "provider", threshold: 120_000 })
expect((yield* models.get(Provider.ID.make("custom"), Model.ID.make("reset")))?.compaction).toEqual({
mode: "provider",
})
expect((yield* models.get(Provider.ID.make("custom"), Model.ID.make("threshold")))?.compaction).toEqual({
mode: "provider",
threshold: 90_000,
})
expect(local.compaction).toEqual({ mode: "local" })
expect(defaultModel.compaction).toBeUndefined()
expect(native.settings?.compaction).toEqual({ type: "native" })
expect(local.settings?.compaction).toEqual({ type: "summary" })
expect(defaultModel.settings?.compaction).toBeUndefined()
yield* ModelResolver.fromCatalogModel(native)
yield* ModelResolver.fromCatalogModel(local)
yield* ModelResolver.fromCatalogModel(defaultModel)
@@ -92,8 +83,8 @@ describe("ConfigProviderPlugin.Plugin", () => {
providers: {
custom: {
package: "@opencode/ai/providers/openai/responses",
transport: "http",
models: { inherited: {}, override: { transport: "websocket" } },
settings: { transport: "http" },
models: { inherited: {}, override: { settings: { transport: "websocket" } } },
},
default: { package: "@opencode/ai/providers/openai/responses", models: { untouched: {} } },
},
@@ -103,9 +94,9 @@ describe("ConfigProviderPlugin.Plugin", () => {
const inherited = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("inherited")))
const override = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("override")))
const untouched = required(yield* models.get(Provider.ID.make("default"), Model.ID.make("untouched")))
expect(inherited.transport).toBe("http")
expect(override.transport).toBe("websocket")
expect(untouched.transport).toBeUndefined()
expect(inherited.settings?.transport).toBe("http")
expect(override.settings?.transport).toBe("websocket")
expect(untouched.settings?.transport).toBeUndefined()
}),
)
@@ -131,7 +122,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
editor.models.update(providerID, modelID, () => {})
})
yield* builtin.plugin.effect(host)
expect((yield* models.get(providerID, modelID))?.transport).toBe("websocket")
expect((yield* models.get(providerID, modelID))?.settings?.transport).toBe("websocket")
yield* addPlugin([
new Document({
@@ -139,16 +130,16 @@ describe("ConfigProviderPlugin.Plugin", () => {
info: decode({
providers: {
[builtin.id]: {
transport: "http",
models: { override: { modelID: builtin.model, transport: "websocket" } },
settings: { transport: "http" },
models: { override: { modelID: builtin.model, settings: { transport: "websocket" } } },
},
},
}),
}),
])
expect((yield* models.get(providerID, modelID))?.transport).toBe("http")
expect((yield* models.get(providerID, Model.ID.make("override")))?.transport).toBe("websocket")
expect((yield* models.get(providerID, modelID))?.settings?.transport).toBe("http")
expect((yield* models.get(providerID, Model.ID.make("override")))?.settings?.transport).toBe("websocket")
}),
)
}
@@ -473,10 +473,10 @@ describe("AzurePlugin", () => {
yield* addPlugin()
const responses = required(yield* service.get(Provider.ID.azure, models.responses))
expect(responses.transport).toBe("websocket")
expect(responses.settings?.transport).toBe("websocket")
for (const modelID of [models.chat, models.preview, models.deploymentURL, models.gateway, models.nonAzure]) {
const model = required(yield* service.get(Provider.ID.azure, modelID))
expect(model.transport).toBeUndefined()
expect(model.settings?.transport).toBeUndefined()
}
}),
),
@@ -205,7 +205,7 @@ describe("OpenAIPlugin", () => {
expect(model.package).toBe("@opencode/ai/providers/openai")
expect(model.enabled).toBe(true)
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
expect(model.transport).toBe("websocket")
expect(model.settings?.transport).toBe("websocket")
expect(direct.headers).not.toHaveProperty("originator")
expect(direct.baseURL).toBe("https://api.openai.com/v1")
expect(provider.headers).not.toHaveProperty("x-codex-beta-features")
@@ -236,7 +236,7 @@ describe("OpenAIPlugin", () => {
id: "deployment-responses",
provider: Provider.ID.azure,
})
const prepare = (preference?: Model.Info["transport"]) =>
const prepare = (preference?: Provider.Transport) =>
Effect.gen(function* () {
const model = SessionRunnerModel.resolved(route.model({ id: "gpt-5.5" }), {
capabilities: { tools: true, input: ["text"], output: ["text"] },
@@ -84,7 +84,7 @@ describe("XAIPlugin", () => {
yield* addPlugin()
const model = yield* models.get(providerID, Model.ID.make("grok-4.6"))
expect(model?.transport).toBe("websocket")
expect(model?.settings?.transport).toBe("websocket")
}),
)
})
@@ -0,0 +1,114 @@
import { expect } from "bun:test"
import { Effect, Schema } from "effect"
import { Agent } from "@opencode/core/agent"
import { Plugin } from "@opencode/core/plugin"
import { ToolInputRepairPlugin } from "@opencode/core/plugin/tool-input-repair"
import { Session } from "@opencode/core/session"
import { SessionMessage } from "@opencode/core/session/message"
import { Tool } from "@opencode/core/tool"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const identity = {
sessionID: Session.ID.make("ses_repair"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_repair"),
}
it.effect("repairs tool input before validating its original schema", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const registry = yield* Tool.Service
const executed: unknown[] = []
yield* plugins.activate([
{ ...ToolInputRepairPlugin.Plugin, revision: "1" },
{
id: "repairable-tool",
revision: "1",
effect: (ctx) =>
ctx.tool.transform((draft) =>
draft.add({
name: "repairable",
options: { codemode: false },
description: "Repairable",
input: Schema.Struct({ count: Schema.Int, enabled: Schema.Boolean }),
execute: (input) => Effect.sync(() => executed.push(input)).pipe(Effect.as({ content: "ok" })),
}),
),
},
])
const snapshot = yield* registry.snapshot()
yield* snapshot.execute({
...identity,
call: {
type: "tool-call",
id: "call-repair",
name: "repairable",
input: '{"count":"2","enabled":"true","extra":true}',
},
})
expect(executed).toEqual([{ count: 2, enabled: true }])
yield* registry.transform((draft) => {
draft.update("repairable", (tool) => {
tool.input = Schema.Struct({ count: Schema.Boolean, enabled: Schema.Boolean })
})
})
const updated = yield* registry.snapshot()
yield* updated.execute({
...identity,
call: {
type: "tool-call",
id: "call-updated",
name: "repairable",
input: { count: "false", enabled: "true" },
},
})
expect(executed).toEqual([
{ count: 2, enabled: true },
{ count: false, enabled: true },
])
yield* registry.transform((draft) => draft.remove("repairable"))
const removed = yield* registry.snapshot()
expect(
(yield* removed
.execute({
...identity,
call: { type: "tool-call", id: "call-removed", name: "repairable", input: {} },
})
.pipe(Effect.flip)).message,
).toBe('No tool named "repairable" is currently available. Please use a tool from the available tool list.')
}),
)
it.effect("repairs namespaced inner tool input called from Code Mode", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const registry = yield* Tool.Service
const executed: unknown[] = []
yield* plugins.activate([{ ...ToolInputRepairPlugin.Plugin, revision: "1" }])
yield* registry.transform((draft) =>
draft.add({
name: "count",
options: { namespace: "example" },
description: "Record a count",
input: Schema.Struct({ count: Schema.Int }),
execute: (input) => Effect.sync(() => executed.push(input)).pipe(Effect.as({ content: "ok" })),
}),
)
const snapshot = yield* registry.snapshot()
yield* snapshot.execute({
...identity,
call: {
type: "tool-call",
id: "call-codemode-repair",
name: "execute",
input: { code: 'return await tools.example.count({ count: "3" })' },
},
})
expect(executed).toEqual([{ count: 3 }])
}),
)
@@ -0,0 +1,466 @@
import { describe, expect } from "bun:test"
import { Agent } from "@opencode/core/agent"
import { ToolInputRepairPlugin } from "@opencode/core/plugin/tool-input-repair"
import { Session } from "@opencode/core/session"
import { SessionMessage } from "@opencode/core/session/message"
import type { ToolHooks } from "@opencode/plugin/effect/tool"
import { Tool } from "@opencode/schema/tool"
import { Effect, type JsonSchema } from "effect"
import { it } from "../lib/effect"
import { host } from "./host"
function run(input: unknown, inputSchema: JsonSchema.JsonSchema) {
const event: ToolHooks["execute.before"] = {
tool: "test",
input,
sessionID: Session.ID.make("ses_repair"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_repair"),
id: Tool.CallID.make("call_repair"),
}
const events: ToolHooks = {
"execute.before": event,
"execute.after": { ...event, status: "error", error: new Tool.Error({ message: "unused" }) },
}
const base = host()
return ToolInputRepairPlugin.Plugin.effect(
host({
tool: {
...base.tool,
transform: (callback) =>
Effect.sync(() => {
const tool = {
id: "test",
name: "test",
description: "Test repair",
input: inputSchema,
execute: () => Effect.succeed({ content: "unused" }),
}
callback({
list: () => [tool],
get: (id) => (id === tool.id ? tool : undefined),
add: () => {},
namespace: () => {},
update: () => {},
remove: () => {},
})
return { dispose: Effect.void }
}),
hook: (name, callback) => callback(events[name]).pipe(Effect.orDie, Effect.as({ dispose: Effect.void })),
},
}),
).pipe(Effect.as(event))
}
const object = (properties: Record<string, unknown>, required?: string[]) => ({
type: "object" as const,
properties,
...(required ? { required } : {}),
})
describe("tool input repair plugin", () => {
it.effect("preserves valid input identity, nested containers, and unknown properties", () =>
Effect.gen(function* () {
const nested = { enabled: true }
const items = [2, 3]
const input = { count: 2, nested, items, extra: "keep" }
const event = yield* run(
input,
object({
count: { type: "integer" },
nested: object({ enabled: { type: "boolean" } }),
items: { type: "array", items: { type: "number" } },
}),
)
expect(event.input).toBe(input)
expect((event.input as typeof input).nested).toBe(nested)
expect((event.input as typeof input).items).toBe(items)
}),
)
it.effect("parses root objects and repairs nested stringified containers", () =>
Effect.gen(function* () {
const schema = object({
count: { type: "integer" },
item: object({ enabled: { type: "boolean" } }),
list: { type: "array", items: { type: "integer" } },
})
expect(
(yield* run('{"count":"2","item":"{\\"enabled\\":\\"false\\"}","list":"[\\"3\\"]"}', schema)).input,
).toEqual({
count: 2,
item: { enabled: false },
list: [3],
})
expect((yield* run("{broken", schema)).input).toBe("{broken")
expect((yield* run("[]", schema)).input).toBe("[]")
expect((yield* run(null, schema)).input).toBeNull()
}),
)
it.effect("removes extras only from explicitly closed objects without mutating inputs", () =>
Effect.gen(function* () {
const input = {
known: "2",
extra: true,
closed: { keep: "3", extra: true },
open: { keep: "4", extra: true },
items: [{ keep: "5", extra: true }],
}
const event = yield* run(input, {
...object({
known: { type: "integer" },
closed: { ...object({ keep: { type: "integer" } }), additionalProperties: false },
open: object({ keep: { type: "integer" } }),
items: {
type: "array",
items: { ...object({ keep: { type: "integer" } }), additionalProperties: false },
},
}),
additionalProperties: false,
})
expect(event.input).toEqual({
known: 2,
closed: { keep: 3 },
open: { keep: 4, extra: true },
items: [{ keep: 5 }],
})
expect(input.extra).toBeTrue()
expect(input.closed.extra).toBeTrue()
expect(input.items[0]?.extra).toBeTrue()
expect((yield* run({ extra: true }, { ...object({}), additionalProperties: false })).input).toEqual({})
}),
)
it.effect("preserves unknown keys when patterned ownership cannot be determined", () =>
Effect.gen(function* () {
const input = { known: 1, match: "2", extra: true }
const event = yield* run(input, {
...object({ known: { type: "integer" } }),
additionalProperties: false,
patternProperties: { "^match$": { type: "integer" } },
})
expect(event.input).toBe(input)
expect((event.input as typeof input).match).toBe("2")
expect((event.input as typeof input).extra).toBeTrue()
}),
)
it.effect("preserves properties that may belong to composed object schemas", () =>
Effect.gen(function* () {
const input = { name: "example", extra: true }
for (const keyword of ["allOf", "anyOf", "oneOf"]) {
const event = yield* run(input, {
type: "object",
[keyword]: [object({ name: { type: "string" } })],
additionalProperties: false,
})
expect(event.input).toBe(input)
}
}),
)
it.effect("removes only optional nonnullable nulls and non-object empty placeholders", () =>
Effect.gen(function* () {
const input = {
optional: null,
required: null,
nullable: null,
union: null,
constant: null,
permissive: null,
referenced: null,
placeholder: {},
array: {},
requiredPlaceholder: {},
object: {},
unknown: null,
}
const event = yield* run(
input,
object(
{
optional: { type: "string" },
required: { type: "string" },
nullable: { type: "string", nullable: true },
union: { anyOf: [{ type: "integer" }, { type: "null" }] },
constant: { anyOf: [{ type: "integer" }, { const: null }] },
permissive: { anyOf: [{ type: "integer" }, true] },
referenced: { anyOf: [{ type: "integer" }, { $ref: "#/$defs/nullable" }] },
placeholder: { type: "integer" },
array: { type: "array", items: { type: "string" } },
requiredPlaceholder: { type: "boolean" },
object: { type: "object" },
unknown: {},
},
["required", "requiredPlaceholder"],
),
)
expect(event.input).toEqual({
required: null,
nullable: null,
union: null,
constant: null,
permissive: null,
referenced: null,
requiredPlaceholder: {},
object: {},
unknown: null,
})
expect(input.optional).toBeNull()
expect(input.placeholder).toEqual({})
}),
)
it.effect("preserves nulls whose validity is hidden inside compositions", () =>
Effect.gen(function* () {
const input = { wrapped: null, enumerated: null, permissive: null, constant: null }
const event = yield* run(
input,
object({
wrapped: { anyOf: [{ type: ["string", "null"] }] },
enumerated: { anyOf: [{ enum: [null, "keep"] }] },
permissive: { anyOf: [{}] },
constant: { type: "string", const: "keep" },
}),
)
expect(event.input).toBe(input)
}),
)
it.effect("preserves optional-looking nulls when the parent composes requirements", () =>
Effect.gen(function* () {
const input = { value: null }
const event = yield* run(input, {
...object({ value: { type: "string" } }),
allOf: [{ required: ["value"] }],
})
expect(event.input).toBe(input)
}),
)
it.effect("coerces numeric and boolean strings while preserving invalid and existing values", () =>
Effect.gen(function* () {
const input = {
number: "1.5",
integer: "42",
enabled: "true",
disabled: "false",
valid: 3,
empty: " ",
infinite: "Infinity",
fractional: "1.5",
unsafe: "9007199254740992",
uppercase: "TRUE",
}
const event = yield* run(
input,
object({
number: { type: "number" },
integer: { type: "integer" },
enabled: { type: "boolean" },
disabled: { type: "boolean" },
valid: { type: "integer" },
empty: { type: "number" },
infinite: { type: "number" },
fractional: { type: "integer" },
unsafe: { type: "integer" },
uppercase: { type: "boolean" },
}),
)
expect(event.input).toEqual({ ...input, number: 1.5, integer: 42, enabled: true, disabled: false })
}),
)
it.effect("wraps compatible scalars after repairing array items", () =>
Effect.gen(function* () {
const event = yield* run(
{
text: "one",
integer: "42",
boolean: "false",
item: '{"count":"2"}',
incompatible: 2,
fractional: 1.5,
unconstrained: "4",
},
object({
text: { type: "array", items: { type: "string" } },
integer: { type: "array", items: { type: "integer" } },
boolean: { type: "array", items: { type: "boolean" } },
item: { type: "array", items: object({ count: { type: "integer" } }) },
incompatible: { type: "array", items: { type: "string" } },
fractional: { type: "array", items: { type: "integer" } },
unconstrained: { type: "array" },
}),
)
expect(event.input).toEqual({
text: ["one"],
integer: [42],
boolean: [false],
item: [{ count: 2 }],
incompatible: 2,
fractional: 1.5,
unconstrained: "4",
})
}),
)
it.effect("repairs nested question-like inputs without mutating original containers", () =>
Effect.gen(function* () {
const question = { question: "Pick one", multiple: "false", options: { label: "First", description: null } }
const input = { questions: [question] }
const event = yield* run(
input,
object({
questions: {
type: "array",
items: object({
question: { type: "string" },
multiple: { type: "boolean" },
options: {
type: "array",
items: object({ label: { type: "string" }, description: { type: "string" } }, ["label"]),
},
}),
},
}),
)
expect(event.input).toEqual({
questions: [{ question: "Pick one", multiple: false, options: [{ label: "First" }] }],
})
expect(input).toEqual({ questions: [question] })
expect(question.options.description).toBeNull()
}),
)
it.effect("repairs unique nullable alternatives while preserving accepted union values", () =>
Effect.gen(function* () {
const input = {
number: "2",
boolean: "false",
nullable: null,
typed: "3",
typedBoolean: "true",
accepted: "4",
valid: 5,
}
const event = yield* run(
input,
object({
number: { anyOf: [{ type: "number" }, { type: "null" }] },
boolean: { oneOf: [{ type: "boolean" }, { type: "null" }] },
nullable: { anyOf: [{ type: "number" }, { type: "null" }] },
typed: { type: ["integer", "null"] },
typedBoolean: { type: ["boolean", "null"] },
accepted: { anyOf: [{ type: "string" }, { type: "number" }] },
valid: { type: ["number", "null"] },
}),
)
expect(event.input).toEqual({ ...input, number: 2, boolean: false, typed: 3, typedBoolean: true })
}),
)
it.effect("repairs tuple positions and rest items while preserving valid array identity", () =>
Effect.gen(function* () {
const valid = [2, false]
const input = { prefix: ["2", "false", "3"], draft: '["4","true"]', valid, scalar: "5" }
const event = yield* run(
input,
object({
prefix: {
type: "array",
prefixItems: [{ type: "integer" }, { type: "boolean" }],
items: { type: "number" },
},
draft: { type: "array", items: [{ type: "integer" }, { type: "boolean" }] },
valid: { type: "array", prefixItems: [{ type: "integer" }, { type: "boolean" }] },
scalar: { type: "array", prefixItems: [{ type: "integer" }] },
}),
)
expect(event.input).toEqual({ prefix: [2, false, 3], draft: [4, true], valid, scalar: "5" })
expect((event.input as typeof input).valid).toBe(valid)
expect(input.prefix).toEqual(["2", "false", "3"])
}),
)
it.effect("repairs typed dictionaries and straightforward local references", () =>
Effect.gen(function* () {
const input = {
modern: "2",
legacy: "false",
nested: { count: "3" },
dictionary: { first: "4" },
missing: "5",
pointer: "6",
escaped: "7",
}
const event = yield* run(input, {
...object({
modern: { $ref: "#/$defs/integer" },
legacy: { $ref: "#/definitions/boolean" },
nested: { $ref: "#/$defs/nested" },
dictionary: { type: "object", additionalProperties: { $ref: "#/$defs/integer" } },
missing: { $ref: "#/$defs/missing" },
pointer: { $ref: "#/$defs/nested/properties/count" },
escaped: { $ref: "#/$defs/a~1b~0c" },
}),
$defs: {
integer: { type: "integer" },
"a/b~c": { type: "integer" },
nested: object({ count: { $ref: "#/$defs/integer" } }),
},
definitions: { boolean: { type: "boolean" } },
})
expect(event.input).toEqual({
modern: 2,
legacy: false,
nested: { count: 3 },
dictionary: { first: 4 },
missing: "5",
pointer: "6",
escaped: 7,
})
expect(input.nested.count).toBe("3")
expect(input.dictionary.first).toBe("4")
}),
)
it.effect("leaves ambiguous unions, compositions, and unsupported roots unchanged", () =>
Effect.gen(function* () {
const input = { numeric: "2", objects: { value: "3" }, both: "4", composed: "5", unknown: "6" }
const event = yield* run(
input,
object({
numeric: { anyOf: [{ type: "number" }, { type: "integer" }] },
objects: {
oneOf: [
object({ value: { type: "integer" } }, ["value"]),
object({ value: { type: "number" } }, ["value"]),
],
},
both: { anyOf: [{ type: "integer" }], oneOf: [{ type: "integer" }] },
composed: { allOf: [{ type: "integer" }] },
unknown: {},
}),
)
expect(event.input).toBe(input)
expect((yield* run(input, { properties: { numeric: { type: "integer" } } })).input).toBe(input)
expect((yield* run(input, { allOf: [object({ numeric: { type: "integer" } })] })).input).toBe(input)
}),
)
})
+10 -2
View File
@@ -28,8 +28,16 @@ describe("Provider", () => {
}
})
test("passes flat settings to native packages without opencode transport keys", () => {
expect(Provider.nativeSettings({ apiKey: "secret", reasoningEffort: "high", chunkTimeout: 1000 })).toEqual({
test("passes flat settings to native packages without Core settings", () => {
expect(
Provider.nativeSettings({
apiKey: "secret",
reasoningEffort: "high",
chunkTimeout: 1000,
compaction: { type: "native" },
transport: "websocket",
}),
).toEqual({
apiKey: "secret",
reasoningEffort: "high",
})
+3 -10
View File
@@ -207,20 +207,13 @@ it.effect("auto compaction estimates current content against the buffered prompt
const inputLimited = { context: 400_000, input: 272_000, output: 128_000 }
expect(compaction.required(input(251_999, inputLimited))).toBe(false)
expect(compaction.required(input(252_000, inputLimited))).toBe(true)
const native = (
tokens: number,
limit: { context: number; input?: number; output: number } = inputLimited,
threshold?: number,
) => {
const native = (tokens: number, limit: { context: number; input?: number; output: number } = inputLimited) => {
const selected = input(tokens, limit)
return { ...selected, resolved: { ...selected.resolved, compaction: { mode: "provider" as const, threshold } } }
return { ...selected, resolved: { ...selected.resolved, compaction: { type: "native" as const } } }
}
expect(compaction.required(native(251_999))).toBe(false)
expect(compaction.required(native(252_000))).toBe(true)
expect(compaction.required(native(99_999, inputLimited, 100_000))).toBe(false)
expect(compaction.required(native(100_000, inputLimited, 100_000))).toBe(true)
expect(compaction.required(native(252_000, inputLimited, 500_000))).toBe(true)
expect(compaction.required(native(1_000_000, { context: 0, input: undefined, output: 0 }, 100_000))).toBe(false)
expect(compaction.required(native(1_000_000, { context: 0, input: undefined, output: 0 }))).toBe(false)
const contextLimited = { context: 100_000, output: 10_000 }
expect(compaction.required(input(79_999, contextLimited))).toBe(false)
@@ -164,7 +164,7 @@ const setup = Effect.fnUntraced(function* (options: { endpoint?: boolean; plugin
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
limit: { context: 200_000, output: 32_000 },
compaction: { mode: "provider" },
compaction: { type: "native" },
},
)
const sessionID = SessionSchema.ID.create()
+4 -2
View File
@@ -2863,7 +2863,8 @@ describe("SessionRunnerLLM", () => {
scenario("automatically persists native windows, retains earlier users, and waits for fresh usage", function* (s) {
s.currentModel = LanguageModel.make({ id: "native", provider: "openai", route: OpenAIResponses.route })
s.compaction = { mode: "provider", threshold: 10_000 }
modelLimits.set("native", { context: 42_000, output: 32_000 })
s.compaction = { type: "native" }
const agents = yield* Agent.Service
yield* agents.transform((editor) =>
editor.update(Agent.defaultID, (agent) => {
@@ -2914,7 +2915,8 @@ describe("SessionRunnerLLM", () => {
scenario("recovers an overflowing native window locally from original durable history", function* (s) {
s.currentModel = LanguageModel.make({ id: "native", provider: "openai", route: OpenAIResponses.route })
s.compaction = { mode: "provider", threshold: 10_000 }
modelLimits.set("native", { context: 42_000, output: 32_000 })
s.compaction = { type: "native" }
yield* s.llm.push(TestLLM.textWithUsage("Earlier answer", "before-native", 10_000))
yield* s.runPrompt("Original durable request")
yield* s.llm.push(
+3
View File
@@ -106,6 +106,9 @@ test("Core reuses the canonical shared schemas", async () => {
[coreModel.Info, Model.Info],
[coreProvider.ID, Provider.ID],
[coreProvider.Request, Provider.Request],
[coreProvider.Compaction, Provider.Compaction],
[coreProvider.Transport, Provider.Transport],
[coreProvider.Settings, Provider.Settings],
[coreProvider.Info, Provider.Info],
[corePermission.Effect, Permission.Effect],
[corePermission.Rule, Permission.Rule],
+76 -38
View File
@@ -13161,7 +13161,7 @@
"properties": {
"action": {
"type": "string",
"enum": ["provider.use"]
"enum": ["provider.use", "permission"]
},
"resource": {
"type": "string"
@@ -13254,12 +13254,6 @@
"Config.ModelEncoded": {
"type": "object",
"properties": {
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"transport": {
"$ref": "#/components/schemas/Provider.Transport"
},
"modelID": {
"type": "string"
},
@@ -13276,7 +13270,7 @@
"type": "string"
},
"settings": {
"type": "object"
"$ref": "#/components/schemas/Config.Provider.Settings"
},
"headers": {
"type": "object",
@@ -13299,7 +13293,7 @@
"type": "string"
},
"settings": {
"type": "object"
"$ref": "#/components/schemas/Config.Provider.Settings"
},
"headers": {
"type": "object",
@@ -13379,15 +13373,47 @@
"required": ["package"],
"additionalProperties": false
},
"Config.ProviderEncoded": {
"Config.Provider.Settings": {
"type": "object",
"properties": {
"timeout": {
"anyOf": [
{
"type": "number"
},
{
"type": "boolean",
"enum": [false]
}
]
},
"chunkTimeout": {
"type": "number"
},
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"transport": {
"$ref": "#/components/schemas/Provider.Transport"
},
}
},
"allOf": [
{
"type": "object",
"additionalProperties": {
"anyOf": [
{},
{
"type": "null"
}
]
}
}
]
},
"Config.ProviderEncoded": {
"type": "object",
"properties": {
"canonical": {
"type": "string"
},
@@ -13404,7 +13430,7 @@
"type": "string"
},
"settings": {
"type": "object"
"$ref": "#/components/schemas/Config.Provider.Settings"
},
"headers": {
"type": "object",
@@ -15481,14 +15507,8 @@
"package": {
"type": "string"
},
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"transport": {
"$ref": "#/components/schemas/Provider.Transport"
},
"settings": {
"type": "object"
"$ref": "#/components/schemas/Provider.Settings"
},
"headers": {
"type": "object",
@@ -15601,7 +15621,7 @@
"type": "string"
},
"settings": {
"type": "object"
"$ref": "#/components/schemas/Provider.Settings"
},
"headers": {
"type": "object",
@@ -16418,27 +16438,23 @@
{
"type": "object",
"properties": {
"mode": {
"type": {
"type": "string",
"enum": ["local"]
"enum": ["summary"]
}
},
"required": ["mode"],
"required": ["type"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"mode": {
"type": {
"type": "string",
"enum": ["provider"]
},
"threshold": {
"type": "integer",
"exclusiveMinimum": 0
"enum": ["native"]
}
},
"required": ["mode"],
"required": ["type"],
"additionalProperties": false
}
]
@@ -16465,14 +16481,8 @@
"package": {
"type": "string"
},
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"transport": {
"$ref": "#/components/schemas/Provider.Transport"
},
"settings": {
"type": "object"
"$ref": "#/components/schemas/Provider.Settings"
},
"headers": {
"type": "object",
@@ -16507,7 +16517,35 @@
"additionalProperties": false
},
"Provider.Settings": {
"type": "object"
"type": "object",
"properties": {
"timeout": {
"anyOf": [
{
"type": "number"
},
{
"type": "boolean",
"enum": [false]
}
]
},
"chunkTimeout": {
"type": "number"
},
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"transport": {
"$ref": "#/components/schemas/Provider.Transport"
}
},
"allOf": [
{
"type": "object",
"additionalProperties": {}
}
]
},
"Provider.Transport": {
"type": "string",
+12 -10
View File
@@ -6,10 +6,21 @@ import { Capabilities, Compatibility, Family, ID, VariantID } from "../model.js"
import { Provider } from "../provider.js"
import { optional } from "../schema.js"
export const Settings = Schema.StructWithRest(
Schema.Struct({
timeout: Schema.Union([Schema.Finite, Schema.Literal(false)]).pipe(optional),
chunkTimeout: Schema.Finite.pipe(optional),
compaction: Provider.Compaction.pipe(optional),
transport: Provider.Transport.pipe(optional),
}),
[Schema.Record(Schema.String, Schema.UndefinedOr(Schema.Json))],
).annotate({ identifier: "Config.Provider.Settings" })
export type Settings = typeof Settings.Type
const JsonRecord = Schema.Record(Schema.String, Schema.Json)
export const Overlays = {
settings: JsonRecord.pipe(optional),
settings: Settings.pipe(optional),
headers: Schema.Record(Schema.String, Schema.String).pipe(optional),
body: JsonRecord.pipe(optional),
}
@@ -41,10 +52,6 @@ class Limit extends Schema.Class<Limit>("Config.Model.Limit")({
}) {}
class Model extends Schema.Class<Model>("Config.Model")({
compaction: Provider.Compaction.pipe(optional),
transport: Provider.Transport.pipe(optional).annotate({
description: "Session transport for this model. Defaults to the provider transport.",
}),
modelID: ID.pipe(optional),
family: Family.pipe(optional),
name: Schema.String.pipe(optional),
@@ -62,11 +69,6 @@ class Model extends Schema.Class<Model>("Config.Model")({
}) {}
export class Info extends Schema.Class<Info>("Config.Provider")({
compaction: Provider.Compaction.pipe(optional),
transport: Provider.Transport.pipe(optional).annotate({
description:
"Session transport for this provider's models. Defaults to the built-in policy; \"websocket\" on a route without a WebSocket channel warns and falls back to HTTP.",
}),
canonical: Provider.ID.pipe(optional),
name: Schema.String.pipe(optional),
env: Schema.String.pipe(Schema.Array, optional),
-3
View File
@@ -110,9 +110,6 @@ export const Info = Schema.Struct({
name: Schema.String,
compatibility: Compatibility.pipe(optional),
package: Provider.Package.pipe(optional),
compaction: Provider.Compaction.pipe(optional),
/** Session transport; omitted inherits the provider transport, then defaults to HTTP. */
transport: Provider.Transport.pipe(optional),
...Provider.Overlays,
capabilities: Capabilities,
variants: Schema.Array(Variant),
+19 -12
View File
@@ -2,7 +2,7 @@ export * as Provider from "./provider.js"
import { Effect, Schema } from "effect"
import { Integration } from "./integration.js"
import { optional, PositiveInt, statics } from "./schema.js"
import { optional, statics } from "./schema.js"
import { ephemeral, inventory } from "./event.js"
export const ID = Schema.String.pipe(
@@ -32,25 +32,35 @@ export type Package = typeof Package.Type
export const Activation = Schema.Literals(["auto", "enabled", "disabled"])
export type Activation = typeof Activation.Type
export type Compaction = typeof Compaction.Type
export const Compaction = Schema.Union([
Schema.Struct({ mode: Schema.Literal("local") }),
Schema.Struct({ mode: Schema.Literal("provider"), threshold: PositiveInt.pipe(optional) }),
]).annotate({ identifier: "Provider.Compaction" })
Schema.Struct({ type: Schema.Literal("summary") }),
Schema.Struct({ type: Schema.Literal("native") }),
])
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Provider.Compaction" })
export type Compaction = typeof Compaction.Type
/** "websocket" on a route without a WebSocket channel warns and falls back to HTTP. */
export const Transport = Schema.Literals(["http", "websocket"]).annotate({ identifier: "Provider.Transport" })
export type Transport = typeof Transport.Type
export const Settings = Schema.StructWithRest(
Schema.Struct({
timeout: Schema.Union([Schema.Finite, Schema.Literal(false)]).pipe(optional),
chunkTimeout: Schema.Finite.pipe(optional),
compaction: Compaction.pipe(optional),
transport: Transport.pipe(optional),
}),
[Schema.Record(Schema.String, Schema.Any)],
).annotate({ identifier: "Provider.Settings" })
export type Settings = typeof Settings.Type
export const Overlays = {
settings: Schema.Record(Schema.String, Schema.Any).pipe(optional),
settings: Settings.pipe(optional),
headers: Schema.Record(Schema.String, Schema.String).pipe(optional),
body: Schema.Record(Schema.String, Schema.Any).pipe(optional),
}
export const Settings = Schema.Record(Schema.String, Schema.Any).annotate({ identifier: "Provider.Settings" })
export type Settings = typeof Settings.Type
export interface Request extends Schema.Schema.Type<typeof Request> {}
export const Request = Schema.Struct({
settings: Settings.pipe(Schema.withConstructorDefault(Effect.succeed({}))),
@@ -66,9 +76,6 @@ export const Info = Schema.Struct({
name: Schema.String,
activation: Activation,
package: Package,
compaction: Compaction.pipe(optional),
/** Session transport for this provider's models; omitted means HTTP. */
transport: Transport.pipe(optional),
...Overlays,
})
.annotate({ identifier: "Provider.Info" })
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
import { DateTime, Schema } from "effect"
import { Agent } from "../src/agent.js"
import { ConfigAgent } from "../src/config/agent.js"
import { ConfigProvider } from "../src/config/provider.js"
import { FileSystem } from "../src/filesystem.js"
import { Form } from "../src/form.js"
import { Mcp } from "../src/mcp.js"
@@ -168,6 +169,7 @@ describe("contract hygiene", () => {
test("reusable public identifiers are stable and unique", () => {
const identifiers = [
Agent.Color,
ConfigProvider.Settings,
FileSystem.Submatch,
Form.Field,
Form.Fields,
@@ -226,7 +228,7 @@ describe("contract hygiene", () => {
}
})
test("current source limits Any to provider options and avoids mutable contract wrappers", async () => {
test("current source limits Any to reviewed boundaries and avoids mutable contract wrappers", async () => {
const files = [...new Bun.Glob("*.ts").scanSync(new URL("../src", import.meta.url).pathname)].filter(
(file) => !file.endsWith("-v1.ts"),
)
@@ -237,11 +239,12 @@ describe("contract hygiene", () => {
expect(
sources
.filter((item) => item.file !== "provider.ts")
.filter((item) => item.file !== "provider.ts" && item.file !== "integration.ts")
.map((item) => item.source)
.join("\n"),
).not.toContain("Schema.Any")
expect(sources.find((item) => item.file === "provider.ts")?.source.match(/Schema\.Any/g)).toHaveLength(4)
expect(sources.find((item) => item.file === "provider.ts")?.source.match(/Schema\.Any/g)).toHaveLength(3)
expect(sources.find((item) => item.file === "integration.ts")?.source.match(/Schema\.Any/g)).toHaveLength(2)
expect(source).not.toContain("Schema.mutable")
})
+14 -19
View File
@@ -56,23 +56,16 @@ describe("Model.Compatibility", () => {
})
describe("Model.Info", () => {
test("provider compaction policy is optional and uses the canonical closed schema", () => {
test("provider compaction policy is a typed setting", () => {
const model = Model.Info.default(Provider.ID.openai, Model.ID.make("gpt-5.4-mini"))
expect(Schema.encodeSync(Model.Info)({ ...model, compaction: undefined })).not.toHaveProperty("compaction")
expect(Schema.decodeUnknownSync(Model.Info)({ ...model, compaction: { mode: "provider" } }).compaction).toEqual({
mode: "provider",
expect(Schema.encodeSync(Model.Info)({ ...model, settings: { compaction: undefined } }).settings).toEqual({})
expect(
Schema.decodeUnknownSync(Model.Info)({ ...model, settings: { compaction: { type: "native" } } }).settings,
).toEqual({
compaction: { type: "native" },
})
expect(Schema.decodeUnknownSync(Provider.Compaction)({ mode: "local" })).toEqual({ mode: "local" })
expect(Schema.encodeSync(Provider.Compaction)({ mode: "provider", threshold: undefined })).toEqual({
mode: "provider",
})
expect(Schema.decodeUnknownSync(Provider.Compaction)({ mode: "provider", threshold: 120_000 })).toEqual({
mode: "provider",
threshold: 120_000,
})
for (const threshold of [0, -1, 1.5])
expect(() => Schema.decodeUnknownSync(Provider.Compaction)({ mode: "provider", threshold })).toThrow()
expect(() => Schema.decodeUnknownSync(Provider.Compaction)({ mode: "automatic" })).toThrow()
expect(Schema.decodeUnknownSync(Provider.Compaction)({ type: "summary" })).toEqual({ type: "summary" })
expect(() => Schema.decodeUnknownSync(Provider.Compaction)({ type: "automatic" })).toThrow()
})
test("uses practical token limits for unknown models", () => {
@@ -83,10 +76,12 @@ describe("Model.Info", () => {
})
describe("Model.Capabilities", () => {
test("decodes the optional transport preference", () => {
test("decodes the optional transport setting", () => {
const model = Model.Info.default(Provider.ID.openai, Model.ID.make("gpt-5.4-mini"))
expect(Schema.encodeSync(Model.Info)({ ...model, transport: undefined })).not.toHaveProperty("transport")
expect(Schema.decodeUnknownSync(Model.Info)({ ...model, transport: "websocket" }).transport).toBe("websocket")
expect(() => Schema.decodeUnknownSync(Model.Info)({ ...model, transport: "sse" })).toThrow()
expect(Schema.encodeSync(Model.Info)({ ...model, settings: { transport: undefined } }).settings).toEqual({})
expect(Schema.decodeUnknownSync(Model.Info)({ ...model, settings: { transport: "websocket" } }).settings).toEqual({
transport: "websocket",
})
expect(() => Schema.decodeUnknownSync(Model.Info)({ ...model, settings: { transport: "sse" } })).toThrow()
})
})
+2 -16
View File
@@ -218,20 +218,13 @@ export const DEFAULT_THEME = {
imageText: "$hue.cyan.600",
codeBlock: "$hue.neutral.900",
},
"@context:elevated": {
"@dialog": {
text: { action: { primary: { default: "$hue.neutral.100" } } },
background: {
default: "$background.raised.base",
action: { primary: { default: "$hue.interactive.500", $hovered: "$background.raised.high" } },
},
},
"@context:overlay": {
text: { action: { primary: { default: "$hue.neutral.100" } } },
background: {
default: "$background.raised.high",
action: { primary: { default: "$hue.interactive.500" } },
},
},
},
dark: {
hue: {
@@ -440,19 +433,12 @@ export const DEFAULT_THEME = {
imageText: "$hue.cyan.400",
codeBlock: "$hue.neutral.100",
},
"@context:elevated": {
"@dialog": {
text: { action: { primary: { default: "$hue.neutral.200" } } },
background: {
default: "$background.raised.base",
action: { primary: { default: "$hue.interactive.400", $hovered: "$background.raised.high" } },
},
},
"@context:overlay": {
text: { action: { primary: { default: "$hue.neutral.200" } } },
background: {
default: "$background.raised.high",
action: { primary: { default: "$hue.interactive.400" } },
},
},
},
} satisfies ThemeDocument
+1 -5
View File
@@ -11,11 +11,7 @@ export function expandTheme<Definition extends ModeDefinition>(definition: Defin
return {
...definition,
...expandTokens(definition),
...Object.fromEntries(
Object.entries(definition)
.filter(([key]) => key.startsWith("@context:"))
.map(([key, value]) => [key, expandTokens(value as ThemeTokensDefinition)]),
),
...(definition["@dialog"] ? { "@dialog": expandTokens(definition["@dialog"]) } : {}),
}
}
+2 -2
View File
@@ -13,6 +13,7 @@ export {
MarkdownDefinition,
MarkdownToken,
ModeDefinition,
SurfaceName,
SyntaxDefinition,
SyntaxToken,
ThemeDefinition,
@@ -26,14 +27,13 @@ export {
type MergeModeDefinition,
type Mode,
type StatefulColorDefinition,
type ContextKey,
type TextDefinition,
type ThemeTokensDefinition,
} from "./schema.js"
export type {
ActionStates,
Categorical,
ContextName,
FormfieldColor,
Hue,
HueSource,
+60 -33
View File
@@ -7,20 +7,22 @@ import {
ActionState,
ActionVariant,
BaseHue,
FeedbackKind,
HueAlias,
HueStep,
SurfaceName,
ThemeDefinition,
ThemeDocument,
} from "./schema.js"
import type {
ActionStateKey,
ContextName,
ActionStates,
HueDefinition,
HueScale,
Mode,
ResolvedActionState,
ResolvedTheme,
ResolvedThemeTokens,
StatefulColor,
StatefulColorDefinition,
ThemeTokensDefinition,
} from "./index.js"
@@ -42,40 +44,40 @@ export function themeDecodeError(error: unknown, name: string) {
return new Error(`Invalid theme: ${name} ${value} is an invalid value`, { cause: error })
}
export function resolveThemeDocument(document: ThemeDocument, mode?: "light" | "dark") {
export function resolveThemeDocument(document: ThemeDocument, mode?: Mode) {
const selected = selectThemeMode(document, mode)
const definition = selected.expanded ? selected.theme : expandTheme(selected.theme)
const defaults = expandTheme(selectTheme(DEFAULT_THEME, selected.mode))
const core = expandTokens(fallback(selected.mode))
const merged = document.standalone ? mergeTheme(core, definition) : mergeTheme(core, defaults, definition)
if (!merged["hue"]) throw new Error("Standalone themes must provide hues")
return resolveExpandedTheme({
...merged,
categorical: merged["categorical"] ?? DEFAULT_CATEGORICAL,
} as ThemeDefinition)
return resolveExpandedTheme(
{
...merged,
categorical: merged["categorical"] ?? DEFAULT_CATEGORICAL,
} as ThemeDefinition,
selected.mode,
)
}
export function resolveTheme(definition: ThemeDefinition): ResolvedTheme {
return resolveExpandedTheme(expandTheme(decodeThemeDefinition(definition)))
export function resolveTheme(definition: ThemeDefinition, mode: Mode): ResolvedTheme {
return resolveExpandedTheme(expandTheme(decodeThemeDefinition(definition)), mode)
}
function resolveExpandedTheme(definition: ThemeDefinition): ResolvedTheme {
function resolveExpandedTheme(definition: ThemeDefinition, mode: Mode): ResolvedTheme {
const hue = resolveHue(definition.hue)
const categorical = (definition.categorical ?? DEFAULT_CATEGORICAL).map((name) => hue[name])
const hueSteps = compileHueSteps(hue)
const raise = (color: RGBA) => (mode === "light" ? hueSteps.increase(color) : hueSteps.decrease(color))
const base = tokens(definition)
const resolved = resolveView(base, hue, categorical, hueSteps)
const context = (name: ContextName) => {
const override = definition[`@context:${name}`]
if (!override) return resolved
return resolveView(contextualize(base, override), hue, categorical, hueSteps)
}
const contextual = {
elevated: context("elevated"),
overlay: context("overlay"),
}
return { ...resolved, contextual } as ResolvedTheme
const views = {} as Record<SurfaceName, ResolvedTheme>
const view = (tokens: ThemeTokensDefinition): ResolvedTheme => ({
...resolveView(tokens, hue, categorical, hueSteps),
raise,
surface: (name) => views[name],
})
views.dialog = definition["@dialog"] ? view(contextualize(base, definition["@dialog"])) : view(base)
return view(base)
}
function tokens(definition: ThemeDefinition): ThemeTokensDefinition {
@@ -92,27 +94,23 @@ function tokens(definition: ThemeDefinition): ThemeTokensDefinition {
function contextualize(base: ThemeTokensDefinition, override: ThemeTokensDefinition) {
const result = mergeTheme(base, override)
const baseText = base.text?.action
const contextText = override.text?.action
const baseBackground = base.background?.action
const contextBackground = override.background?.action
const text = result["text"] as NonNullable<ThemeTokensDefinition["text"]>
const background = result["background"] as NonNullable<ThemeTokensDefinition["background"]>
return {
...result,
text: { ...text, action: contextualActions(baseText, contextText) },
background: { ...background, action: contextualActions(baseBackground, contextBackground) },
text: { ...text, action: contextualActions(base.text?.action, override.text?.action) },
background: { ...background, action: contextualActions(base.background?.action, override.background?.action) },
} as ThemeTokensDefinition
}
function contextualActions(
base: Partial<Record<ActionVariant, StatefulColorDefinition>> | undefined,
context: Partial<Record<ActionVariant, StatefulColorDefinition>> | undefined,
surface: Partial<Record<ActionVariant, StatefulColorDefinition>> | undefined,
) {
return Object.fromEntries(
ActionVariant.literals.map((variant) => {
const baseVariant = base?.[variant]
const contextVariant = context?.[variant]
const surfaceVariant = surface?.[variant]
return [
variant,
Object.fromEntries(
@@ -120,8 +118,8 @@ function contextualActions(
const key = state === "default" ? undefined : (`$${state}` as ActionStateKey)
return [
key ?? "default",
(key ? contextVariant?.[key] : undefined) ??
contextVariant?.default ??
(key ? surfaceVariant?.[key] : undefined) ??
surfaceVariant?.default ??
(key ? baseVariant?.[key] : undefined) ??
baseVariant?.default,
]
@@ -139,7 +137,36 @@ function resolveView(
hueSteps: Pick<ResolvedThemeTokens, "source" | "increase" | "decrease">,
): ResolvedThemeTokens {
const source: Record<string, unknown> = { hue, ...definition }
return { ...(createResolver(source)(source, "theme") as ResolvedThemeTokens), hue, categorical, ...hueSteps }
const resolved = createResolver(source)(source, "theme") as ResolvedThemeTokens
return {
...resolved,
hue,
categorical,
text: {
...resolved.text,
action: statefulActions(resolved.text.action),
formfield: statefulColor(resolved.text.formfield),
},
background: {
...resolved.background,
action: statefulActions(resolved.background.action),
formfield: statefulColor(resolved.background.formfield),
},
...hueSteps,
}
}
function statefulActions(actions: Readonly<Record<ActionVariant, StatefulColor>>) {
return Object.fromEntries(ActionVariant.literals.map((variant) => [variant, statefulColor(actions[variant])])) as Readonly<
Record<ActionVariant, StatefulColor>
>
}
function statefulColor(color: StatefulColor): StatefulColor {
return {
...color,
state: (states: ActionStates) => color[ActionState.literals.find((state) => states[state]) ?? "default"],
}
}
function compileHueSteps(
+6 -9
View File
@@ -16,6 +16,9 @@ export const ActionState = Schema.Literals(["disabled", "pressed", "focused", "s
export type ActionState = Schema.Schema.Type<typeof ActionState>
export type ActionStateKey = `$${ActionState}`
export const SurfaceName = Schema.Literal("dialog")
export type SurfaceName = Schema.Schema.Type<typeof SurfaceName>
export const FormfieldState = ActionState
export type FormfieldState = ActionState
export type FormfieldStateKey = `$${FormfieldState}`
@@ -40,9 +43,6 @@ export const CategoricalDefinition = Schema.Array(HueName).check(Schema.isMinLen
export type CategoricalDefinition = Schema.Schema.Type<typeof CategoricalDefinition>
const HueColorValue = Schema.Union([HexColor, Schema.TemplateLiteral(["$hue.", HueName, ".", HueStep])])
const ContextKey = Schema.Literals(["@context:elevated", "@context:overlay"])
export type ContextKey = Schema.Schema.Type<typeof ContextKey>
const HueScaleDefinition = Schema.Record(HueStep, HexColor)
const HueValueDefinition = Schema.Union([Schema.TemplateLiteral(["$hue.", HueName]), HueScaleDefinition])
@@ -229,8 +229,7 @@ const ThemeDefinitionFields = Schema.Struct({
hue: HueDefinition,
categorical: Schema.optional(CategoricalDefinition),
...ThemeTokensDefinition.fields,
"@context:elevated": Schema.optional(ThemeTokensDefinition),
"@context:overlay": Schema.optional(ThemeTokensDefinition),
"@dialog": Schema.optional(ThemeTokensDefinition),
})
export const ThemeDefinition = ThemeDefinitionFields
export type ThemeDefinition = Schema.Schema.Type<typeof ThemeDefinition>
@@ -239,8 +238,7 @@ const FileThemeDefinition = Schema.Struct({
hue: Schema.optional(HueOverrideDefinition),
categorical: Schema.optional(CategoricalDefinition),
...ThemeTokensDefinition.fields,
"@context:elevated": Schema.optional(ThemeTokensDefinition),
"@context:overlay": Schema.optional(ThemeTokensDefinition),
"@dialog": Schema.optional(ThemeTokensDefinition),
})
export type FileThemeDefinition = Schema.Schema.Type<typeof FileThemeDefinition>
@@ -249,8 +247,7 @@ const MergeModeDefinition = Schema.Struct({
hue: Schema.optional(HueOverrideDefinition),
categorical: Schema.optional(CategoricalDefinition),
...ThemeTokensDefinition.fields,
"@context:elevated": Schema.optional(ThemeTokensDefinition),
"@context:overlay": Schema.optional(ThemeTokensDefinition),
"@dialog": Schema.optional(ThemeTokensDefinition),
})
export type MergeModeDefinition = Schema.Schema.Type<typeof MergeModeDefinition>
export const ModeDefinition = Schema.Union([MergeModeDefinition, FileThemeDefinition])
+9 -4
View File
@@ -7,6 +7,7 @@ import type {
HueAlias,
HueStep,
MarkdownToken,
SurfaceName,
SyntaxToken,
} from "./schema.js"
@@ -16,7 +17,10 @@ export type HueScale = Readonly<Record<HueStep, RGBA>>
export type Hue = Readonly<Record<BaseHue | HueAlias, HueScale>>
export type HueSource = Readonly<{ hue: BaseHue | HueAlias; step: HueStep }>
export type Categorical = readonly HueScale[]
export type StatefulColor = Readonly<Record<ResolvedActionState, RGBA>>
export type ActionStates = Readonly<Partial<Record<ActionState, boolean>>>
export type StatefulColor = Readonly<Record<ResolvedActionState, RGBA>> & {
readonly state: (states: ActionStates) => RGBA
}
export type FormfieldColor = StatefulColor
export type ResolvedThemeTokens = {
@@ -69,8 +73,9 @@ export type ResolvedThemeTokens = {
readonly markdown: Readonly<Record<MarkdownToken, RGBA>>
}
export type ContextName = "elevated" | "overlay"
export type ResolvedTheme = ResolvedThemeTokens & {
readonly contextual: Readonly<Record<ContextName, ResolvedThemeTokens>>
/** Lift a ramp color one step away from the background: lighter in light mode, darker in dark mode. */
readonly raise: (color: RGBA) => RGBA
/** The same theme re-resolved on a raised surface. Absolute: every view's surfaces are the base theme's. */
readonly surface: (name: SurfaceName) => ResolvedTheme
}
+1 -2
View File
@@ -173,13 +173,12 @@ function migrateMode(theme: Theme, mode: Mode): FileThemeDefinition {
imageText: color("markdownImageText"),
codeBlock: color("markdownCodeBlock"),
},
"@context:elevated": {
"@dialog": {
background: {
default: "$background.raised.base",
action: { primary: { $hovered: "$background.raised.high" } },
},
},
"@context:overlay": { background: { default: "$background.raised.high" } },
})
}
+2 -2
View File
@@ -14,7 +14,7 @@ test.each(["light", "dark"] as const)("built-in %s themes resolve status colors"
expect(theme.text.status.question.equals(theme.text.status.unread)).toBeTrue()
expect(theme.text.status.permission.equals(theme.text.status.unread)).toBeTrue()
expect(theme.text.status.unread.equals(theme.hue.accent[mode === "light" ? 800 : 200])).toBeTrue()
expect(theme.contextual.elevated.text.status).toEqual(theme.text.status)
expect(theme.surface("dialog").text.status).toEqual(theme.text.status)
}
})
@@ -34,7 +34,7 @@ test.each(["light", "dark"] as const)("custom %s themes inherit the unread atten
expect(theme.text.status.unread.equals(RGBA.fromHex("#abcdef"))).toBeTrue()
expect(theme.text.status.question.equals(theme.text.status.unread)).toBeTrue()
expect(theme.text.status.permission.equals(theme.text.status.unread)).toBeTrue()
expect(theme.contextual.elevated.text.status).toEqual(theme.text.status)
expect(theme.surface("dialog").text.status).toEqual(theme.text.status)
}
})
+8 -8
View File
@@ -39,7 +39,7 @@ export function DevToolsBar() {
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const { current: theme, mode, supports, setMode } = themes
const elevatedTheme = useTheme("elevated")
const elevatedTheme = useTheme()
const [panel, setPanel] = createSignal<Panel>()
const [dumping, setDumping] = createSignal(false)
const [dumpPath, setDumpPath] = createSignal<string>()
@@ -474,7 +474,7 @@ function BarItem(props: ParentProps<{ active: boolean; onClick: () => void }>) {
}
function PanelBox(props: ParentProps) {
const theme = useTheme("elevated")
const theme = useTheme()
const renderer = useRenderer()
return (
<box
@@ -487,7 +487,7 @@ function PanelBox(props: ParentProps) {
paddingRight={2}
paddingTop={1}
paddingBottom={1}
backgroundColor={theme.background.default}
backgroundColor={theme.background.raised.base}
flexDirection="column"
onMouseUp={(event) => {
if (renderer.getSelection()?.getSelectedText()) return
@@ -500,7 +500,7 @@ function PanelBox(props: ParentProps) {
}
function PanelTitle(props: ParentProps) {
const theme = useTheme("elevated")
const theme = useTheme()
return (
<text fg={theme.text.default} attributes={TextAttributes.BOLD} marginBottom={1}>
{props.children}
@@ -509,7 +509,7 @@ function PanelTitle(props: ParentProps) {
}
function Row(props: { label: string; value: string }) {
const theme = useTheme("elevated")
const theme = useTheme()
return (
<box flexDirection="row">
<text fg={theme.text.subdued}>{props.label}</text>
@@ -520,12 +520,12 @@ function Row(props: { label: string; value: string }) {
}
function Action(props: ParentProps<{ onClick: () => void; disabled?: boolean; hoverBackground?: boolean }>) {
const theme = useTheme("elevated")
const theme = useTheme()
const [hovered, setHovered] = createSignal(false)
return (
<box
backgroundColor={
props.hoverBackground && hovered() && !props.disabled ? theme.background.action.primary.hovered : undefined
props.hoverBackground && hovered() && !props.disabled ? theme.background.raised.high : undefined
}
onMouseOver={() => setHovered(true)}
onMouseOut={() => setHovered(false)}
@@ -545,7 +545,7 @@ function cpuPercent(microseconds: number, milliseconds: number) {
}
function ProcessStat(props: { label: string; values: readonly number[]; unit: string; decimals?: number }) {
const theme = useTheme("elevated")
const theme = useTheme()
const value = () => {
const value = props.values.at(-1)
if (value === undefined) return "--"
@@ -1,6 +1,6 @@
import { CliRenderEvents, TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { createSignal, Show } from "solid-js"
import { useConfig } from "../config"
import { useClipboard } from "../context/clipboard"
import { Keymap } from "../context/keymap"
@@ -27,35 +27,11 @@ export function DialogErrorDetails(props: {
const location = useLocation()
const route = useRoute()
const toast = useToast()
const theme = useTheme("elevated")
const renderer = useRenderer()
const theme = useTheme().surface("dialog")
const dimensions = useTerminalDimensions()
const config = useConfig().data
const [copied, setCopied] = createSignal(false)
const [scrollable, setScrollable] = createSignal(false)
const [height, setHeight] = createSignal(1)
const maxHeight = createMemo(() => Math.max(3, Math.floor(dimensions().height / 2) - 5))
let scroll: ScrollBoxRenderable | undefined
let measure: (() => void) | undefined
createEffect(() => {
dimensions()
props.error
if (measure) renderer.off(CliRenderEvents.FRAME, measure)
measure = () => {
measure = undefined
if (!scroll) return
const next = Math.max(1, Math.min(maxHeight(), scroll.scrollHeight))
setHeight(next)
setScrollable(scroll.scrollHeight > next)
}
renderer.once(CliRenderEvents.FRAME, measure)
renderer.requestRender()
})
onCleanup(() => {
if (measure) renderer.off(CliRenderEvents.FRAME, measure)
})
const copy = () => {
void clipboard
@@ -86,11 +62,10 @@ export function DialogErrorDetails(props: {
}))
useKeyboard((event) => {
if (!scrollable()) 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" && scroll) return scroll.scrollTo(scroll.scrollHeight)
})
@@ -126,7 +101,8 @@ export function DialogErrorDetails(props: {
<box>
<scrollbox
ref={(element: ScrollBoxRenderable) => (scroll = element)}
height={height()}
maxHeight={20}
contentOptions={{ minHeight: 0 }}
scrollbarOptions={{ visible: false }}
scrollAcceleration={getScrollAcceleration(config)}
>
@@ -151,9 +127,7 @@ export function DialogErrorDetails(props: {
</span>
<span style={{ fg: theme.text.subdued }}>{copied() ? "" : " copy details"}</span>
</text>
<Show when={scrollable()}>
<text fg={theme.text.subdued}>/ scroll</text>
</Show>
<text fg={theme.text.subdued}>/ scroll</text>
</box>
</box>
)
@@ -13,7 +13,7 @@ type ImagePreviewItem = Readonly<{
export function DialogImagePreview(props: { images: readonly ImagePreviewItem[]; initial: number }) {
const dialog = useDialog()
const dimensions = useTerminalDimensions()
const theme = useTheme("elevated")
const theme = useTheme().surface("dialog")
const [index, setIndex] = createSignal(Math.max(0, Math.min(props.images.length - 1, props.initial)))
const [failed, setFailed] = createSignal(false)
const current = createMemo(() => props.images[index()])
@@ -78,7 +78,7 @@ export function DialogIntegration(
const data = useData()
const currentLocation = useLocation()
const dialog = useDialog()
const theme = useTheme("elevated")
const theme = useTheme().surface("dialog")
const location = currentLocation.ref ?? data.location.default()
const integrations = createMemo(() =>
integrationOptions(data.location.integration.list(location) ?? []).filter(
@@ -153,7 +153,7 @@ function manageConnections(
const data = useData()
const client = useClient()
const toast = useToast()
const theme = useTheme("elevated")
const theme = useTheme().surface("dialog")
const shortcuts = Keymap.useShortcuts()
const [deleting, setDeleting] = createSignal<string>()
const [selected, setSelected] = createSignal(methods.length ? "add" : credentialConnections(integration)[0]?.id)
@@ -427,8 +427,8 @@ function CommandPending(props: {
function CommandView(props: { title: string; output: string; message: string }) {
const dialog = useDialog()
const theme = useTheme("elevated")
const overlayTheme = useTheme("overlay")
const theme = useTheme().surface("dialog")
const overlayTheme = useTheme()
onMount(() => dialog.setSize("large"))
return (
<box gap={1} paddingBottom={1}>
@@ -441,7 +441,7 @@ function CommandView(props: { title: string; output: string; message: string })
</text>
</box>
<box
backgroundColor={overlayTheme.background.default}
backgroundColor={overlayTheme.background.raised.high}
paddingLeft={2}
paddingRight={2}
paddingTop={1}
@@ -467,7 +467,7 @@ function KeyMethod(props: {
const dialog = useDialog()
const client = useClient()
const toast = useToast()
const theme = useTheme("elevated")
const theme = useTheme().surface("dialog")
const [error, setError] = createSignal<string>()
return (
@@ -672,7 +672,7 @@ function OAuthCode(props: {
const dialog = useDialog()
const client = useClient()
const toast = useToast()
const theme = useTheme("elevated")
const theme = useTheme().surface("dialog")
const [error, setError] = createSignal<string>()
let settled = false
@@ -724,7 +724,7 @@ function OAuthView(props: {
open?: boolean
}) {
const dialog = useDialog()
const theme = useTheme("elevated")
const theme = useTheme().surface("dialog")
return (
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between">
@@ -852,7 +852,7 @@ function textAnswer(
return new Promise<FormValue | undefined | typeof CANCELLED>((resolve) => {
dialog.replace(
() => {
const theme = useTheme("elevated")
const theme = useTheme().surface("dialog")
const [error, setError] = createSignal<string>()
return (
<DialogPrompt
+1 -1
View File
@@ -40,7 +40,7 @@ export function DialogMcp(props: { initialServer?: string; details?: boolean } =
const client = useClient()
const location = useLocation()
const toast = useToast()
const theme = useTheme("elevated")
const theme = useTheme().surface("dialog")
const current = () => location.ref ?? data.location.default()
const servers = createMemo(() =>
pipe(
+1 -1
View File
@@ -44,7 +44,7 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
const sessionTabs = useSessionTabs()
const toast = useToast()
const themes = useThemes()
const theme = useTheme("elevated")
const theme = useTheme().surface("dialog")
const mode = themes.mode
const paths = useTuiPaths()
const dimensions = useTerminalDimensions()
+1 -1
View File
@@ -17,7 +17,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
const client = useClient()
const dialog = useDialog()
const dimensions = useTerminalDimensions()
const theme = useTheme("elevated")
const theme = useTheme().surface("dialog")
const [loadError, setLoadError] = createSignal<unknown>()
const [showPassword, setShowPassword] = createSignal(false)
const [passwordHover, setPasswordHover] = createSignal(false)
@@ -30,7 +30,7 @@ export function DialogSessionList() {
const route = useRoute()
const data = useData()
const themes = useThemes()
const theme = useTheme("elevated")
const theme = useTheme().surface("dialog")
const mode = themes.mode
const client = useClient()
const local = useLocal()
@@ -13,7 +13,7 @@ const PAGE_BYTES = 64 * 1024
export function DialogShellOutput(props: { shell: ShellInfo; location: LocationRef }) {
const client = useClient()
const dialog = useDialog()
const theme = useTheme("elevated")
const theme = useTheme().surface("dialog")
const dimensions = useTerminalDimensions()
const [info, setInfo] = createSignal(props.shell)
const [output, setOutput] = createSignal<string>()
+1 -1
View File
@@ -29,7 +29,7 @@ function getStashPreview(input: string, maxLength: number = 50): string {
export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
const dialog = useDialog()
const stash = usePromptStash()
const theme = useTheme("elevated")
const theme = useTheme().surface("dialog")
const shortcuts = Keymap.useShortcuts()
const [toDelete, setToDelete] = createSignal<number>()
+1 -1
View File
@@ -6,7 +6,7 @@ import { For, Match, Switch, Show, createMemo } from "solid-js"
export function DialogStatus() {
const data = useData()
const theme = useTheme("elevated")
const theme = useTheme().surface("dialog")
const dialog = useDialog()
const mcp = createMemo(() => data.location.mcp.server.list() ?? [])
+1 -1
View File
@@ -15,7 +15,7 @@ export function DialogUpdate(props: {
restart: () => void
}) {
const dialog = useDialog()
const theme = useTheme("elevated")
const theme = useTheme().surface("dialog")
const [error, setError] = createSignal<string>()
const [active, setActive] = createSignal(0)
const controller = new AbortController()
@@ -31,8 +31,8 @@ export function DialogWorkspaceFileChanges(props: {
message?: string
}) {
const dialog = useDialog()
const theme = useTheme("elevated")
const overlayTheme = useTheme("overlay")
const theme = useTheme().surface("dialog")
const overlayTheme = useTheme()
const config = useConfig().data
const dimensions = useTerminalDimensions()
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
@@ -86,7 +86,7 @@ export function DialogWorkspaceFileChanges(props: {
</box>
<scrollbox
height={height()}
backgroundColor={overlayTheme.background.default}
backgroundColor={overlayTheme.background.raised.high}
scrollbarOptions={{ visible: false }}
scrollAcceleration={scrollAcceleration()}
>
@@ -40,7 +40,7 @@ export function DialogWorkspaces(props: DialogWorkspacesProps) {
const dialog = useDialog()
const client = useClient()
const dimensions = useTerminalDimensions()
const theme = useTheme("elevated")
const theme = useTheme().surface("dialog")
const sessionData = useData()
const route = useRoute()
const toast = useToast()
@@ -8,7 +8,7 @@ import { useConfig } from "../config"
export function DialogWorktreeName(props: { onConfirm: (name: string) => void }) {
const dialog = useDialog()
const theme = useTheme("elevated")
const theme = useTheme().surface("dialog")
const shortcuts = Keymap.useShortcuts()
const config = useConfig().data
const [inputTarget, setInputTarget] = createSignal<InputRenderable>()
@@ -10,7 +10,7 @@ type Progress = { label: string; numerator?: number; denominator?: number }
export function MigrationOverlay() {
const client = useClient()
const toast = useToast()
const theme = useTheme("overlay")
const theme = useTheme()
const [progress, setProgress] = createSignal<Progress>()
const abort = new AbortController()
@@ -52,7 +52,7 @@ export function MigrationOverlay() {
top={1}
right={2}
flexDirection="row"
backgroundColor={theme.background.default}
backgroundColor={theme.background.raised.high}
border={["left"]}
borderColor={theme.text.feedback.info.default}
customBorderChars={SplitBorder.customBorderChars}
+6 -5
View File
@@ -2,7 +2,7 @@ import type { BoxRenderable } from "@opentui/core"
import { onCleanup, onMount } from "solid-js"
import { usePanel, type PanelTarget } from "../context/panel"
import { InteractivityProvider } from "../context/interactivity"
import { ThemeContextProvider, useTheme } from "../context/theme"
import { useTheme } from "../context/theme"
import { Slot } from "../plugin/render"
export function PanelHost(props: {
@@ -19,6 +19,9 @@ export function PanelHost(props: {
const Content = () => {
const theme = useTheme()
// Side panels sit on a raised surface; fullscreen takes over the base background.
const background = () =>
panels.presentation() === "panel" ? theme.background.raised.base : theme.background.default
return (
<box
id="session-panel"
@@ -27,7 +30,7 @@ export function PanelHost(props: {
minWidth={0}
minHeight={0}
focusable
backgroundColor={theme.background.default}
backgroundColor={background()}
onMouseDown={props.onFocus}
>
<Slot
@@ -55,9 +58,7 @@ export function PanelHost(props: {
return (
<InteractivityProvider enabled={props.focused}>
<ThemeContextProvider context={panels.presentation() === "panel" ? "elevated" : undefined}>
<Content />
</ThemeContextProvider>
<Content />
</InteractivityProvider>
)
}
@@ -77,7 +77,7 @@ export function Autocomplete(props: {
const data = useData()
const keymap = Keymap.use()
const keymapCommands = Keymap.useCommands()
const theme = useTheme("overlay")
const theme = useTheme()
const dimensions = useTerminalDimensions()
const frecency = useFrecency()
const config = useConfig().data
@@ -887,7 +887,7 @@ export function Autocomplete(props: {
scroll = r
scroll.verticalScrollBar.on("change", syncSelectionWindow)
}}
backgroundColor={theme.background.default}
backgroundColor={theme.background.raised.high}
height={height()}
scrollbarOptions={{ visible: false }}
scrollAcceleration={scrollAcceleration()}
+2 -2
View File
@@ -3,7 +3,7 @@ import { useTheme } from "../context/theme"
import { Spinner } from "./spinner"
export function Reconnecting(props: { managed?: boolean }) {
const theme = useTheme("elevated")
const theme = useTheme()
return (
<box
@@ -21,7 +21,7 @@ export function Reconnecting(props: { managed?: boolean }) {
width={48}
maxWidth="90%"
flexDirection="column"
backgroundColor={theme.background.default}
backgroundColor={theme.background.raised.base}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
@@ -10,29 +10,29 @@ export function SessionTabsRailControls(props: {
tabs: SessionTabsController
belowHighlighted: boolean
}) {
const theme = useTheme("elevated")
const theme = useTheme()
const keymap = Keymap.use()
const [hovered, setHovered] = createSignal(false)
const hoverColor = createMemo(() =>
tint(theme.background.default, theme.background.action.primary.hovered, theme.background.action.primary.hovered.a),
tint(theme.background.raised.base, theme.background.raised.high, theme.background.raised.high.a),
)
let pressed = false
const search = () => (props.tabs.search ? props.tabs.search() : keymap.dispatch("session.list"))
return (
<box height={1} position="relative" flexShrink={0} backgroundColor={theme.background.default}>
<box height={1} position="relative" flexShrink={0} backgroundColor={theme.background.raised.base}>
<SessionTabHalfRow
top={-1}
edge="top"
width={props.width}
color={hovered() ? hoverColor() : theme.background.default}
background={theme.background.default}
color={hovered() ? hoverColor() : theme.background.raised.base}
background={theme.background.raised.base}
/>
<box
height={1}
position="relative"
flexDirection="row"
justifyContent="center"
backgroundColor={hovered() ? theme.background.action.primary.hovered : undefined}
backgroundColor={hovered() ? theme.background.raised.high : undefined}
onMouseOver={() => setHovered(true)}
onMouseOut={() => setHovered(false)}
onMouseDown={(event) => {
@@ -59,7 +59,7 @@ export function SessionTabsRailControls(props: {
height={1}
zIndex={2}
fg={hoverColor()}
bg={hovered() && props.belowHighlighted ? hoverColor() : theme.background.default}
bg={hovered() && props.belowHighlighted ? hoverColor() : theme.background.raised.base}
selectable={false}
>
{(hovered() ? "▀" : props.belowHighlighted ? "▄" : " ").repeat(props.width)}
+44 -39
View File
@@ -366,7 +366,9 @@ export function createTabMarquee(animations: () => boolean) {
function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsController; onClose: () => void }) {
const dimensions = useTerminalDimensions()
const theme = useTheme("elevated")
const theme = useTheme()
const background = () => theme.background.raised.base
const actionHovered = () => theme.background.raised.high
const dialog = useDialog()
onCleanup(Keymap.use().mode.push("menu"))
Keymap.createLayer(() => ({
@@ -430,7 +432,7 @@ function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsCo
height={actions().length}
width={CONTEXT_MENU_WIDTH}
flexDirection="column"
backgroundColor={theme.background.default}
backgroundColor={background()}
onMouseDown={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) props.onClose()
event.preventDefault()
@@ -443,7 +445,7 @@ function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsCo
width="100%"
paddingLeft={1}
paddingRight={1}
backgroundColor={selected() === index() ? theme.background.action.primary.hovered : undefined}
backgroundColor={selected() === index() ? actionHovered() : undefined}
onMouseOver={() => setSelected(index())}
onMouseOut={() => setSelected(undefined)}
onMouseUp={(event) => {
@@ -515,7 +517,10 @@ function VerticalSessionTabs(props: {
const data = props.controller ? undefined : useData()
const dimensions = useTerminalDimensions()
const renderer = useRenderer()
const theme = useTheme("elevated")
const theme = useTheme()
const background = () => theme.background.raised.base
const actionSelected = () => theme.background.action.primary.selected
const actionHovered = () => theme.background.raised.high
const base = useTheme()
const config = useConfig().data
const animations = () => props.animations ?? config.animations ?? true
@@ -525,9 +530,9 @@ function VerticalSessionTabs(props: {
const stride = () => (compact() ? 2 : 3)
const unreadColor = () => theme.text.status.unread
const activeNumber = () => theme.text.status.running
const idleNumber = () => tint(theme.text.formfield.default, theme.background.default, 0.55)
const separatorUpperPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.04))
const separatorLowerPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.05))
const idleNumber = () => tint(theme.text.formfield.default, background(), 0.55)
const separatorUpperPulseColor = createMemo(() => tint(background(), theme.text.default, 0.04))
const separatorLowerPulseColor = createMemo(() => tint(background(), theme.text.default, 0.05))
const [addHovered, setAddHovered] = createSignal(false)
const marquee = createTabMarquee(animations)
const hovered = marquee.hovered
@@ -564,7 +569,7 @@ function VerticalSessionTabs(props: {
})
const items = ordered
const highlightColor = createMemo(() =>
tint(theme.background.default, theme.background.action.primary.hovered, theme.background.action.primary.hovered.a),
tint(background(), actionHovered(), actionHovered().a),
)
const highlighted = (sessionID: string | undefined) =>
sessionID !== undefined && (activeID() === sessionID || hovered() === sessionID || dragging() === sessionID)
@@ -671,7 +676,7 @@ function VerticalSessionTabs(props: {
flexDirection="column"
position="relative"
paddingTop={1}
backgroundColor={theme.background.default}
backgroundColor={background()}
onMouseOut={marquee.leaveHovered}
onMouseUp={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
@@ -694,7 +699,7 @@ function VerticalSessionTabs(props: {
}}
flexGrow={1}
minHeight={0}
backgroundColor={theme.background.default}
backgroundColor={background()}
scrollbarOptions={{ visible: false }}
>
<box flexShrink={0} flexDirection="column" gap={1} paddingY={compact() ? 1 : 0}>
@@ -732,13 +737,13 @@ function VerticalSessionTabs(props: {
const detailFades = createMemo(
() => marqueeOverflows(tabDetail(), titleWidth()) && titleWidth() > FADE_WIDTH,
)
const background = createMemo(() => {
if (selected() && !compact()) return theme.background.action.primary.selected
const tabBackground = createMemo(() => {
if (selected() && !compact()) return actionSelected()
if ((compact() && selected()) || hovered() === tab.sessionID || dragging() === tab.sessionID)
return theme.background.action.primary.hovered
return theme.background.default
return actionHovered()
return background()
})
const pulseBackground = createMemo(() => tint(theme.background.default, background(), background().a))
const pulseBackground = createMemo(() => tint(background(), tabBackground(), tabBackground().a))
const runs = () => status().runs
const numberIgnition = createNumberIgnition(runs, () => status().promptPulse, animations)
const numberColor = () => {
@@ -815,10 +820,10 @@ function VerticalSessionTabs(props: {
return lastPreviousGlowHue ?? unreadColor()
}
const separatorUpperColor = createMemo(() =>
tint(theme.background.default, previousGlowHue(), 0.1 * previousGlowLevel()),
tint(background(), previousGlowHue(), 0.1 * previousGlowLevel()),
)
const separatorLowerColor = createMemo(() =>
tint(theme.background.default, glowHue(), 0.12 * glowLevel()),
tint(background(), glowHue(), 0.12 * glowLevel()),
)
const titleColor = (index: number, separator: boolean) => {
const level = titleGlow.value().level
@@ -843,7 +848,7 @@ function VerticalSessionTabs(props: {
width="100%"
position="relative"
flexDirection="column"
backgroundColor={background()}
backgroundColor={tabBackground()}
onMouseOver={(event) => {
setHoverY(event.y)
marquee.enter(tab.sessionID, title(), compact() ? Infinity : hoveredTitleWidth())
@@ -885,7 +890,7 @@ function VerticalSessionTabs(props: {
width={width()}
color={pulseBackground()}
background={
highlighted(items()[index() - 1]?.sessionID) ? highlightColor() : theme.background.default
highlighted(items()[index() - 1]?.sessionID) ? highlightColor() : background()
}
/>
<SessionTabHalfRow
@@ -900,7 +905,7 @@ function VerticalSessionTabs(props: {
: highlighted(items()[index() + 1]?.sessionID)
)
? highlightColor()
: theme.background.default
: background()
}
/>
</Show>
@@ -946,8 +951,8 @@ function VerticalSessionTabs(props: {
color={separatorLowerPulseColor()}
width={indicatorWidth}
outerColor={separatorUpperPulseColor()}
flashColor={tint(theme.background.default, theme.text.default, 0.22)}
outerFlashColor={tint(theme.background.default, theme.text.default, 0.18)}
flashColor={tint(background(), theme.text.default, 0.22)}
outerFlashColor={tint(background(), theme.text.default, 0.18)}
flashTail={8}
glowColor={separatorLowerColor()}
outerGlowColor={separatorUpperColor()}
@@ -955,7 +960,7 @@ function VerticalSessionTabs(props: {
outerGlowTail={5}
completionColor={separatorLowerColor()}
outerCompletionColor={separatorUpperColor()}
backgroundColor={theme.background.default}
backgroundColor={background()}
/>
<Show when={index() === items().length - 1}>
<TabPulse
@@ -970,18 +975,18 @@ function VerticalSessionTabs(props: {
outerComplete={false}
glow={glows()}
outerGlow={false}
color={tint(theme.background.default, theme.text.default, 0.04)}
color={tint(background(), theme.text.default, 0.04)}
width={indicatorWidth}
outerColor={tint(theme.background.default, theme.text.default, 0.006)}
flashColor={tint(theme.background.default, theme.text.default, 0.18)}
outerColor={tint(background(), theme.text.default, 0.006)}
flashColor={tint(background(), theme.text.default, 0.18)}
flashTail={8}
glowColor={tint(theme.background.default, glowHue(), 0.1 * glowLevel())}
outerGlowColor={theme.background.default}
glowColor={tint(background(), glowHue(), 0.1 * glowLevel())}
outerGlowColor={background()}
glowTail={8}
outerGlowTail={5}
completionColor={tint(theme.background.default, glowHue(), 0.1 * glowLevel())}
outerCompletionColor={theme.background.default}
backgroundColor={theme.background.default}
completionColor={tint(background(), glowHue(), 0.1 * glowLevel())}
outerCompletionColor={background()}
backgroundColor={background()}
/>
</Show>
<box height={1} width="100%" flexDirection="row" position="relative">
@@ -1113,10 +1118,10 @@ function VerticalSessionTabs(props: {
alignItems="center"
backgroundColor={
newTab() && !compact()
? theme.background.action.primary.selected
? actionSelected()
: addHovered() || (compact() && newTab())
? theme.background.action.primary.hovered
: theme.background.default
? actionHovered()
: background()
}
onMouseOver={() => setAddHovered(true)}
onMouseOut={() => setAddHovered(false)}
@@ -1145,14 +1150,14 @@ function VerticalSessionTabs(props: {
edge="top"
width={width()}
color={highlightColor()}
background={highlighted(items().at(-1)?.sessionID) ? highlightColor() : theme.background.default}
background={highlighted(items().at(-1)?.sessionID) ? highlightColor() : background()}
/>
<SessionTabHalfRow
top={1}
edge="bottom"
width={width()}
color={highlightColor()}
background={theme.background.default}
background={background()}
/>
</Show>
<text
@@ -1211,10 +1216,10 @@ function VerticalSessionTabs(props: {
top={0}
edge="top"
width={tooltipWidth()}
color={theme.background.default}
color={background()}
background={base.background.default}
/>
<box height={2} paddingX={1} backgroundColor={theme.background.default}>
<box height={2} paddingX={1} backgroundColor={background()}>
<text fg={theme.text.default} wrapMode="none" selectable={false}>
{Locale.truncateWidth(
data?.session.get(sessionID())?.title ??
@@ -1231,7 +1236,7 @@ function VerticalSessionTabs(props: {
top={3}
edge="bottom"
width={tooltipWidth()}
color={theme.background.default}
color={background()}
background={base.background.default}
/>
</box>
@@ -3,7 +3,7 @@ import { useTheme } from "../context/theme"
import { Spinner } from "./spinner"
export function StartupLoading(props: { ready: () => boolean }) {
const theme = useTheme("elevated")
const theme = useTheme()
const [show, setShow] = createSignal(false)
const text = createMemo(() => (props.ready() ? "Finishing startup…" : "Loading plugins…"))
let wait: NodeJS.Timeout | undefined
@@ -54,7 +54,7 @@ export function StartupLoading(props: { ready: () => boolean }) {
return (
<Show when={show()}>
<box position="absolute" zIndex={5000} left={0} right={0} bottom={1} justifyContent="center" alignItems="center">
<box backgroundColor={theme.background.default} paddingLeft={1} paddingRight={1}>
<box backgroundColor={theme.background.raised.base} paddingLeft={1} paddingRight={1}>
<Spinner color={theme.text.subdued}>{text()}</Spinner>
</box>
</box>
+4 -4
View File
@@ -32,7 +32,7 @@ export function TerminalPane(props: {
const client = useClient()
const keymap = Keymap.use()
const leader = Keymap.useLeaderActive()
const theme = useTheme("elevated")
const theme = useTheme()
const themes = useThemes()
const renderer = useRenderer()
const [failure, setFailure] = createSignal<string>()
@@ -155,8 +155,8 @@ export function TerminalPane(props: {
})
createEffect(() => {
const tokens = themes.currentTokens().contextual.elevated
terminalTheme = terminalPalette(tokens, themes.mode(), tokens.background.default)
const tokens = themes.currentTokens()
terminalTheme = terminalPalette(tokens, themes.mode(), tokens.background.raised.base)
applyTerminalTheme()
})
@@ -284,7 +284,7 @@ export function TerminalPane(props: {
minWidth={0}
minHeight={0}
overflow="hidden"
backgroundColor={themes.currentTokens().contextual.elevated.background.default}
backgroundColor={themes.currentTokens().background.raised.base}
onSizeChange={function () {
size = { cols: Math.max(1, this.width - 2), rows: this.height }
if (controller && restored) interact()
+17 -20
View File
@@ -5,7 +5,7 @@ import {
resolveThemeDocument,
themeModes,
type ResolvedTheme,
type ContextName,
type SurfaceName,
} from "@opencode/theme/tui"
import {
DEFAULT_THEMES,
@@ -23,7 +23,7 @@ import {
} from "../theme"
import { generateSystem, terminalMode } from "../theme/system"
import { discoverThemes } from "../theme/discovery"
import { createComponentTheme, createComponentThemeView, type ComponentTheme } from "../theme/component"
import { createComponentTheme, type ComponentTheme } from "../theme/component"
import { createEffect, createMemo, createSignal, onCleanup, onMount, type Accessor, type ParentProps } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "./helper"
@@ -122,7 +122,7 @@ type Themes = {
}
type ThemeContextValue = {
current: ComponentTheme["contextual"][ContextName]
current: ComponentTheme
themes: Themes
readonly ready: boolean
}
@@ -322,7 +322,7 @@ const themeContext = createSimpleContext({
const tokens = () => selected().theme
tokens()
themePerformance.set("Init", `${(performance.now() - initStarted).toFixed(2)} ms`)
const current = createComponentTheme(tokens, mode)
const current = createComponentTheme(tokens)
createEffect(() => renderer.setBackgroundColor(tokens().background.default))
@@ -377,27 +377,18 @@ const themeContext = createSimpleContext({
export function useThemes() {
return themeContext.use().themes
}
export function useTheme(): ComponentTheme
export function useTheme(context: ContextName): ComponentTheme["contextual"][ContextName]
export function useTheme(context?: ContextName) {
const value = themeContext.use()
return context ? value.themes.current.contextual[context] : value.current
export function useTheme(): ComponentTheme {
return themeContext.use().current
}
export const ThemeProvider = themeContext.provider
function usablePalette(colors: TerminalColors | undefined): colors is TerminalColors {
return Boolean(
colors && (colors.defaultBackground ?? colors.palette[0]) && (colors.defaultForeground ?? colors.palette[7]),
)
}
/** Switches context without remounting children; undefined inherits the enclosing view. */
export function ThemeContextProvider(props: ParentProps<{ context: ContextName | undefined }>) {
/** Switches the ambient theme surface without remounting children; undefined inherits the enclosing view. */
export function ThemeContextProvider(props: ParentProps<{ context: SurfaceName | undefined }>) {
const value = themeContext.use()
const current = createComponentThemeView(() => {
const current = createComponentTheme(() => {
const name = props.context
return name ? value.themes.currentTokens().contextual[name] : value.current
}, value.themes.mode)
return name ? value.themes.currentTokens().surface(name) : value.current
})
return (
<themeContext.context.Provider value={{ current, themes: value.themes, ready: value.ready }}>
{props.children}
@@ -405,6 +396,12 @@ export function ThemeContextProvider(props: ParentProps<{ context: ContextName |
)
}
function usablePalette(colors: TerminalColors | undefined): colors is TerminalColors {
return Boolean(
colors && (colors.defaultBackground ?? colors.palette[0]) && (colors.defaultForeground ?? colors.palette[7]),
)
}
function loadTheme(source: ThemeDocumentSource, name: string, requested: "dark" | "light") {
const document = parseTheme(source, name)
const modes = themeModes(document)
+14 -14
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"
@@ -86,12 +86,10 @@ function Answer(props: { question: string; answer: string }) {
const toast = useToast()
const clipboard = useClipboard()
const plugins = usePlugin()
const theme = useTheme("elevated")
const overlay = useTheme("overlay")
const theme = useTheme().surface("dialog")
const overlay = useTheme()
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,14 +126,16 @@ 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()}
backgroundColor={overlay.background.default}
maxHeight={20}
backgroundColor={overlay.background.raised.high}
scrollbarOptions={{ visible: false }}
scrollAcceleration={getScrollAcceleration(config)}
>
@@ -148,7 +148,7 @@ function Answer(props: { question: string; answer: string }) {
internalBlockMode="top-level"
tableOptions={{ style: "grid", cellPaddingX: 1 }}
fg={overlay.markdown.text}
bg={overlay.background.default}
bg={overlay.background.raised.high}
/>
</box>
</scrollbox>
@@ -12,7 +12,7 @@ export function DiffFileMenu(props: {
onClose: () => void
}) {
const dimensions = useTerminalDimensions()
const theme = props.context.theme.contextual.overlay
const theme = props.context.theme
const [hovered, setHovered] = createSignal(false)
const label = () => (props.reviewed ? "Mark incomplete" : "Mark complete")
const width = () => Math.min(19, dimensions().width)
@@ -63,7 +63,7 @@ export function DiffFileMenu(props: {
height={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={hovered() ? theme.background.action.primary.hovered : theme.background.default}
backgroundColor={hovered() ? theme.background.action.primary.hovered : theme.background.raised.high}
onMouseOver={() => setHovered(true)}
onMouseOut={() => setHovered(false)}
onMouseDown={(event) => {
@@ -27,7 +27,7 @@ export type DiffViewerFileTreeProps = {
}
export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
const theme = useTheme("elevated")
const theme = useTheme()
const [sourceHovered, setSourceHovered] = createSignal(false)
const list = () => props.layout === "list"
const tree = createMemo(() => buildFileTree(props.files))
@@ -37,9 +37,9 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
: flattenFileTree(tree(), props.expandedNodes),
)
// Quieter than subdued text: markers are affordances, not content.
const faint = createMemo(() => tint(theme.text.subdued, theme.background.default, 0.45))
const faint = createMemo(() => tint(theme.text.subdued, theme.background.raised.base, 0.45))
// Rails are pure texture; keep them barely above the surface.
const rail = createMemo(() => tint(theme.text.subdued, theme.background.default, 0.7))
const rail = createMemo(() => tint(theme.text.subdued, theme.background.raised.base, 0.7))
const reviewedCount = createMemo(() => props.files.filter((file) => props.reviewedFileNames?.has(file.file)).length)
const contentWidth = () => Math.max(0, props.width - 4 - FILE_TREE_STATUS_WIDTH - 1)
let scroll: ScrollBoxRenderable | undefined
@@ -56,7 +56,7 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
return (
<box width={props.width} height="100%" minWidth={0} minHeight={0} flexShrink={0} flexDirection="column">
<box id="diff-tree-top-edge" height={1} flexShrink={0} backgroundColor={theme.background.default} />
<box id="diff-tree-top-edge" height={1} flexShrink={0} backgroundColor={theme.background.raised.base} />
<box
flexGrow={1}
minWidth={0}
@@ -64,7 +64,7 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
backgroundColor={theme.background.default}
backgroundColor={theme.background.raised.base}
>
<box id="diff-source-header" height={1} flexShrink={0} flexDirection="row" marginBottom={1} gap={1}>
<box
@@ -137,8 +137,8 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
}
const background = () => {
// Elevated context maps this to a quiet neutral surface step, not the loud accent.
if (hovered()) return theme.background.action.primary.hovered
return theme.background.default
if (hovered()) return theme.background.raised.high
return theme.background.raised.base
}
const marker = () => {
if (row.kind !== "directory") return "≡ "
@@ -214,7 +214,7 @@ function DiffBaseDialog(props: {
current?: string
onSelect: (ref: string) => void
}) {
const theme = props.context.theme.contextual.elevated
const theme = props.context.theme.surface("dialog")
const [search, setSearch] = createDebouncedSignal("", 150)
const [branches] = createResource(search, (search) =>
props.context.client.vcs.branch.list({ location: props.location, search, limit: 100 }),
@@ -1079,7 +1079,7 @@ export function DiffViewerContent(props: {
function DiffViewerHelpDialog(props: { context: Plugin.Context; single: boolean }) {
const dimensions = useTerminalDimensions()
const theme = props.context.theme.contextual.elevated
const theme = props.context.theme.surface("dialog")
const shortcut =
(...ids: string[]) =>
() =>
@@ -14,10 +14,10 @@ export function StoryFooter(props: {
message?: string
controls: readonly StoryFooterControl[]
}) {
const theme = props.context.theme.contextual.elevated
const theme = props.context.theme
return (
<box flexShrink={0} flexDirection="column" backgroundColor={theme.background.default}>
<box flexShrink={0} flexDirection="column" backgroundColor={theme.background.raised.base}>
<box height={1} paddingLeft={1} paddingRight={1} flexDirection="row">
<text fg={theme.text.default}>{props.title}</text>
<Show when={props.details?.length}>
@@ -11,7 +11,7 @@ const directory = "/Users/kit/code/open-source/opencode-workerd-profile"
function SessionLocationMissingStory(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
const theme = props.context.theme.contextual.elevated
const theme = props.context.theme
const [message, setMessage] = createSignal("Choose another directory to continue")
const open = () =>
props.context.ui.dialog.show(() => (
@@ -45,7 +45,7 @@ function SessionLocationMissingStory(props: { context: Plugin.Context }) {
}))
return (
<box width={dimensions().width} height={dimensions().height} backgroundColor={theme.background.default}>
<box width={dimensions().width} height={dimensions().height} backgroundColor={theme.background.raised.base}>
<box paddingLeft={2} paddingRight={2} paddingTop={1} flexGrow={1}>
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
Workerd Modal workspace driver
+10 -11
View File
@@ -115,7 +115,6 @@ function map(
syntax?: SyntaxStyle,
system = false,
): RunTheme {
const elevated = theme.contextual.elevated
// V1 system migration serializes colors; restore terminal defaults before quantizing scrollback.
const exact = (color: RGBA) => {
if (system && color.equals(theme.text.default)) return RGBA.defaultForeground(color)
@@ -137,13 +136,13 @@ function map(
return {
background: RGBA.defaultBackground(theme.background.default),
footer: {
actionSecondaryText: exact(elevated.text.action.secondary.default),
actionFocusedBg: exact(elevated.background.action.primary.focused),
actionFocusedText: exact(elevated.text.action.primary.focused),
formfieldText: exact(elevated.text.formfield.default),
formfieldFocusedBg: exact(elevated.background.formfield.focused),
formfieldFocusedText: exact(elevated.text.formfield.focused),
selection: exact(elevated.text.formfield.selected),
actionSecondaryText: exact(theme.text.action.secondary.default),
actionFocusedBg: exact(theme.background.action.primary.focused),
actionFocusedText: exact(theme.text.action.primary.focused),
formfieldText: exact(theme.text.formfield.default),
formfieldFocusedBg: exact(theme.background.formfield.focused),
formfieldFocusedText: exact(theme.text.formfield.focused),
selection: exact(theme.text.formfield.selected),
running: exact(theme.text.status.running),
question: exact(theme.text.status.question),
permission: exact(theme.text.status.permission),
@@ -157,9 +156,9 @@ function map(
error: exact(theme.text.feedback.error.default),
muted: exact(theme.text.subdued),
text: exact(theme.text.default),
shade: exact(elevated.background.default),
surface: exact(elevated.background.default),
pane: exact(theme.contextual.overlay.background.default),
shade: exact(theme.background.raised.base),
surface: exact(theme.background.raised.base),
pane: exact(theme.background.raised.high),
border: exact(theme.border.default),
line: exact(theme.background.raised.high),
},
@@ -42,7 +42,7 @@ export type ComposerProps = {
}
export function Composer(props: ComposerProps) {
const theme = useTheme("elevated")
const theme = useTheme()
const config = useConfig().data
const [store, setStore] = createStore({
@@ -114,7 +114,7 @@ export function Composer(props: ComposerProps) {
{...SplitBorder}
border={["left"]}
borderColor={theme.border.default}
backgroundColor={theme.background.default}
backgroundColor={theme.background.raised.base}
paddingLeft={1}
paddingRight={2}
paddingTop={1}
@@ -21,7 +21,7 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) {
const dialog = useDialog()
const clipboard = useClipboard()
const toast = useToast()
const theme = useTheme("elevated")
const theme = useTheme().surface("dialog")
const dimensions = useTerminalDimensions()
const config = useConfig().data
const [copied, setCopied] = createSignal<"code" | "output">()
@@ -224,7 +224,7 @@ function GutteredCode(props: {
digits: number
blocks: Set<CodeRenderable>
}) {
const theme = useTheme("elevated")
const theme = useTheme().surface("dialog")
const syntax = useThemes().currentSyntax
const gutter = createMemo(() =>
props.content
+54 -73
View File
@@ -2,10 +2,10 @@ import { createStore, unwrap } from "solid-js/store"
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
import { usePaste, useRenderer, useTerminalDimensions } from "@opentui/solid"
import {
CliRenderEvents,
decodePasteBytes,
stripAnsiSequences,
TextAttributes,
type BoxRenderable,
type ScrollBoxRenderable,
type TextareaRenderable,
} from "@opentui/core"
@@ -59,7 +59,7 @@ const drafts = new Map<string, FormDraft>()
export function FormPrompt(props: { form: FormWithLocation }) {
const data = useData()
const themes = useThemes()
const theme = useTheme("elevated")
const theme = useTheme()
const themeMode = themes.mode
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
@@ -75,8 +75,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
drafts.delete(props.form.id)
const [tabHover, setTabHover] = createSignal<number | "confirm" | null>(null)
const [reviewHeight, setReviewHeight] = createSignal(1)
const [reviewScrollable, setReviewScrollable] = createSignal(false)
const [reviewContentHeight, setReviewContentHeight] = createSignal(0)
const [store, setStore] = createStore<FormDraft>(
draft ?? {
tab: 0,
@@ -92,7 +91,6 @@ export function FormPrompt(props: { form: FormWithLocation }) {
let textarea: TextareaRenderable | undefined
const [inputTarget, setInputTarget] = createSignal<TextareaRenderable>()
let review: ScrollBoxRenderable | undefined
let measureReview: (() => void) | undefined
const message = createMemo(() => {
const value = props.form.metadata?.["message"]
@@ -141,6 +139,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
return current?.type === "external" ? current : undefined
})
const confirm = createMemo(() => !single() && store.tab >= fields().length)
const reviewMaxHeight = createMemo(() => Math.max(3, dimensions().height - 14))
const configuredRows = createMemo(() => {
const current = answerField()
return current ? formRows(current) : []
@@ -211,32 +210,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
return "confirm"
})
createEffect(() => {
if (measureReview) renderer.off(CliRenderEvents.FRAME, measureReview)
if (!confirm()) {
measureReview = undefined
review = undefined
setReviewScrollable(false)
return
}
const limit = Math.max(3, dimensions().height - 14)
const initial = Math.min(Math.max(1, fields().length), limit)
Object.values(store.answers)
setReviewHeight(initial)
setReviewScrollable(false)
measureReview = () => {
measureReview = undefined
const content = review?.scrollHeight ?? initial
const height = Math.min(Math.max(1, content), limit)
setReviewHeight(height)
setReviewScrollable(content > height)
}
renderer.once(CliRenderEvents.FRAME, measureReview)
renderer.requestRender()
})
onCleanup(() => {
if (measureReview) renderer.off(CliRenderEvents.FRAME, measureReview)
// A reply or cancel removes the form from data before this unmount runs, so a
// form still listed here is only hidden by navigation and worth restoring.
const pending = data.session.form
@@ -794,7 +768,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
return (
<box
backgroundColor={theme.background.default}
backgroundColor={theme.background.raised.base}
border={["left"]}
borderColor={theme.hue.interactive[themeMode() === "light" ? 800 : 200]}
customBorderChars={SplitBorder.customBorderChars}
@@ -839,7 +813,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
? theme.background.formfield.selected
: tabHover() === index()
? theme.background.formfield.focused
: theme.background.default
: theme.background.raised.base
}
onMouseOver={() => setTabHover(index())}
onMouseOut={() => setTabHover(null)}
@@ -861,7 +835,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
? theme.background.formfield.selected
: tabHover() === "confirm"
? theme.background.formfield.focused
: theme.background.default
: theme.background.raised.base
}
onMouseOver={() => setTabHover("confirm")}
onMouseOut={() => setTabHover(null)}
@@ -969,7 +943,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
>
<box flexDirection="row">
<box
backgroundColor={active() ? theme.background.formfield.focused : theme.background.default}
backgroundColor={active() ? theme.background.formfield.focused : theme.background.raised.base}
paddingRight={1}
>
<text
@@ -977,7 +951,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
>{`${i() + 1}.`}</text>
</box>
<box
backgroundColor={active() ? theme.background.formfield.focused : theme.background.default}
backgroundColor={active() ? theme.background.formfield.focused : theme.background.raised.base}
flexDirection="row"
>
<Show when={multi()}>
@@ -1023,7 +997,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
>
<box flexDirection="row">
<box
backgroundColor={other() ? theme.background.formfield.focused : theme.background.default}
backgroundColor={other() ? theme.background.formfield.focused : theme.background.raised.base}
paddingRight={1}
>
<text fg={other() ? theme.text.formfield.focused : theme.text.subdued}>
@@ -1033,7 +1007,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
<box
flexDirection="row"
flexGrow={1}
backgroundColor={other() ? theme.background.formfield.focused : theme.background.default}
backgroundColor={other() ? theme.background.formfield.focused : theme.background.raised.base}
>
<Show when={multi()}>
<text
@@ -1102,56 +1076,63 @@ export function FormPrompt(props: { form: FormWithLocation }) {
<Show when={confirm()}>
<scrollbox
height={reviewHeight()}
maxHeight={reviewMaxHeight()}
contentOptions={{ minHeight: 0 }}
scrollbarOptions={{ visible: false }}
ref={(r: ScrollBoxRenderable) => (review = r)}
>
<For each={fields()}>
{(item) => {
if (item.type === "external") {
const acknowledged = () => store.answers[item.key] === true
<box
onSizeChange={function (this: BoxRenderable) {
setReviewContentHeight(this.height)
}}
>
<For each={fields()}>
{(item) => {
if (item.type === "external") {
const acknowledged = () => store.answers[item.key] === true
return (
<box paddingLeft={1}>
<text>
<span style={{ fg: theme.text.subdued }}>{truncate(formLabel(item), 40)}:</span>{" "}
<span
style={{
fg: acknowledged()
? theme.text.feedback.success.default
: theme.text.feedback.error.default,
}}
>
{acknowledged() ? "Acknowledged" : "(acknowledgement required)"}
</span>
</text>
</box>
)
}
const value = () => formDisplayValue(item, store.answers[item.key], "(none)")
const answered = () => store.answers[item.key] !== undefined
const missing = () => !answered() && item.required === true
const invalid = () => formValidateValue(item, store.answers[item.key])
return (
<box paddingLeft={1}>
<text>
<span style={{ fg: theme.text.subdued }}>{truncate(formLabel(item), 40)}:</span>{" "}
<span
style={{
fg: acknowledged()
? theme.text.feedback.success.default
: theme.text.feedback.error.default,
fg:
invalid() || missing()
? theme.text.feedback.error.default
: answered()
? theme.text.default
: theme.text.subdued,
}}
>
{acknowledged() ? "Acknowledged" : "(acknowledgement required)"}
{invalid() ?? (answered() ? value() : missing() ? "(required)" : "(not answered)")}
</span>
</text>
</box>
)
}
const value = () => formDisplayValue(item, store.answers[item.key], "(none)")
const answered = () => store.answers[item.key] !== undefined
const missing = () => !answered() && item.required === true
const invalid = () => formValidateValue(item, store.answers[item.key])
return (
<box paddingLeft={1}>
<text>
<span style={{ fg: theme.text.subdued }}>{truncate(formLabel(item), 40)}:</span>{" "}
<span
style={{
fg:
invalid() || missing()
? theme.text.feedback.error.default
: answered()
? theme.text.default
: theme.text.subdued,
}}
>
{invalid() ?? (answered() ? value() : missing() ? "(required)" : "(not answered)")}
</span>
</text>
</box>
)
}}
</For>
}}
</For>
</box>
</scrollbox>
</Show>
</box>
@@ -1175,7 +1156,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
{"↑↓"} <span style={{ fg: theme.text.subdued }}>select</span>
</text>
</Show>
<Show when={confirm() && reviewScrollable()}>
<Show when={confirm() && reviewContentHeight() > reviewMaxHeight()}>
<text fg={theme.text.default}>
{"↑↓"} <span style={{ fg: theme.text.subdued }}>scroll</span>
</text>
+21 -28
View File
@@ -23,7 +23,7 @@ import { SplitBorder } from "../../ui/border"
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
import { Spinner, SPINNER_FRAMES } from "../../component/spinner"
import { PatchDiff } from "../../component/patch-diff"
import { createSyntaxStyleMemo, ThemeContextProvider, useTheme, useThemes } from "../../context/theme"
import { createSyntaxStyleMemo, useTheme, useThemes } from "../../context/theme"
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA, MouseEvent } from "@opentui/core"
import { Prompt, type PromptRef } from "../../component/prompt"
import type {
@@ -1936,7 +1936,7 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) {
const config = useConfig()
const data = useData()
const local = useLocal()
const theme = useTheme("elevated")
const theme = useTheme()
const model = createMemo(
() =>
ctx
@@ -2179,7 +2179,7 @@ function RevertMessage(props: {
}>
}) {
const ctx = use()
const theme = useTheme("elevated")
const theme = useTheme()
const route = useRouteData("session")
const client = useClient()
const toast = useToast()
@@ -2204,13 +2204,13 @@ function RevertMessage(props: {
marginTop={1}
border={["left"]}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.background.default}
borderColor={theme.background.raised.base}
>
<box
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
backgroundColor={hover() ? theme.raise(theme.background.default) : theme.background.default}
backgroundColor={hover() ? theme.raise(theme.background.raised.base) : theme.background.raised.base}
>
<text fg={theme.text.subdued}>
{props.count} message{props.count === 1 ? "" : "s"} reverted
@@ -2282,7 +2282,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
),
)
const themes = useThemes()
const theme = useTheme("elevated")
const theme = useTheme()
const mode = themes.mode
const [hover, setHover] = createSignal(false)
const color = createMemo(() => local.agent.color(data.session.get(ctx.sessionID)?.agent ?? "build"))
@@ -2301,7 +2301,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
border={["left"]}
borderColor={delivery() ? theme.border.default : color()}
customBorderChars={SplitBorder.customBorderChars}
backgroundColor={theme.background.default}
backgroundColor={theme.background.raised.base}
>
<SessionImages images={images()} paddingLeft={2} />
<box
@@ -2339,7 +2339,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
backgroundColor={hover() ? theme.raise(theme.background.default) : theme.background.default}
backgroundColor={hover() ? theme.raise(theme.background.raised.base) : theme.background.raised.base}
flexShrink={0}
>
<text fg={theme.text.default}>{props.message.text}</text>
@@ -2351,13 +2351,13 @@ function UserMessage(props: { message: SessionMessageUser }) {
<span
style={{
bg: theme.hue.accent[mode() === "light" ? 700 : 200],
fg: theme.background.default,
fg: theme.background.raised.base,
bold: true,
}}
>
{" skill "}
</span>
<span style={{ bg: theme.raise(theme.background.default), fg: theme.text.subdued }}>
<span style={{ bg: theme.raise(theme.background.raised.base), fg: theme.text.subdued }}>
{` ${skill.name} `}
</span>
</text>
@@ -2375,13 +2375,13 @@ function UserMessage(props: { message: SessionMessageUser }) {
<span
style={{
bg: theme.hue.accent[mode() === "light" ? 700 : 200],
fg: theme.background.default,
fg: theme.background.raised.base,
bold: true,
}}
>
{` ${label} `}
</span>
<span style={{ bg: theme.raise(theme.background.default), fg: theme.text.subdued }}>
<span style={{ bg: theme.raise(theme.background.raised.base), fg: theme.text.subdued }}>
{" "}
{file.name ?? (file.source.type === "uri" ? file.source.uri : "attachment")}{" "}
</span>
@@ -2398,7 +2398,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
}
function QueuedPromptDock(props: { prompts: { id: string; text: string }[]; onOpen: () => void }) {
const theme = useTheme("elevated")
const theme = useTheme()
const [hover, setHover] = createSignal(false)
const next = createMemo(() => props.prompts[0]?.text.replaceAll("\n", " "))
@@ -2417,7 +2417,7 @@ function QueuedPromptDock(props: { prompts: { id: string; text: string }[]; onOp
paddingBottom={1}
paddingLeft={2}
paddingRight={1}
backgroundColor={hover() ? theme.raise(theme.background.default) : theme.background.default}
backgroundColor={hover() ? theme.raise(theme.background.raised.base) : theme.background.raised.base}
flexDirection="row"
>
<text fg={theme.text.subdued} wrapMode="none" truncate flexGrow={1} flexShrink={1} minWidth={0}>
@@ -2758,10 +2758,11 @@ function InlineTool(props: {
)
}
function StatusBadge(props: { children: string }) {
function StatusBadge(props: { children: string; raised?: boolean }) {
const theme = useTheme()
const background = () => (props.raised ? theme.background.raised.base : theme.background.default)
return (
<text flexShrink={0} bg={theme.raise(theme.background.default)} fg={theme.text.subdued}>
<text flexShrink={0} bg={theme.raise(background())} fg={theme.text.subdued}>
{" "}
{props.children}{" "}
</text>
@@ -2781,16 +2782,8 @@ type BlockToolProps = {
}
function BlockTool(props: BlockToolProps) {
const parentTheme = useTheme()
return (
<ThemeContextProvider context="elevated">
<BlockToolContent {...props} borderColor={parentTheme.background.default} />
</ThemeContextProvider>
)
}
function BlockToolContent(props: BlockToolProps & { borderColor: RGBA }) {
const theme = useTheme()
const background = () => theme.background.raised.base
const ctx = use()
const renderer = useRenderer()
const [hover, setHover] = createSignal(false)
@@ -2806,9 +2799,9 @@ function BlockToolContent(props: BlockToolProps & { borderColor: RGBA }) {
paddingBottom={1}
paddingLeft={2}
gap={1}
backgroundColor={hover() ? theme.raise(theme.background.default) : theme.background.default}
backgroundColor={hover() ? theme.raise(background()) : background()}
customBorderChars={SplitBorder.customBorderChars}
borderColor={props.borderColor}
borderColor={theme.background.default}
onMouseOver={() => props.onClick && setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={() => {
@@ -3041,7 +3034,7 @@ function ShellDisplay(props: {
</Show>
</Show>
<Show when={props.background}>
<StatusBadge>Background</StatusBadge>
<StatusBadge raised>Background</StatusBadge>
</Show>
</box>
</BlockTool>
@@ -13,7 +13,7 @@ export function SessionLocationMissing(props: { directory: string; projectID: st
export function SessionLocationUnavailable(props: { directory: string; onMove: () => void }) {
const paths = useTuiPaths()
const theme = useTheme("elevated")
const theme = useTheme()
const directory = createMemo(() => Locale.truncateMiddle(abbreviateHome(props.directory, paths.home), 72))
return (
@@ -277,7 +277,7 @@ function RejectPrompt(props: {
}) {
let input: TextareaRenderable
const enabled = useInteractivity()
const theme = useTheme("elevated")
const theme = useTheme()
const config = useConfig().data
const dimensions = useTerminalDimensions()
const narrow = createMemo(() => dimensions().width < 80)
@@ -314,7 +314,7 @@ function RejectPrompt(props: {
role: "dialog",
label: `Reject permission: ${props.action}`,
}))}
backgroundColor={theme.background.default}
backgroundColor={theme.background.raised.base}
border={["left"]}
borderColor={theme.text.feedback.error.default}
customBorderChars={SplitBorder.customBorderChars}
@@ -335,7 +335,7 @@ function RejectPrompt(props: {
paddingLeft={2}
paddingRight={3}
paddingBottom={1}
backgroundColor={theme.raise(theme.background.default)}
backgroundColor={theme.raise(theme.background.raised.base)}
justifyContent={narrow() ? "flex-start" : "space-between"}
alignItems={narrow() ? "flex-start" : "center"}
gap={1}
@@ -418,7 +418,7 @@ export function SessionQuestion<const T extends Record<string, string>>(props: {
fullscreen?: boolean
onSelect: (option: keyof T) => void
}) {
const theme = useTheme("elevated")
const theme = useTheme()
const dimensions = useTerminalDimensions()
const keys = Object.keys(props.options) as (keyof T)[]
const [store, setStore] = createStore({
@@ -507,7 +507,7 @@ export function SessionQuestion<const T extends Record<string, string>>(props: {
label: props.semanticLabel ?? props.title,
expanded: store.expanded,
}))}
backgroundColor={theme.background.default}
backgroundColor={theme.background.raised.base}
border={["left"]}
borderColor={theme.background.action.primary.focused}
customBorderChars={SplitBorder.customBorderChars}
@@ -546,7 +546,7 @@ export function SessionQuestion<const T extends Record<string, string>>(props: {
paddingLeft={2}
paddingRight={3}
paddingBottom={1}
backgroundColor={theme.raise(theme.background.default)}
backgroundColor={theme.raise(theme.background.raised.base)}
justifyContent={narrow() ? "flex-start" : "space-between"}
alignItems={narrow() ? "flex-start" : "center"}
>
+4 -4
View File
@@ -12,7 +12,7 @@ import { SESSION_SIDEBAR_WIDTH } from "../../ui/layout"
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
const data = useData()
const theme = useTheme("elevated")
const theme = useTheme()
const config = useConfig().data
const session = createMemo(() => data.session.get(props.sessionID))
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
@@ -20,7 +20,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
return (
<Show when={session()}>
<box
backgroundColor={theme.background.default}
backgroundColor={theme.background.raised.base}
width={SESSION_SIDEBAR_WIDTH}
height="100%"
paddingTop={1}
@@ -37,7 +37,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
title: withTimestampedFallback(session()),
}}
enabled={config.animations ?? true}
backdrop={theme.background.default}
backdrop={theme.background.raised.base}
attributes={
data.session.title.pending(props.sessionID) && config.animations === false
? TextAttributes.DIM
@@ -61,7 +61,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
width: 1,
height: "100%",
trackOptions: {
backgroundColor: theme.background.default,
backgroundColor: theme.background.raised.base,
foregroundColor: theme.scrollbar.default,
},
}}
+15 -13
View File
@@ -1,17 +1,12 @@
import type { RGBA } from "@opentui/core"
import type { Accessor } from "solid-js"
import type { Mode, ResolvedTheme, ResolvedThemeTokens } from "@opencode/theme/tui"
import type { ResolvedTheme, SurfaceName } from "@opencode/theme/tui"
export function createComponentTheme(current: Accessor<ResolvedTheme>, mode: Accessor<Mode>) {
return Object.assign(createComponentThemeView(current, mode), {
contextual: {
elevated: createComponentThemeView(() => current().contextual.elevated, mode),
overlay: createComponentThemeView(() => current().contextual.overlay, mode),
},
})
}
export function createComponentThemeView(view: Accessor<ResolvedThemeTokens>, mode: Accessor<Mode>) {
export function createComponentTheme(
view: Accessor<ResolvedTheme>,
// Shared across a theme's surface views so `surface()` stays absolute at the wrapper level too.
surfaces = new Map<SurfaceName, ComponentTheme>(),
): ComponentTheme {
return {
get hue() {
return view().hue
@@ -43,8 +38,15 @@ export function createComponentThemeView(view: Accessor<ResolvedThemeTokens>, mo
source: (color: RGBA) => view().source(color),
increase: (color: RGBA, amount = 1) => view().increase(color, amount),
decrease: (color: RGBA, amount = 1) => view().decrease(color, amount),
raise: (color: RGBA) => (mode() === "light" ? view().increase(color) : view().decrease(color)),
raise: (color: RGBA) => view().raise(color),
surface(name: SurfaceName) {
const cached = surfaces.get(name)
if (cached) return cached
const created = createComponentTheme(() => view().surface(name), surfaces)
surfaces.set(name, created)
return created
},
}
}
export type ComponentTheme = ReturnType<typeof createComponentTheme>
export type ComponentTheme = ResolvedTheme
+1 -1
View File
@@ -11,7 +11,7 @@ export type DialogAlertProps = {
export function DialogAlert(props: DialogAlertProps) {
const dialog = useDialog()
const theme = useTheme("elevated")
const theme = useTheme().surface("dialog")
Keymap.createLayer(() => ({
mode: "modal",
+1 -1
View File
@@ -19,7 +19,7 @@ export type DialogConfirmProps = {
export function DialogConfirm(props: DialogConfirmProps) {
const dialog = useDialog()
const theme = useTheme("elevated")
const theme = useTheme().surface("dialog")
const [store, setStore] = createStore({
active: "confirm" as "confirm" | "cancel",
})
@@ -23,8 +23,8 @@ type Active = ExportFormat | "thinking" | "tools" | "sanitize" | "copy" | "expor
export function DialogExportOptions(props: DialogExportOptionsProps) {
const dialog = useDialog()
const theme = useTheme("elevated")
const overlayTheme = useTheme("overlay")
const theme = useTheme().surface("dialog")
const overlayTheme = useTheme()
const [store, setStore] = createStore({
format: "markdown" as ExportFormat,
thinking: props.defaultThinking,
@@ -247,7 +247,7 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
<box
paddingLeft={4}
paddingRight={4}
backgroundColor={overlayTheme.background.default}
backgroundColor={overlayTheme.background.raised.high}
onMouseUp={() => confirm("copy")}
>
<text fg={overlayTheme.text.default}>Copy</text>
+1 -1
View File
@@ -5,7 +5,7 @@ import { useDialog, type DialogContext } from "./dialog"
export function DialogExportResult(props: { path: string; onClose?: () => void }) {
const dialog = useDialog()
const theme = useTheme("elevated")
const theme = useTheme().surface("dialog")
const close = () => {
props.onClose?.()
+1 -1
View File
@@ -5,7 +5,7 @@ import { useDialog } from "./dialog"
export function DialogHelp() {
const dialog = useDialog()
const theme = useTheme("elevated")
const theme = useTheme().surface("dialog")
const shortcuts = Keymap.useShortcuts()
Keymap.createLayer(() => ({
+1 -1
View File
@@ -22,7 +22,7 @@ export type DialogPromptProps = {
export function DialogPrompt(props: DialogPromptProps) {
const dialog = useDialog()
const renderer = useRenderer()
const theme = useTheme("elevated")
const theme = useTheme().surface("dialog")
const shortcuts = Keymap.useShortcuts()
const config = useConfig().data
const [textareaTarget, setTextareaTarget] = createSignal<TextareaRenderable>()
+2 -2
View File
@@ -107,7 +107,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
const dialog = useDialog()
const themes = useThemes()
const theme = useTheme("elevated")
const theme = useTheme().surface("dialog")
const mode = themes.mode
const config = useConfig().data
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
@@ -843,7 +843,7 @@ function Option(props: {
activeColor?: RGBA
onMouseOver?: () => void
}) {
const theme = useTheme("elevated")
const theme = useTheme().surface("dialog")
const text = createMemo(() => {
if (props.active && !props.muted) return props.activeColor ?? theme.text.action.primary.focused
if (props.muted && (props.active || props.current)) return theme.text.subdued
+6 -4
View File
@@ -1,7 +1,7 @@
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
import { batch, createContext, createEffect, onCleanup, Show, useContext, type JSX, type ParentProps } from "solid-js"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { ThemeContextProvider, useTheme } from "../context/theme"
import { InputRenderable, MouseButton, Renderable, RGBA } from "@opentui/core"
import { createStore } from "solid-js/store"
import { useToast } from "./toast"
@@ -25,12 +25,13 @@ export function Dialog(
}>,
) {
const dimensions = useTerminalDimensions()
const theme = useTheme("elevated")
const theme = useTheme().surface("dialog")
const renderer = useRenderer()
let dismiss = false
return (
<box
<ThemeContextProvider context="dialog">
<box
onMouseDown={() => {
dismiss = !!renderer.getSelection()
}}
@@ -67,7 +68,8 @@ export function Dialog(
>
{props.children}
</box>
</box>
</box>
</ThemeContextProvider>
)
}
+2 -2
View File
@@ -6,7 +6,7 @@ export function PaneResizeHandle(props: {
left: number
highlight?: "left" | "right"
}) {
const theme = useTheme("elevated")
const theme = useTheme()
return (
<box
@@ -25,7 +25,7 @@ export function PaneResizeHandle(props: {
height="100%"
marginLeft={props.highlight === "right" ? 1 : 0}
backgroundColor={
props.resize.hovered() || props.resize.resizing() ? theme.background.action.primary.hovered : undefined
props.resize.hovered() || props.resize.resizing() ? theme.background.raised.high : undefined
}
/>
</box>
+2 -2
View File
@@ -22,7 +22,7 @@ function ToastSurface(props: {
onHover?: (hovered: boolean) => void
onActivate: () => void
}) {
const theme = useTheme("overlay")
const theme = useTheme()
const dimensions = useTerminalDimensions()
const renderer = useRenderer()
const [hovered, setHovered] = createSignal(false)
@@ -66,7 +66,7 @@ function ToastSurface(props: {
paddingRight={2}
paddingTop={1}
paddingBottom={1}
backgroundColor={theme.background.default}
backgroundColor={theme.background.raised.high}
>
<Show
when={props.toast.title}
+16 -73
View File
@@ -2,8 +2,6 @@
import { testRender } from "@opentui/solid"
import { expect, test } from "bun:test"
import { RGBA } from "@opentui/core"
import { createSignal } from "solid-js"
import { DEFAULT_THEME, selectTheme } from "@opencode/theme/tui"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { DEFAULT_THEMES } from "../../../src/theme"
import { ConfigProvider } from "../../../src/config"
@@ -121,29 +119,21 @@ test.each([
}
})
test("contextual hooks resolve overrides and fall back to a standalone theme's base view", async () => {
const standalone = {
version: 2,
standalone: true,
dark: {
hue: selectTheme(DEFAULT_THEME, "dark").hue,
"@context:elevated": { text: { default: "#abcdef" } },
},
} as const
test("dialog surfaces are absolute and can be inherited through the theme context", async () => {
let themes: ReturnType<typeof useThemes> | undefined
let theme: ReturnType<typeof useTheme> | undefined
let explicit: ReturnType<typeof useTheme> | undefined
let contextual: ReturnType<typeof useTheme> | undefined
function ContextProbe() {
theme = useTheme()
explicit = useTheme("elevated")
return <text>{theme.text.default.toString()}</text>
contextual = useTheme()
return <text>{contextual.text.default.toString()}</text>
}
function Probe() {
themes = useThemes()
theme = useTheme()
return (
<ThemeContextProvider context="elevated">
<ThemeContextProvider context="dialog">
<ContextProbe />
</ThemeContextProvider>
)
@@ -151,8 +141,8 @@ test("contextual hooks resolve overrides and fall back to a standalone theme's b
const app = await testRender(
() => (
<ConfigProvider config={createTuiResolvedConfig({ theme: { name: "standalone", mode: "dark" } })}>
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({ standalone }) }}>
<ConfigProvider config={createTuiResolvedConfig({ theme: { name: "opencode", mode: "dark" } })}>
<ThemeProvider mode="dark" source={{ discover: async () => ({}) }}>
<Probe />
</ThemeProvider>
</ConfigProvider>
@@ -163,62 +153,15 @@ test("contextual hooks resolve overrides and fall back to a standalone theme's b
try {
await wait(() => themes?.ready === true)
if (!themes) throw new Error("Theme provider is not mounted")
if (!theme) throw new Error("Contextual theme is not mounted")
if (!explicit) throw new Error("Explicit contextual theme is not mounted")
expect(theme.text.default.equals(RGBA.fromHex("#abcdef"))).toBeTrue()
expect(theme.text.default).toBe(explicit.text.default)
expect(theme.text.default).toBe(themes.current.contextual.elevated.text.default)
expect(themes.current.contextual.overlay.background.default).toBe(themes.current.background.default)
if (!themes || !theme || !contextual) throw new Error("Theme provider is not mounted")
const dialog = theme.surface("dialog")
expect(theme.surface("dialog")).toBe(dialog)
expect(dialog.surface("dialog")).toBe(dialog)
expect(dialog.background.default).toBe(themes.currentTokens().background.raised.base)
expect(contextual.background.default).toBe(dialog.background.default)
expect(contextual.text.default).toBe(dialog.text.default)
expect(dialog.raise(dialog.background.raised.base)).toBe(themes.currentTokens().hue.neutral[600])
} finally {
app.renderer.destroy()
}
})
test.each(["dark", "light"] as const)(
"reactive %s theme contexts change without remounting their contents",
async (mode) => {
const [context, setContext] = createSignal<"elevated" | undefined>("elevated")
const [parent, setParent] = createSignal<"overlay" | undefined>()
let theme: ReturnType<typeof useTheme> | undefined
let themes: ReturnType<typeof useThemes> | undefined
let mounts = 0
function Probe() {
mounts++
theme = useTheme()
themes = useThemes()
return <text fg={theme.text.default}>probe</text>
}
const app = await testRender(() => (
<ConfigProvider config={createTuiResolvedConfig({ theme: { name: "opencode", mode } })}>
<ThemeProvider mode={mode} source={{ discover: async () => ({}) }}>
<ThemeContextProvider context={parent()}>
<ThemeContextProvider context={context()}>
<Probe />
</ThemeContextProvider>
</ThemeContextProvider>
</ThemeProvider>
</ConfigProvider>
))
app.renderer.start()
try {
await wait(() => themes?.ready === true)
if (!theme || !themes) throw new Error("Theme provider is not mounted")
const view = theme
expect(view.background.default).toBe(themes.current.contextual.elevated.background.default)
setContext(undefined)
await app.flush()
expect(view.background.default).toBe(themes.current.background.default)
setParent("overlay")
await app.flush()
expect(view.background.default).toBe(themes.current.contextual.overlay.background.default)
setContext("elevated")
await app.flush()
expect(view.text.default).toBe(themes.current.contextual.elevated.text.default)
expect(theme).toBe(view)
expect(mounts).toBe(1)
} finally {
app.renderer.destroy()
}
},
)
@@ -38,7 +38,7 @@ for (const orientation of ["horizontal", "vertical"] as const) {
let theme!: ReturnType<typeof useTheme>
function Colors() {
config = useConfig()
theme = orientation === "vertical" ? useTheme("elevated") : useTheme()
theme = orientation === "vertical" ? useTheme() : useTheme()
return null
}
const controller = {

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