Compare commits

..

1 Commits

Author SHA1 Message Date
LukeParkerDev a98927909b perf(desktop): install the context menu after the first window exists
electron-context-menu (with electron-dl, cli-truncate, ext-name and
friends) was imported and configured in configureApplication, before
Chromium was ready. It attaches to every existing and future window, so
loading it right after restoreWindows() gives the same menus without
its dependency tree on the startup path.

Packaged build, warm service, median of 6 launches: first main-process
log line 401 -> 381 ms after spawn.
2026-09-19 00:34:55 +10:00
114 changed files with 1241 additions and 2081 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: Schema.optional(OpenResponsesOptions.ReasoningEffort),
reasoningEffort: OpenResponsesOptions.Options.fields.reasoningEffort,
enableThinking: Schema.optional(Schema.Boolean),
thinkingBudget: Schema.optional(Schema.Int),
preserveThinking: Schema.optional(Schema.Boolean),
@@ -19,7 +19,7 @@ const Options = Schema.Struct({
}),
),
toolStream: Schema.optional(Schema.Boolean),
parallelToolCalls: Schema.optional(Schema.Boolean),
parallelToolCalls: OpenResponsesOptions.Options.fields.parallelToolCalls,
repetitionPenalty: Schema.optional(Schema.Number),
responseFormat: Schema.optional(
Schema.Struct({
@@ -6,9 +6,9 @@ import { OpenResponsesOptions } from "./utils/open-responses-options.js"
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
const Options = Schema.Struct({
reasoningEffort: Schema.optional(OpenResponsesOptions.ReasoningEffort),
reasoningEffort: OpenResponsesOptions.Options.fields.reasoningEffort,
enableThinking: Schema.optional(Schema.Boolean),
store: Schema.optional(Schema.Boolean),
store: OpenResponsesOptions.Options.fields.store,
previousResponseId: Schema.optional(Schema.String),
conversation: Schema.optional(Schema.String),
})
+167 -79
View File
@@ -1,5 +1,5 @@
import { Buffer } from "node:buffer"
import { Effect, Option, Schema, SchemaGetter } from "effect"
import { Effect, Option, Schema } from "effect"
import { Tool } from "@opencode/schema/tool"
import { Route } from "../route/client.js"
import { Auth } from "../route/auth.js"
@@ -21,12 +21,11 @@ import {
type JsonSchema,
type MediaPart,
type ProviderMetadata,
type ProviderOptions,
type ToolCallPart,
type ToolDefinition,
type ToolResultPart,
} from "../schema/index.js"
import { JsonObject, knownString, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { classifyProviderFailure } from "../provider-error.js"
import { effortUpdate, resolveEffortUpdates } from "../effort-updates.js"
import * as Cache from "./utils/cache.js"
@@ -53,10 +52,57 @@ const SSE_EVENTS = new Set([
])
export const framing = Framing.sseEvents(SSE_EVENTS)
export type ThinkingBlockBinding = typeof AnthropicThinkingBlockBinding.Type
export type ThinkingInput = typeof Thinking.Encoded
/** Caller-facing provider options; unknown keys are accepted and ignored. `Options.Type` is the wire-ready form. */
export type OptionsInput = ProviderOptions & typeof Options.Encoded
export type ThinkingBlockBinding = {
readonly prefix_mismatch_behavior?: "error" | "drop_block" | (string & {})
}
export type ThinkingInput =
| {
readonly type: "adaptive"
readonly display?: "summarized" | "omitted"
readonly block_binding?: ThinkingBlockBinding
}
| {
readonly type: "disabled"
}
| ({
readonly type: "enabled"
readonly display?: "summarized" | "omitted"
readonly block_binding?: ThinkingBlockBinding
} & (
| { readonly budgetTokens: number; readonly budget_tokens?: number }
| { readonly budgetTokens?: number; readonly budget_tokens: number }
))
export interface OptionsInput {
/** Advanced in-band compaction. The caller owns checkpoint persistence and recovery. */
readonly contextManagement?: ContextManagement
readonly [key: string]: unknown
readonly thinking?: ThinkingInput
readonly effort?: string
readonly service_tier?: "auto" | "standard_only"
readonly serviceTier?: "auto" | "standard_only"
// SDK Metadata:2649 {user_id?: string | null}
readonly metadata?: { readonly user_id?: string | null }
// SDK MessageCreateParamsContainer:2596 ContainerParams|string
readonly container?:
| string
| { readonly id?: string | null; readonly skills?: ReadonlyArray<Record<string, unknown>> | null }
readonly inference_geo?: string | null
readonly inferenceGeo?: string | null
readonly cache_control?: { readonly type: "ephemeral"; readonly ttl?: "5m" | "1h" }
readonly cacheControl?: { readonly type: "ephemeral"; readonly ttl?: "5m" | "1h" }
// SDK OutputConfig:2684 {effort, format: JSONOutputFormat}
readonly output_config?: {
readonly effort?: string | null
readonly format?: { readonly type: "json_schema"; readonly schema: Record<string, unknown> } | null
}
readonly outputConfig?: {
readonly effort?: string | null
readonly format?: { readonly type: "json_schema"; readonly schema: Record<string, unknown> } | null
}
}
export type ProviderOptionsInput = OptionsInput
export const ContextManagement = Schema.Struct({
@@ -83,7 +129,6 @@ const AnthropicCacheControl = Schema.Struct({
type: Schema.tag("ephemeral"),
ttl: Schema.optional(Schema.Literals(["5m", "1h"])),
})
const AnthropicServiceTier = Schema.Literals(["auto", "standard_only"])
const AnthropicTextBlock = Schema.Struct({
type: Schema.tag("text"),
@@ -272,21 +317,25 @@ const AnthropicToolChoice = Schema.Union([
])
const AnthropicThinkingBlockBinding = Schema.Struct({
prefix_mismatch_behavior: Schema.optional(knownString<"error" | "drop_block">()),
prefix_mismatch_behavior: Schema.optional(Schema.String),
})
const AnthropicThinkingFields = {
display: Schema.optional(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])
const AnthropicThinking = Schema.Union([
Schema.Struct({
type: Schema.tag("enabled"),
budget_tokens: Schema.Number,
display: Schema.optional(Schema.Literals(["summarized", "omitted"])),
block_binding: Schema.optional(AnthropicThinkingBlockBinding),
}),
Schema.Struct({
type: Schema.tag("adaptive"),
display: Schema.optional(Schema.Literals(["summarized", "omitted"])),
block_binding: Schema.optional(AnthropicThinkingBlockBinding),
}),
Schema.Struct({
type: Schema.tag("disabled"),
}),
])
type AnthropicThinking = typeof AnthropicThinking.Type
// SDK OutputConfig:2684 {effort?: "low"|"medium"|"high"|"xhigh"|"max"|null, format?: JSONOutputFormat:2399}
@@ -311,53 +360,6 @@ const AnthropicContainer = Schema.Union([
}),
])
// =============================================================================
// Provider Options
// =============================================================================
// Callers spell the budget as `budgetTokens` or the wire `budget_tokens`; the
// keys are disjoint per variant so the input type requires exactly one and the
// transform can narrow on it. Decoding straight to the wire block keeps the
// alias out of the rest of the file.
const ThinkingEnabledInput = Schema.Union([
Schema.Struct({ type: Schema.tag("enabled"), budgetTokens: Schema.Number, ...AnthropicThinkingFields }),
Schema.Struct({ type: Schema.tag("enabled"), budget_tokens: Schema.Number, ...AnthropicThinkingFields }),
]).pipe(
Schema.decodeTo(AnthropicThinkingEnabled, {
decode: SchemaGetter.transform((input) => ({
type: "enabled" as const,
budget_tokens: "budgetTokens" in input ? input.budgetTokens : input.budget_tokens,
display: input.display,
block_binding: input.block_binding,
})),
encode: SchemaGetter.passthrough({ strict: false }),
}),
)
const Thinking = Schema.Union([ThinkingEnabledInput, AnthropicThinkingAdaptive, AnthropicThinkingDisabled])
const OutputConfigInput = Schema.Struct({
effort: optionalNull(Schema.String),
format: optionalNull(AnthropicJsonOutputFormat),
})
// Both key spellings are accepted; `fromRequest` prefers the snake_case one.
const Options = Schema.Struct({
/** Advanced in-band compaction. The caller owns checkpoint persistence and recovery. */
contextManagement: Schema.optional(ContextManagement),
thinking: Schema.optional(Thinking),
effort: Schema.optional(Schema.String),
service_tier: Schema.optional(AnthropicServiceTier),
serviceTier: Schema.optional(AnthropicServiceTier),
metadata: Schema.optional(AnthropicMetadata),
container: Schema.optional(AnthropicContainer),
inference_geo: optionalNull(Schema.String),
inferenceGeo: optionalNull(Schema.String),
cache_control: Schema.optional(AnthropicCacheControl),
cacheControl: Schema.optional(AnthropicCacheControl),
output_config: Schema.optional(OutputConfigInput),
outputConfig: Schema.optional(OutputConfigInput),
})
const decodeOptions = ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))
const AnthropicBodyFields = {
context_management: Schema.optional(
Schema.Struct({
@@ -389,7 +391,7 @@ const AnthropicBodyFields = {
container: Schema.optional(Schema.NullOr(AnthropicContainer)),
inference_geo: Schema.optional(Schema.NullOr(Schema.String)),
metadata: Schema.optional(AnthropicMetadata),
service_tier: Schema.optional(AnthropicServiceTier),
service_tier: Schema.optional(Schema.Literals(["auto", "standard_only"])),
}
export const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields)
export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesBody>
@@ -999,6 +1001,64 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
return messages
})
const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* (request: LLMRequest) {
const input = request.providerOptions as Record<string, unknown> | undefined
const rawServiceTier =
(input as Record<string, unknown> | undefined)?.service_tier ??
(input as Record<string, unknown> | undefined)?.serviceTier
const service_tier =
rawServiceTier === "auto" || rawServiceTier === "standard_only"
? (rawServiceTier as "auto" | "standard_only")
: undefined
const rawMetadata = (input as Record<string, unknown> | undefined)?.metadata
const metadata =
ProviderShared.isRecord(rawMetadata) && (typeof rawMetadata.user_id === "string" || rawMetadata.user_id === null)
? { user_id: rawMetadata.user_id as string | null }
: undefined
const container =
typeof (input as Record<string, unknown> | undefined)?.container === "string" ||
ProviderShared.isRecord((input as Record<string, unknown> | undefined)?.container)
? ((input as Record<string, unknown>).container as
| string
| { id?: string | null; skills?: ReadonlyArray<Record<string, unknown>> | null })
: undefined
const rawInferenceGeo =
(input as Record<string, unknown> | undefined)?.inference_geo ??
(input as Record<string, unknown> | undefined)?.inferenceGeo
const inference_geo = typeof rawInferenceGeo === "string" ? rawInferenceGeo : undefined
const rawCacheControl =
(input as Record<string, unknown> | undefined)?.cache_control ??
(input as Record<string, unknown> | undefined)?.cacheControl
const cache_control =
ProviderShared.isRecord(rawCacheControl) && rawCacheControl.type === "ephemeral"
? (rawCacheControl as { type: "ephemeral"; ttl?: "5m" | "1h" })
: undefined
const rawOutputConfig =
(input as Record<string, unknown> | undefined)?.output_config ??
(input as Record<string, unknown> | undefined)?.outputConfig
const outputConfigEffort =
typeof (input as Record<string, unknown> | undefined)?.effort === "string"
? ((input as Record<string, unknown>).effort as string)
: ProviderShared.isRecord(rawOutputConfig) && typeof rawOutputConfig.effort === "string"
? (rawOutputConfig.effort as string)
: undefined
const outputConfigFormat =
ProviderShared.isRecord(rawOutputConfig) && ProviderShared.isRecord(rawOutputConfig.format)
? (rawOutputConfig.format as { type: "json_schema"; schema: Record<string, unknown> })
: undefined
const thinking = yield* resolveThinking(input?.thinking)
return {
thinking: applyThinkingBindingDefault(request.model, thinking),
effort: outputConfigEffort,
format: outputConfigFormat,
service_tier,
metadata,
container,
inference_geo,
cache_control,
}
})
// Accept gateway namespaces and Vertex suffixes without treating a snapshot date as a minor version.
const claudeVersion = (id: string) => {
const match = /(?:^|[./])claude-(?<family>[a-z]+)-(?<major>\d+)(?:[.-](?<minor>\d{1,2}))?(?:$|[-:@])/.exec(
@@ -1037,12 +1097,35 @@ const applyThinkingBindingDefault = (model: LLMRequest["model"], thinking: Anthr
}
}
const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function* (input: unknown) {
if (!ProviderShared.isRecord(input)) return undefined
if (input.type === "disabled") return { type: "disabled" as const }
if (input.type !== "adaptive" && input.type !== "enabled") return undefined
const block_binding = yield* ProviderShared.validateWith(
Schema.decodeUnknownEffect(Schema.UndefinedOr(AnthropicThinkingBlockBinding)),
)(input.block_binding)
const display =
input.display === "summarized" || input.display === "omitted"
? (input.display as "summarized" | "omitted")
: undefined
if (input.type === "adaptive") return { type: "adaptive" as const, display, block_binding }
const budget =
typeof input.budgetTokens === "number"
? input.budgetTokens
: typeof input.budget_tokens === "number"
? input.budget_tokens
: undefined
if (budget === undefined)
return yield* ProviderShared.invalidRequest("Anthropic thinking provider option requires budgetTokens")
return { type: "enabled" as const, budget_tokens: budget, display, block_binding }
})
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
const options = yield* decodeOptions(request.providerOptions ?? {})
const management = options.contextManagement
const outputConfig = options.output_config ?? options.outputConfig
const format = outputConfig?.format ?? undefined
const updates = resolveEffortUpdates(request, options.effort ?? outputConfig?.effort ?? undefined)
const management = yield* ProviderShared.validateWith(
Schema.decodeUnknownEffect(Schema.UndefinedOr(ContextManagement)),
)(request.providerOptions?.contextManagement)
const options = yield* resolveOptions(request)
const updates = resolveEffortUpdates(request, options.effort)
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
// Allocate the 4-breakpoint budget in invalidation order: tools → system →
@@ -1078,7 +1161,12 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
)
}
const output_config =
updates.effort === undefined && format === undefined ? undefined : { effort: updates.effort, format }
updates.effort === undefined && options.format === undefined
? undefined
: {
...(updates.effort === undefined ? {} : { effort: updates.effort }),
...(options.format === undefined ? {} : { format: options.format }),
}
const body = {
model: request.model.id,
system,
@@ -1091,14 +1179,14 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
top_p: generation?.topP,
top_k: generation?.topK,
stop_sequences: generation?.stop,
thinking: applyThinkingBindingDefault(request.model, options.thinking),
thinking: options.thinking,
output_config,
// top-level passthrough per SDK MessageCreateParamsBase:4638,4643,4649,4654,4670
cache_control: options.cache_control ?? options.cacheControl,
cache_control: options.cache_control,
container: options.container,
inference_geo: options.inference_geo ?? options.inferenceGeo ?? undefined,
inference_geo: options.inference_geo,
metadata: options.metadata,
service_tier: options.service_tier ?? options.serviceTier,
service_tier: options.service_tier,
}
if (!management) return body
return {
+68 -45
View File
@@ -14,13 +14,12 @@ import {
type LLMRequest,
type MediaPart,
type ProviderMetadata,
type ProviderOptions,
type TextPart,
type ToolCallPart,
type ToolDefinition,
} from "../schema/index.js"
import { classifyProviderFailure } from "../provider-error.js"
import { JsonObject, knownString, lenient, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { GeminiToolSchema } from "./utils/gemini-tool-schema.js"
import { Lifecycle } from "./utils/lifecycle.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
@@ -51,8 +50,35 @@ const omitsFunctionCallIds = (modelID: string) => {
return match !== null && Number(match[1]) < 3
}
/** Caller-facing provider options; unknown keys are accepted and ignored. */
export type OptionsInput = ProviderOptions & typeof Options.Encoded
export interface OptionsInput {
readonly [key: string]: unknown
readonly cachedContent?: string
readonly safetySettings?: ReadonlyArray<{
readonly category:
| "HARM_CATEGORY_UNSPECIFIED"
| "HARM_CATEGORY_HATE_SPEECH"
| "HARM_CATEGORY_DANGEROUS_CONTENT"
| "HARM_CATEGORY_HARASSMENT"
| "HARM_CATEGORY_SEXUALLY_EXPLICIT"
| "HARM_CATEGORY_CIVIC_INTEGRITY"
| (string & {})
readonly threshold:
| "HARM_BLOCK_THRESHOLD_UNSPECIFIED"
| "BLOCK_LOW_AND_ABOVE"
| "BLOCK_MEDIUM_AND_ABOVE"
| "BLOCK_ONLY_HIGH"
| "BLOCK_NONE"
| "OFF"
| (string & {})
}>
readonly serviceTier?: "standard" | "flex" | "priority" | (string & {})
readonly thinkingConfig?: {
readonly thinkingBudget?: number
readonly includeThoughts?: boolean
readonly thinkingLevel?: "minimal" | "low" | "medium" | "high" | (string & {})
}
}
export type ProviderOptionsInput = OptionsInput
// =============================================================================
@@ -135,50 +161,17 @@ const GeminiToolConfig = Schema.Struct({
}),
})
const GeminiThinkingLevel = knownString<"minimal" | "low" | "medium" | "high">()
const GeminiThinkingConfig = Schema.Struct({
thinkingBudget: Schema.optional(Schema.Number),
includeThoughts: Schema.optional(Schema.Boolean),
thinkingLevel: Schema.optional(GeminiThinkingLevel),
thinkingLevel: Schema.optional(Schema.String),
})
const GeminiSafetySetting = Schema.Struct({
category: knownString<
| "HARM_CATEGORY_UNSPECIFIED"
| "HARM_CATEGORY_HATE_SPEECH"
| "HARM_CATEGORY_DANGEROUS_CONTENT"
| "HARM_CATEGORY_HARASSMENT"
| "HARM_CATEGORY_SEXUALLY_EXPLICIT"
| "HARM_CATEGORY_CIVIC_INTEGRITY"
>(),
threshold: knownString<
| "HARM_BLOCK_THRESHOLD_UNSPECIFIED"
| "BLOCK_LOW_AND_ABOVE"
| "BLOCK_MEDIUM_AND_ABOVE"
| "BLOCK_ONLY_HIGH"
| "BLOCK_NONE"
| "OFF"
>(),
category: Schema.String,
threshold: Schema.String,
})
// =============================================================================
// Provider Options
// =============================================================================
// Malformed fields are dropped rather than failing the request; a `thinkingConfig`
// object that omits `includeThoughts` asks for thoughts.
const GeminiThinkingConfigInput = Schema.Struct({
thinkingBudget: lenient(Schema.Number),
includeThoughts: lenient(Schema.Boolean),
thinkingLevel: lenient(GeminiThinkingLevel),
})
const Options = Schema.Struct({
cachedContent: lenient(Schema.String),
safetySettings: lenient(Schema.Array(GeminiSafetySetting)),
serviceTier: lenient(knownString<"standard" | "flex" | "priority">()),
thinkingConfig: lenient(GeminiThinkingConfigInput),
})
const decodeOptions = ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))
const GeminiGenerationConfig = Schema.Struct({
maxOutputTokens: Schema.optional(Schema.Number),
temperature: Schema.optional(Schema.Number),
@@ -438,11 +431,44 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
return contents
})
const resolveOptions = (request: LLMRequest) => {
const input = request.providerOptions
const value = input?.thinkingConfig
const thinkingConfig = {
thinkingBudget:
ProviderShared.isRecord(value) && typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined,
includeThoughts:
ProviderShared.isRecord(value) && typeof value.includeThoughts === "boolean"
? value.includeThoughts
: ProviderShared.isRecord(value)
? true
: undefined,
thinkingLevel:
ProviderShared.isRecord(value) && typeof value.thinkingLevel === "string" ? value.thinkingLevel : undefined,
}
return {
cachedContent: typeof input?.cachedContent === "string" ? input.cachedContent : undefined,
safetySettings: mapSafetySettings(input?.safetySettings),
serviceTier: typeof input?.serviceTier === "string" ? input.serviceTier : undefined,
thinkingConfig: Object.values(thinkingConfig).some((item) => item !== undefined) ? thinkingConfig : undefined,
}
}
function mapSafetySettings(value: unknown) {
if (!Array.isArray(value)) return undefined
const settings = value.flatMap((item) =>
ProviderShared.isRecord(item) && typeof item.category === "string" && typeof item.threshold === "string"
? [{ category: item.category, threshold: item.threshold }]
: [],
)
return settings
}
const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) {
const flattened = ProviderShared.flattenToolRequest(request)
const hasTools = flattened.tools.length > 0
const generation = request.generation
const options = yield* decodeOptions(request.providerOptions ?? {})
const options = resolveOptions(request)
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const generationConfig = {
maxOutputTokens: generation?.maxTokens,
@@ -453,10 +479,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
presencePenalty: generation?.presencePenalty,
seed: generation?.seed,
stopSequences: generation?.stop,
thinkingConfig:
options.thinkingConfig === undefined
? undefined
: { ...options.thinkingConfig, includeThoughts: options.thinkingConfig.includeThoughts ?? true },
thinkingConfig: options.thinkingConfig,
}
return {
+1 -11
View File
@@ -1,6 +1,6 @@
import { Buffer } from "node:buffer"
import { Tool } from "@opencode/schema/tool"
import { Effect, Option, Schema, Stream } from "effect"
import { Effect, Schema, Stream } from "effect"
import * as Sse from "effect/unstable/encoding/Sse"
import { Headers, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import {
@@ -29,16 +29,6 @@ const isJson = Schema.is(Schema.Json)
export const JsonObject = Schema.Record(Schema.String, Schema.Unknown)
export const optionalArray = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.Array(schema))
export const optionalNull = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.NullOr(schema))
/** Optional field whose malformed value decodes to `undefined` instead of failing the enclosing struct. */
export const lenient = <const S extends Schema.Top>(schema: S) =>
Schema.optionalKey(
Schema.UndefinedOr(schema).pipe(Schema.catchDecoding(() => Effect.succeed(Option.some(undefined)))),
)
/** Provider-defined string enum: known values for autocomplete, any string accepted at runtime. */
export const knownString = <Known extends string>() =>
Schema.declare<Known | (string & {})>((value): value is Known | (string & {}) => typeof value === "string", {
expected: "string",
})
export const OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH = 64
@@ -1,6 +1,5 @@
import { Schema } from "effect"
import { Option, Schema } from "effect"
import { ReasoningEffort, ReasoningEfforts, type LLMRequest } from "../../schema/index.js"
import { lenient } from "../shared.js"
export { ReasoningEffort, ReasoningEfforts }
@@ -50,22 +49,21 @@ export const StreamOptions = Schema.Struct({
includeObfuscation: Schema.optional(Schema.Boolean),
})
// Malformed options are dropped one at a time so a bad `topLogprobs` cannot discard `store` or `reasoningEffort`.
export const Options = Schema.Struct({
store: lenient(Schema.Boolean),
metadata: lenient(Schema.Record(Schema.String, Schema.String)),
safetyIdentifier: lenient(Schema.String),
streamOptions: lenient(StreamOptions),
topLogprobs: lenient(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 20 }))),
reasoningEffort: lenient(ReasoningEffort),
reasoningSummary: lenient(Schema.Literals(["auto", "concise", "detailed"])),
include: lenient(Schema.Array(ResponseIncludableSchema)),
textVerbosity: lenient(TextVerbositySchema),
serviceTier: lenient(ServiceTierSchema),
truncation: lenient(TruncationSchema),
allowedTools: lenient(AllowedTools),
maxToolCalls: lenient(Schema.Int),
parallelToolCalls: lenient(Schema.Boolean),
store: Schema.optional(Schema.Boolean),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
safetyIdentifier: Schema.optional(Schema.String),
streamOptions: Schema.optional(StreamOptions),
topLogprobs: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 20 }))),
reasoningEffort: Schema.optional(ReasoningEffort),
reasoningSummary: Schema.optional(Schema.Literals(["auto", "concise", "detailed"])),
include: Schema.optional(Schema.Array(ResponseIncludableSchema)),
textVerbosity: Schema.optional(TextVerbositySchema),
serviceTier: Schema.optional(ServiceTierSchema),
truncation: Schema.optional(TruncationSchema),
allowedTools: Schema.optional(AllowedTools),
maxToolCalls: Schema.optional(Schema.Int),
parallelToolCalls: Schema.optional(Schema.Boolean),
})
export type Options = typeof Options.Type
@@ -73,10 +71,11 @@ export type Resolved = Omit<Options, "allowedTools"> & {
readonly allowedTools?: AllowedTools & { readonly mode: NonNullable<AllowedTools["mode"]> }
}
const decodeOptions = Schema.decodeUnknownSync(Options)
const decodeOptions = Schema.decodeUnknownOption(Options)
export const resolve = (request: LLMRequest): Resolved => {
const input = decodeOptions(request.providerOptions ?? {})
const input = Option.getOrUndefined(decodeOptions(request.providerOptions))
if (!input) return {}
return {
...input,
include: input.include?.length ? input.include : undefined,
@@ -166,71 +166,7 @@ describe("Anthropic Messages route", () => {
}),
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
expect(error.message).toContain("budgetTokens")
}),
)
it.effect("lowers passthrough provider options and accepts either key spelling", () =>
Effect.gen(function* () {
const snake = yield* compileRequest(
LLMRequest.update(request, {
providerOptions: {
service_tier: "auto",
metadata: { user_id: "user_1" },
container: { id: "container_1" },
inference_geo: "us",
cache_control: { type: "ephemeral", ttl: "1h" },
output_config: { format: { type: "json_schema", schema: { type: "object" } } },
},
}),
)
const camel = yield* compileRequest(
LLMRequest.update(request, {
providerOptions: {
serviceTier: "standard_only",
container: "container_2",
inferenceGeo: "eu",
cacheControl: { type: "ephemeral" },
outputConfig: { effort: "low" },
},
}),
)
expect(snake.body).toMatchObject({
service_tier: "auto",
metadata: { user_id: "user_1" },
container: { id: "container_1" },
inference_geo: "us",
cache_control: { type: "ephemeral", ttl: "1h" },
output_config: { format: { type: "json_schema", schema: { type: "object" } } },
})
expect(camel.body).toMatchObject({
service_tier: "standard_only",
container: "container_2",
inference_geo: "eu",
cache_control: { type: "ephemeral" },
output_config: { effort: "low" },
})
}),
)
it.effect("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"))
expect(error.message).toContain("Anthropic thinking provider option requires budgetTokens")
}),
)
@@ -248,21 +248,6 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("keeps valid Chat options when a sibling option is malformed", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).chat("gpt-4o-mini"),
prompt: "think",
providerOptions: { store: true, reasoningEffort: "max", topLogprobs: 25 },
}),
)
expect(prepared.body.store).toBe(true)
expect(prepared.body.reasoning_effort).toBe("max")
}),
)
it.effect("maps the request prompt cache key when the compatibility flag is set", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -1945,30 +1945,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("drops a malformed provider option without discarding its siblings", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
prompt: "hi",
providerOptions: {
topLogprobs: 25,
metadata: { tenant: 7 },
reasoningEffort: "high",
serviceTier: "priority",
maxToolCalls: 4,
},
}),
)
expect(prepared.body.top_logprobs).toBeUndefined()
expect(prepared.body.metadata).toBeUndefined()
expect(prepared.body.reasoning).toEqual({ effort: "high" })
expect(prepared.body.service_tier).toBe("priority")
expect(prepared.body.max_tool_calls).toBe(4)
}),
)
it.effect("accepts the full ResponseIncludable union", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
+1 -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().surface("dialog")
const theme = useTheme("elevated")
const focus = renderer.currentFocusedRenderable
onCleanup(Keymap.use().mode.push("modal"))
Keymap.createLayer(() => ({
+61 -65
View File
@@ -8,9 +8,7 @@ export type LocationPublicRef = { directory: string }
export type ModelRef = { id: string; providerID: string; variant?: string }
export type ProviderCompaction = { type: "summary" } | { type: "native" }
export type ProviderTransport = "http" | "websocket"
export type ProviderSettings = { [x: string]: any }
export type AgentColor = string
@@ -220,8 +218,19 @@ export type ModelReasoningField = "reasoning" | "reasoning_content" | "reasoning
export type ModelMaxTokensField = "max_completion_tokens" | "max_tokens"
export type ProviderCompaction = { mode: "local" } | { mode: "provider"; threshold?: number }
export type ProviderTransport = "http" | "websocket"
export type ModelCapabilities = { tools: boolean; input: Array<string>; output: Array<string> }
export type ModelVariant = {
id: string
settings?: { [x: string]: any }
headers?: { [x: string]: string }
body?: { [x: string]: any }
}
export type MoneyUSDPerMillionTokens = number
export type GenerateTextResponse = { data: { text: string } }
@@ -456,19 +465,11 @@ export type V2EventServerConnected = {
data: {}
}
export type ProviderSettings = {
timeout?: number | false
chunkTimeout?: number
compaction?: ProviderCompaction
transport?: ProviderTransport
} & { [x: string]: any }
export type ConfigProviderSettings = {
timeout?: number | false
chunkTimeout?: number
compaction?: ProviderCompaction
transport?: ProviderTransport
} & { [x: string]: JsonValue | null }
export type ProviderRequest = {
settings: ProviderSettings
headers: { [x: string]: string }
body: { [x: string]: any }
}
export type PermissionRule = { action: string; resource: string; effect: PermissionEffect }
@@ -1442,6 +1443,20 @@ export type ModelCompatibility = {
supportsPromptCacheKey?: boolean
}
export type ProviderInfo = {
id: string
canonical?: string
integrationID?: string
name: string
activation: "auto" | "enabled" | "disabled"
package: string
compaction?: ProviderCompaction
transport?: ProviderTransport
settings?: { [x: string]: any }
headers?: { [x: string]: string }
body?: { [x: string]: any }
}
export type ModelCost = {
tier?: { type: "context"; size: number }
input: MoneyUSDPerMillionTokens
@@ -1656,31 +1671,6 @@ export type SessionInboxMove = {
payload: SessionInboxMovePayload
}
export type ProviderRequest = {
settings: ProviderSettings
headers: { [x: string]: string }
body: { [x: string]: any }
}
export type ModelVariant = {
id: string
settings?: ProviderSettings
headers?: { [x: string]: string }
body?: { [x: string]: any }
}
export type ProviderInfo = {
id: string
canonical?: string
integrationID?: string
name: string
activation: "auto" | "enabled" | "disabled"
package: string
settings?: ProviderSettings
headers?: { [x: string]: string }
body?: { [x: string]: any }
}
export type PermissionRuleset = Array<PermissionRule>
export type SessionRevertStaged = {
@@ -1864,6 +1854,29 @@ export type FormField =
export type FormState = { status: "pending" } | { status: "answered"; answer: FormAnswer } | { status: "cancelled" }
export type ModelInfo = {
id: string
modelID: string
providerID: string
canonical?: string
family?: string
name: string
compatibility?: ModelCompatibility
package?: string
compaction?: ProviderCompaction
transport?: ProviderTransport
settings?: { [x: string]: any }
headers?: { [x: string]: string }
body?: { [x: string]: any }
capabilities: ModelCapabilities
variants: Array<ModelVariant>
time: { released: number }
cost: Array<ModelCost>
status: "alpha" | "beta" | "deprecated" | "active"
enabled: boolean
limit: { context: number; input?: number; output: number }
}
export type FormField1 =
| FormStringField1
| FormNumberField1
@@ -1889,27 +1902,6 @@ export type ReferenceInfo = {
source: ReferenceSource
}
export type ModelInfo = {
id: string
modelID: string
providerID: string
canonical?: string
family?: string
name: string
compatibility?: ModelCompatibility
package?: string
settings?: ProviderSettings
headers?: { [x: string]: string }
body?: { [x: string]: any }
capabilities: ModelCapabilities
variants: Array<ModelVariant>
time: { released: number }
cost: Array<ModelCost>
status: "alpha" | "beta" | "deprecated" | "active"
enabled: boolean
limit: { context: number; input?: number; output: number }
}
export type AgentInfo = {
id: string
name: string
@@ -2093,27 +2085,31 @@ export type ConfigEntry =
warming?: boolean | { prompt?: string; interval?: string; duration?: string }
providers?: {
[x: string]: {
compaction?: ProviderCompaction
transport?: ProviderTransport
canonical?: string
name?: string
env?: Array<string>
package?: string
settings?: ConfigProviderSettings
settings?: { [x: string]: JsonValue }
headers?: { [x: string]: string }
body?: { [x: string]: JsonValue }
models?: {
[x: string]: {
compaction?: ProviderCompaction
transport?: ProviderTransport
modelID?: string
family?: string
name?: string
compatibility?: ModelCompatibility
package?: string
settings?: ConfigProviderSettings
settings?: { [x: string]: JsonValue }
headers?: { [x: string]: string }
body?: { [x: string]: JsonValue }
capabilities?: ModelCapabilities
variants?: Array<{
id: string
settings?: ConfigProviderSettings
settings?: { [x: string]: JsonValue }
headers?: { [x: string]: string }
body?: { [x: string]: JsonValue }
}>
+1 -6
View File
@@ -128,8 +128,6 @@ function prepareOptions(model: Info, pkg: string) {
const customFetch = options.fetch
const chunkTimeout = options.chunkTimeout
delete options.chunkTimeout
delete options.compaction
delete options.transport
options.fetch = async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
const opts = { ...(init ?? {}) }
const signals = [
@@ -390,10 +388,7 @@ function requestSettings(settings: Readonly<Record<string, unknown>> | undefined
if (settings === undefined) return undefined
const result = Object.fromEntries(
Object.entries(settings).filter(
([key]) =>
!["apiKey", "authToken", "baseURL", "chunkTimeout", "compaction", "fetch", "timeout", "transport"].includes(
key,
),
([key]) => !["apiKey", "authToken", "baseURL", "chunkTimeout", "fetch", "timeout"].includes(key),
),
)
return Object.keys(result).length === 0 ? undefined : result
@@ -68,6 +68,8 @@ export const Plugin = define({
if (item.canonical !== undefined) provider.canonical = item.canonical
if (item.name !== undefined) provider.name = item.name
if (item.package !== undefined) provider.package = item.package
if (item.compaction !== undefined) provider.compaction = { ...item.compaction }
if (item.transport !== undefined) provider.transport = item.transport
if (item.settings !== undefined) provider.settings = Provider.mergeOverlay(provider.settings, item.settings)
if (item.headers !== undefined) provider.headers = Provider.mergeHeaders(provider.headers, item.headers)
if (item.body !== undefined) provider.body = Provider.mergeOverlay(provider.body, item.body)
@@ -114,6 +116,8 @@ export const Plugin = define({
if (config.compatibility !== undefined)
model.compatibility = { ...model.compatibility, ...config.compatibility }
if (config.package !== undefined) model.package = config.package
if (config.compaction !== undefined) model.compaction = { ...config.compaction }
if (config.transport !== undefined) model.transport = config.transport
if (config.settings !== undefined) model.settings = Provider.mergeOverlay(model.settings, config.settings)
if (config.headers !== undefined) model.headers = Provider.mergeHeaders(model.headers, config.headers)
if (config.body !== undefined) model.body = Provider.mergeOverlay(model.body, config.body)
+14 -61
View File
@@ -36,7 +36,6 @@ export type Status = Background["status"]
const decodeBackground = Schema.decodeUnknownResult(Background)
const backgroundPrefix = "job.background/"
const COMPLETED_LIMIT = 25
export type Info = {
id: string
@@ -58,7 +57,6 @@ type Active = {
scope: Scope.Closeable
blockingSessions: Map<SessionSchema.ID, number>
isBackgrounded: boolean
consumed: boolean
recovery?: Recovery
}
@@ -71,7 +69,6 @@ type FinishResult = {
info?: Info
done?: Deferred.Deferred<Info>
scope?: Scope.Closeable
generation?: Scope.Closeable
}
type BackgroundResult = {
@@ -84,12 +81,11 @@ type StartResult = { info: Info } | { info: Info; scope: Scope.Closeable }
type BlockWait = {
done: Deferred.Deferred<Info>
backgrounded: Deferred.Deferred<Info>
generation: Scope.Closeable
}
type BlockStart =
| { type: "missing" }
| { type: "finished"; info: Info; generation: Scope.Closeable }
| { type: "finished"; info: Info }
| { type: "backgrounded"; info: Info }
| { type: "wait"; wait: BlockWait }
@@ -172,9 +168,6 @@ function decrementSession(input: Map<SessionSchema.ID, number>, sessionID: Sessi
/**
* Makes one scoped, process-local registry. Explicitly recoverable background
* work also owns a durable notification marker until its notification is admitted.
* Unconsumed results survive the start-to-wait handoff. Foreground block/cancel
* and non-recoverable wait results enter a 25-entry consumed history. Recoverable
* wait results stay available for background registration and acknowledgment.
*/
export const make = Effect.gen(function* () {
const kv = yield* KV.Service
@@ -183,19 +176,6 @@ export const make = Effect.gen(function* () {
scope: yield* Scope.Scope,
}
const consume = (id: string, generation: Scope.Closeable) =>
SynchronizedRef.update(state.jobs, (jobs) => {
const job = jobs.get(id)
if (!job || job.scope !== generation || job.info.status === "running" || job.consumed) return jobs
const next = new Map(jobs)
// Order history by first consumption, not by start time or subsequent reads.
next.delete(id)
next.set(id, { ...job, consumed: true })
const completed = [...next].filter(([, job]) => job.consumed && !job.info.notificationID)
for (const [id] of completed.slice(0, -COMPLETED_LIMIT)) next.delete(id)
return next
})
const persistBackground = Effect.fnUntraced(function* (job: Active) {
if (!job.recovery || !job.info.notificationID) return
yield* kv.set(`${backgroundPrefix}${job.info.notificationID}`, {
@@ -280,7 +260,6 @@ export const make = Effect.gen(function* () {
scope,
blockingSessions: new Map<SessionSchema.ID, number>(),
isBackgrounded: false,
consumed: false,
recovery: input.recovery,
}
return [{ info: snapshot(job), scope }, new Map(jobs).set(id, job)]
@@ -301,19 +280,12 @@ export const make = Effect.gen(function* () {
const wait: Interface["wait"] = Effect.fn("Job.wait")(function* (input) {
const job = (yield* SynchronizedRef.get(state.jobs)).get(input.id)
if (!job) return { timedOut: false }
return yield* Effect.gen(function* () {
if (job.info.status !== "running") return { info: snapshot(job), timedOut: false }
if (input.timeout === undefined) return { info: yield* Deferred.await(job.done), timedOut: false }
if (input.timeout <= 0) return { info: snapshot(job), timedOut: true }
const info = yield* Deferred.await(job.done).pipe(Effect.timeoutOption(input.timeout))
if (info._tag === "Some") return { info: info.value, timedOut: false }
return { info: snapshot(job), timedOut: true }
}).pipe(
// Recoverable wait -> background is a supported handoff, even after failure.
Effect.tap((result) =>
result.info.status === "running" || job.recovery ? Effect.void : consume(input.id, job.scope),
),
)
if (job.info.status !== "running") return { info: snapshot(job), timedOut: false }
if (input.timeout === undefined) return { info: yield* Deferred.await(job.done), timedOut: false }
if (input.timeout <= 0) return { info: snapshot(job), timedOut: true }
const info = yield* Deferred.await(job.done).pipe(Effect.timeoutOption(input.timeout))
if (info._tag === "Some") return { info: info.value, timedOut: false }
return { info: snapshot(job), timedOut: true }
})
const removeBlock = Effect.fnUntraced(function* (input: BlockInput) {
@@ -331,10 +303,10 @@ export const make = Effect.gen(function* () {
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [BlockStart, Map<string, Active>] => {
const job = jobs.get(input.id)
if (!job) return [{ type: "missing" }, jobs]
if (job.info.status !== "running") return [{ type: "finished", info: snapshot(job), generation: job.scope }, jobs]
if (job.info.status !== "running") return [{ type: "finished", info: snapshot(job) }, jobs]
if (job.isBackgrounded) return [{ type: "backgrounded", info: snapshot(job) }, jobs]
return [
{ type: "wait", wait: { done: job.done, backgrounded: job.backgrounded, generation: job.scope } },
{ type: "wait", wait: { done: job.done, backgrounded: job.backgrounded } },
new Map(jobs).set(input.id, {
...job,
blockingSessions: incrementSession(job.blockingSessions, input.sessionID),
@@ -342,18 +314,12 @@ export const make = Effect.gen(function* () {
]
})
if (result.type === "missing") return undefined
if (result.type === "finished") {
yield* consume(input.id, result.generation)
return { type: "finished", info: result.info }
}
if (result.type === "finished") return { type: "finished", info: result.info }
if (result.type === "backgrounded") return { type: "backgrounded", info: result.info }
return yield* Effect.raceFirst(
Deferred.await(result.wait.done).pipe(Effect.map((info) => ({ type: "finished" as const, info }))),
Deferred.await(result.wait.backgrounded).pipe(Effect.map((info) => ({ type: "backgrounded" as const, info }))),
).pipe(
Effect.tap((outcome) => (outcome.type === "finished" ? consume(input.id, result.wait.generation) : Effect.void)),
Effect.ensuring(removeBlock(input)),
)
).pipe(Effect.ensuring(removeBlock(input)))
})
const markBackground = Effect.fnUntraced(function* (job: Active) {
@@ -417,7 +383,7 @@ export const make = Effect.gen(function* () {
Effect.fnUntraced(function* (jobs): Effect.fn.Return<readonly [FinishResult, Map<string, Active>]> {
const job = jobs.get(id)
if (!job) return [{}, jobs]
if (job.info.status !== "running") return [{ info: snapshot(job), generation: job.scope }, jobs]
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
const next = {
...job,
blockingSessions: new Map<SessionSchema.ID, number>(),
@@ -428,15 +394,11 @@ export const make = Effect.gen(function* () {
},
}
yield* persistBackground(next)
return [
{ info: snapshot(next), done: job.done, scope: job.scope, generation: job.scope },
new Map(jobs).set(id, next),
]
return [{ info: snapshot(next), done: job.done, scope: job.scope }, new Map(jobs).set(id, next)]
}),
)
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info)
if (result.scope) yield* Scope.close(result.scope, Exit.void)
if (result.generation) yield* consume(id, result.generation)
return result.info
})
@@ -452,16 +414,7 @@ export const make = Effect.gen(function* () {
}).pipe(Effect.withSpan("Job.pendingBackground"))
const completeBackground: Interface["completeBackground"] = Effect.fn("Job.completeBackground")((notificationID) =>
SynchronizedRef.updateEffect(state.jobs, (jobs) =>
Effect.gen(function* () {
yield* kv.remove(`${backgroundPrefix}${notificationID}`)
const entry = [...jobs].find(([, job]) => job.info.notificationID === notificationID)
if (!entry || entry[1].info.status === "running") return jobs
const next = new Map(jobs)
next.delete(entry[0])
return next
}),
),
kv.remove(`${backgroundPrefix}${notificationID}`),
)
return Service.of({
+5 -9
View File
@@ -118,9 +118,9 @@ export interface Resolved {
/** Catalog token limits used by Core for context management. */
readonly limit: Info["limit"]
/** Model policy overrides the provider policy; omitted means local compaction. */
readonly compaction?: Provider.Compaction
readonly compaction?: Info["compaction"]
/** Model transport overrides the provider transport; omitted means HTTP. */
readonly transport?: Provider.Transport
readonly transport?: Info["transport"]
}
export interface Interface {
@@ -178,11 +178,7 @@ export const fromCatalogModel = (
Effect.flatMap((resolved) => validateProviderVariables(model, resolved)),
Effect.flatMap((resolved) => {
// Reject provider compaction policies up front so the misconfiguration surfaces before any step runs.
if (
model.settings?.compaction?.type !== "native" ||
resolved.route.compact?.trigger ||
resolved.route.compact?.endpoint
)
if (model.compaction?.mode !== "provider" || resolved.route.compact?.trigger || resolved.route.compact?.endpoint)
return Effect.succeed(resolved)
return Effect.fail(
new UnsupportedCompactionError({ providerID: model.providerID, modelID: model.id, route: resolved.route.id }),
@@ -381,8 +377,8 @@ export const layer = Layer.effect(
capabilities: selected.capabilities,
cost: selected.cost,
limit: selected.limit,
compaction: runtimeInfo.settings?.compaction,
transport: runtimeInfo.settings?.transport,
compaction: selected.compaction,
transport: selected.transport,
}
})
return Service.of({
+2
View File
@@ -188,6 +188,8 @@ const layer = Layer.effect(
...model,
...(provider?.canonical === undefined ? {} : { canonical: provider.canonical }),
package: model.package ?? provider?.package,
compaction: model.compaction ?? provider?.compaction,
transport: model.transport ?? provider?.transport,
settings: Provider.mergeOverlay(provider?.settings, model.settings),
headers: Provider.mergeHeaders(provider?.headers, model.headers),
body: Provider.mergeOverlay(provider?.body, model.body),
-2
View File
@@ -95,7 +95,6 @@ import { ProviderPlugins } from "./provider.js"
import { WebSearchPlugins } from "./websearch/index.js"
import { SkillPlugin } from "./skill.js"
import { VcsHgPlugin } from "./vcs/hg.js"
import { ToolInputRepairPlugin } from "./tool-input-repair.js"
import { OptimizePlugin } from "./optimize.js"
import { VcsGitPlugin } from "./vcs/git.js"
import { WarmingPlugin } from "./warming.js"
@@ -206,7 +205,6 @@ export const requirements = LayerNode.group([
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
const pre = [
ToolInputRepairPlugin.Plugin,
ConfigWorktreePlugin.Plugin,
BrowserPlugin,
ConfigMcpPlugin.Plugin,
+1 -3
View File
@@ -168,9 +168,7 @@ export const AzurePlugin = define({
resolveResourceName(draft.settings, resourceName) ?? resourceName,
)
if (responsesWebSocketCapable(item.provider, draft))
draft.settings = Provider.mergeOverlay(draft.settings, {
transport: item.provider.settings?.transport ?? "websocket",
})
draft.transport = item.provider.transport ?? "websocket"
})
}
}
+1 -3
View File
@@ -270,9 +270,7 @@ export const OpenAIPlugin = define({
// ChatGPT-plan tokens only authorize codex-eligible models, and the
// subscription covers usage, so hide the rest and zero the cost.
models.update(model.providerID, model.id, (draft) => {
draft.settings = Provider.mergeOverlay(draft.settings, {
transport: models.provider.get(model.providerID)?.provider.settings?.transport ?? "websocket",
})
draft.transport = models.provider.get(model.providerID)?.provider.transport ?? "websocket"
if (!chatgpt) return
if (Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(draft.body?.reasoning)) {
draft.enabled = false
+1 -3
View File
@@ -98,9 +98,7 @@ export const XAIPlugin = define({
yield* ctx.model.transform((models) => {
for (const model of models.list(providerID)) {
models.update(providerID, model.id, (draft) => {
draft.settings = Provider.mergeOverlay(draft.settings, {
transport: models.provider.get(providerID)?.provider.settings?.transport ?? "websocket",
})
draft.transport = models.provider.get(providerID)?.provider.transport ?? "websocket"
})
}
})
@@ -1,177 +0,0 @@
export * as ToolInputRepairPlugin from "./tool-input-repair.js"
import { define } from "@opencode/plugin/effect/plugin"
import type { ToolEditor } from "@opencode/plugin/effect/tool"
import { Effect, JsonSchema, Option, Predicate, Schema } from "effect"
import { definition } from "../tool/runtime.js"
// Repairs apply only when the input schema unambiguously supports them:
// - Stringified root or nested object: '{"limit":"20"}' -> { limit: 20 }
// - Closed object: { limit: "20", extra: true } -> { limit: 20 }
// - Optional null or empty-object placeholder: { limit: null } -> {}
// - Numeric or boolean string: { limit: "20", enabled: "false" } -> { limit: 20, enabled: false }
// - Nullable field: { count: "2" } -> { count: 2 }
// - Stringified array or compatible item: { tags: '["a"]', count: "2" } -> { tags: ["a"], count: [2] }
// - Positional tuple: { pair: ["2", "false"] } -> { pair: [2, false] }
// - Typed dictionary: { counts: { first: "2" } } -> { counts: { first: 2 } }
// - Nested fields and local references: { items: [{ count: "2" }] } -> { items: [{ count: 2 }] }
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
const maxDepth = 6
export const Plugin = define({
id: "opencode.tool.input.repair",
effect: Effect.fn(function* (ctx) {
let get: ToolEditor["get"] = () => undefined
yield* ctx.tool.transform((draft) => {
// The draft sees later tool transforms too; reload replaces this lookup.
get = draft.get
})
yield* ctx.tool.hook("execute.before", (event) =>
Effect.sync(() => {
// The outer Code Mode tool is built per snapshot rather than registered, so it cannot be
// looked up here. Its `{ code }` input is trivial; the tools it calls are repaired normally.
if (event.tool === "execute") return
const tool = get(event.tool)
if (!tool) return
const schema = definition(tool).inputSchema
if (schema.type !== "object") return
event.input = repair(event.input, schema, schema, 0)
}),
)
}),
})
function repair(value: unknown, schema: JsonSchema.JsonSchema, root: JsonSchema.JsonSchema, depth: number): unknown {
if (depth > maxDepth) return value
if (typeof schema.$ref === "string") {
const definitions = /^#\/\$defs\/[^/]+$/.test(schema.$ref)
? root.$defs
: /^#\/definitions\/[^/]+$/.test(schema.$ref)
? root.definitions
: undefined
if (!Predicate.isObject(definitions)) return value
const target = Object.fromEntries(
Object.entries(definitions).filter((entry): entry is [string, JsonSchema.JsonSchema] =>
Predicate.isObject(entry[1]),
),
)[
schema.$ref
.slice(schema.$ref.lastIndexOf("/") + 1)
.replaceAll("~1", "/")
.replaceAll("~0", "~")
]
return target ? repair(value, target, root, depth + 1) : value
}
if (Array.isArray(schema.type)) {
if (value === null && schema.type.includes("null")) return value
if (schema.type.includes(typeof value)) return value
const types = schema.type.filter((type) => type !== "null")
return types.length === 1 ? repair(value, { ...schema, type: types[0] }, root, depth + 1) : value
}
if (schema.type === undefined) {
if (Array.isArray(schema.anyOf) && Array.isArray(schema.oneOf)) return value
const branches = Array.isArray(schema.anyOf) ? schema.anyOf : schema.oneOf
if (!Array.isArray(branches) || value === null) return value
if (branches.some((branch) => !Predicate.isObject(branch) || branch.type === typeof value)) return value
const candidates = branches.filter((branch) => Predicate.isObject(branch) && branch.type !== "null")
return candidates.length === 1 ? repair(value, candidates[0], root, depth + 1) : value
}
if (schema.type === "number" || schema.type === "integer") {
if (typeof value !== "string" || value.trim() === "") return value
const parsed = Number(value)
return Number.isFinite(parsed) && (schema.type !== "integer" || Number.isSafeInteger(parsed)) ? parsed : value
}
if (schema.type === "boolean") return value === "true" ? true : value === "false" ? false : value
if (schema.type === "object") return repairObject(value, schema, root, depth)
if (schema.type === "array") return repairArray(value, schema, root, depth)
return value
}
function repairObject(
value: unknown,
schema: JsonSchema.JsonSchema,
root: JsonSchema.JsonSchema,
depth: number,
): unknown {
const parsed = typeof value === "string" ? Option.getOrUndefined(decodeJson(value)) : value
if (!Predicate.isObject(parsed)) return value
const properties = Predicate.isObject(schema.properties) ? schema.properties : {}
const required = Array.isArray(schema.required) ? schema.required : []
const patterned = Predicate.isObject(schema.patternProperties)
const composed = Array.isArray(schema.allOf) || Array.isArray(schema.anyOf) || Array.isArray(schema.oneOf)
return Object.keys(parsed).reduce<Record<string, unknown>>((result, key) => {
const current = result[key]
const declared = Object.hasOwn(properties, key)
const property = declared ? properties[key] : !patterned ? schema.additionalProperties : undefined
if (!declared && schema.additionalProperties === false && !patterned && !composed) {
const next = { ...result }
delete next[key]
return next
}
if (!Predicate.isObject(property)) return result
// Only a bare single-type property provably rejects null and `{}`. Compositions, enums,
// constants, references and nullable flags may accept them, so those are left alone.
const plain =
typeof property.type === "string" &&
property.type !== "null" &&
!composed &&
["anyOf", "oneOf", "allOf", "enum", "const", "$ref", "nullable"].every((keyword) => !(keyword in property))
const placeholder = Predicate.isObject(current) && Object.keys(current).length === 0 && property.type !== "object"
if (declared && !required.includes(key) && plain && (current === null || placeholder)) {
const next = { ...result }
delete next[key]
return next
}
const repaired = repair(current, property, root, depth + 1)
return repaired === current ? result : { ...result, [key]: repaired }
}, parsed)
}
function repairArray(
value: unknown,
schema: JsonSchema.JsonSchema,
root: JsonSchema.JsonSchema,
depth: number,
): unknown {
const parsed = typeof value === "string" ? Option.getOrUndefined(decodeJson(value)) : value
const tuple = Array.isArray(schema.prefixItems)
? schema.prefixItems
: Array.isArray(schema.items)
? schema.items
: undefined
if (Array.isArray(parsed)) {
const repaired = parsed.map((item, index) => {
const member = tuple
? (tuple[index] ?? (Array.isArray(schema.prefixItems) ? schema.items : schema.additionalItems))
: schema.items
return Predicate.isObject(member) ? repair(item, member, root, depth + 1) : item
})
return repaired.every((item, index) => item === parsed[index]) ? parsed : repaired
}
if (tuple || !Predicate.isObject(schema.items)) return value
const repaired = repair(value, schema.items, root, depth + 1)
const type = schema.items.type
const compatible =
type === "object"
? Predicate.isObject(repaired)
: type === "array"
? Array.isArray(repaired)
: type === "integer"
? typeof repaired === "number" && Number.isSafeInteger(repaired)
: type === "number"
? typeof repaired === "number" && Number.isFinite(repaired)
: (type === "string" || type === "boolean") && typeof repaired === type
return compatible ? [repaired] : value
}
+3 -9
View File
@@ -132,11 +132,11 @@ export const loadPackage = Effect.fn("Provider.loadPackage")(function* (input: s
return yield* importPackage(specifier, entrypoint)
})
/** opencode settings consumed in Core; native packages never receive them. */
const CORE_KEYS = ["chunkTimeout", "compaction", "fetch", "timeout", "transport"] as const
/** opencode transport settings consumed in aisdk.ts; native packages never receive them. */
const TRANSPORT_KEYS = ["chunkTimeout", "fetch", "timeout"] as const
export function nativeSettings(settings: Settings): Settings {
return Struct.omit(settings, CORE_KEYS)
return Struct.omit(settings, TRANSPORT_KEYS)
}
export function mergeOverlay(
@@ -177,12 +177,6 @@ export function mergeHeaders(
export const Request = Provider.Request
export type Request = Provider.Request
export const Compaction = Provider.Compaction
export type Compaction = Provider.Compaction
export const Transport = Provider.Transport
export type Transport = Provider.Transport
export const Settings = Provider.Settings
export type Settings = Provider.Settings
+8 -3
View File
@@ -736,7 +736,7 @@ export const layer = Layer.effect(
const compact = Effect.fn("SessionCompaction.compact")(function* (input: AutoInput): Effect.fn.Return<Outcome> {
const request = { ...input, reason: "auto" as const }
if (input.overflow) return yield* recoverLocally(request)
if (input.context.model.compaction?.type !== "native") return yield* execute(request)
if (input.context.model.compaction?.mode !== "provider") return yield* execute(request)
return yield* executeProvider(request)
})
const required = (input: RequiredInput) => {
@@ -759,7 +759,12 @@ export const layer = Layer.effect(
limit.input === undefined ? Number.POSITIVE_INFINITY : limit.input - config.buffer,
context - Math.max(output, config.buffer),
)
return estimateTokens(input) >= promptCeiling
const policy = input.resolved.compaction
const threshold =
policy?.mode === "provider" && policy.threshold !== undefined
? Math.min(policy.threshold, promptCeiling)
: promptCeiling
return estimateTokens(input) >= threshold
}
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
if (findTailStart(input.messages, state.get().tokens) === undefined)
@@ -787,7 +792,7 @@ export const layer = Layer.effect(
inputID: input.inputID,
started: input.started,
}
return context.model.compaction?.type === "native" ? executeProvider(request) : execute(request)
return context.model.compaction?.mode === "provider" ? executeProvider(request) : execute(request)
},
}),
)
-6
View File
@@ -155,18 +155,14 @@ it.effect("projects request settings, headers, and body overlays", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
let body: unknown
let options: Record<string, unknown> | undefined
yield* aisdk.hook.sdk((event) => {
body = event.options.body
options = event.options
event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
})
const input = model("@ai-sdk/google", {
apiKey: "secret",
thinkingConfig: { thinkingBudget: 1024 },
compaction: { type: "native" },
transport: "websocket",
})
const resolved = yield* aisdk.model({
...input,
@@ -189,8 +185,6 @@ it.effect("projects request settings, headers, and body overlays", () =>
})
expect(prepared.body.headers).toEqual({ "x-test": "header" })
expect(body).toEqual({ safety_setting: "strict" })
expect(options).not.toHaveProperty("compaction")
expect(options).not.toHaveProperty("transport")
}),
)
+25 -16
View File
@@ -34,7 +34,7 @@ function required<T>(value: T | undefined): T {
const decode = Schema.decodeUnknownSync(Info)
describe("ConfigProviderPlugin.Plugin", () => {
it.effect("inherits the provider compaction setting with model overrides and rejects unsupported routes", () =>
it.effect("inherits provider compaction policy with model overrides and rejects unsupported routes", () =>
Effect.gen(function* () {
const models = yield* Model.Service
yield* addPlugin([
@@ -44,10 +44,12 @@ describe("ConfigProviderPlugin.Plugin", () => {
providers: {
custom: {
package: "@opencode/ai/providers/openai/responses",
settings: { compaction: { type: "native" } },
compaction: { mode: "provider", threshold: 120_000 },
models: {
native: {},
local: { settings: { compaction: { type: "summary" } }, package: "@opencode/ai/providers/openai/chat" },
reset: { compaction: { mode: "provider" } },
threshold: { compaction: { mode: "provider", threshold: 90_000 } },
local: { compaction: { mode: "local" }, package: "@opencode/ai/providers/openai/chat" },
unsupported: { package: "@opencode/ai/providers/openai/chat" },
},
},
@@ -60,9 +62,16 @@ describe("ConfigProviderPlugin.Plugin", () => {
const local = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("local")))
const unsupported = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("unsupported")))
const defaultModel = required(yield* models.get(Provider.ID.make("default"), Model.ID.make("chat")))
expect(native.settings?.compaction).toEqual({ type: "native" })
expect(local.settings?.compaction).toEqual({ type: "summary" })
expect(defaultModel.settings?.compaction).toBeUndefined()
expect(native.compaction).toEqual({ mode: "provider", threshold: 120_000 })
expect((yield* models.get(Provider.ID.make("custom"), Model.ID.make("reset")))?.compaction).toEqual({
mode: "provider",
})
expect((yield* models.get(Provider.ID.make("custom"), Model.ID.make("threshold")))?.compaction).toEqual({
mode: "provider",
threshold: 90_000,
})
expect(local.compaction).toEqual({ mode: "local" })
expect(defaultModel.compaction).toBeUndefined()
yield* ModelResolver.fromCatalogModel(native)
yield* ModelResolver.fromCatalogModel(local)
yield* ModelResolver.fromCatalogModel(defaultModel)
@@ -83,8 +92,8 @@ describe("ConfigProviderPlugin.Plugin", () => {
providers: {
custom: {
package: "@opencode/ai/providers/openai/responses",
settings: { transport: "http" },
models: { inherited: {}, override: { settings: { transport: "websocket" } } },
transport: "http",
models: { inherited: {}, override: { transport: "websocket" } },
},
default: { package: "@opencode/ai/providers/openai/responses", models: { untouched: {} } },
},
@@ -94,9 +103,9 @@ describe("ConfigProviderPlugin.Plugin", () => {
const inherited = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("inherited")))
const override = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("override")))
const untouched = required(yield* models.get(Provider.ID.make("default"), Model.ID.make("untouched")))
expect(inherited.settings?.transport).toBe("http")
expect(override.settings?.transport).toBe("websocket")
expect(untouched.settings?.transport).toBeUndefined()
expect(inherited.transport).toBe("http")
expect(override.transport).toBe("websocket")
expect(untouched.transport).toBeUndefined()
}),
)
@@ -122,7 +131,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
editor.models.update(providerID, modelID, () => {})
})
yield* builtin.plugin.effect(host)
expect((yield* models.get(providerID, modelID))?.settings?.transport).toBe("websocket")
expect((yield* models.get(providerID, modelID))?.transport).toBe("websocket")
yield* addPlugin([
new Document({
@@ -130,16 +139,16 @@ describe("ConfigProviderPlugin.Plugin", () => {
info: decode({
providers: {
[builtin.id]: {
settings: { transport: "http" },
models: { override: { modelID: builtin.model, settings: { transport: "websocket" } } },
transport: "http",
models: { override: { modelID: builtin.model, transport: "websocket" } },
},
},
}),
}),
])
expect((yield* models.get(providerID, modelID))?.settings?.transport).toBe("http")
expect((yield* models.get(providerID, Model.ID.make("override")))?.settings?.transport).toBe("websocket")
expect((yield* models.get(providerID, modelID))?.transport).toBe("http")
expect((yield* models.get(providerID, Model.ID.make("override")))?.transport).toBe("websocket")
}),
)
}
@@ -473,10 +473,10 @@ describe("AzurePlugin", () => {
yield* addPlugin()
const responses = required(yield* service.get(Provider.ID.azure, models.responses))
expect(responses.settings?.transport).toBe("websocket")
expect(responses.transport).toBe("websocket")
for (const modelID of [models.chat, models.preview, models.deploymentURL, models.gateway, models.nonAzure]) {
const model = required(yield* service.get(Provider.ID.azure, modelID))
expect(model.settings?.transport).toBeUndefined()
expect(model.transport).toBeUndefined()
}
}),
),
@@ -205,7 +205,7 @@ describe("OpenAIPlugin", () => {
expect(model.package).toBe("@opencode/ai/providers/openai")
expect(model.enabled).toBe(true)
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
expect(model.settings?.transport).toBe("websocket")
expect(model.transport).toBe("websocket")
expect(direct.headers).not.toHaveProperty("originator")
expect(direct.baseURL).toBe("https://api.openai.com/v1")
expect(provider.headers).not.toHaveProperty("x-codex-beta-features")
@@ -236,7 +236,7 @@ describe("OpenAIPlugin", () => {
id: "deployment-responses",
provider: Provider.ID.azure,
})
const prepare = (preference?: Provider.Transport) =>
const prepare = (preference?: Model.Info["transport"]) =>
Effect.gen(function* () {
const model = SessionRunnerModel.resolved(route.model({ id: "gpt-5.5" }), {
capabilities: { tools: true, input: ["text"], output: ["text"] },
@@ -84,7 +84,7 @@ describe("XAIPlugin", () => {
yield* addPlugin()
const model = yield* models.get(providerID, Model.ID.make("grok-4.6"))
expect(model?.settings?.transport).toBe("websocket")
expect(model?.transport).toBe("websocket")
}),
)
})
@@ -1,114 +0,0 @@
import { expect } from "bun:test"
import { Effect, Schema } from "effect"
import { Agent } from "@opencode/core/agent"
import { Plugin } from "@opencode/core/plugin"
import { ToolInputRepairPlugin } from "@opencode/core/plugin/tool-input-repair"
import { Session } from "@opencode/core/session"
import { SessionMessage } from "@opencode/core/session/message"
import { Tool } from "@opencode/core/tool"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const identity = {
sessionID: Session.ID.make("ses_repair"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_repair"),
}
it.effect("repairs tool input before validating its original schema", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const registry = yield* Tool.Service
const executed: unknown[] = []
yield* plugins.activate([
{ ...ToolInputRepairPlugin.Plugin, revision: "1" },
{
id: "repairable-tool",
revision: "1",
effect: (ctx) =>
ctx.tool.transform((draft) =>
draft.add({
name: "repairable",
options: { codemode: false },
description: "Repairable",
input: Schema.Struct({ count: Schema.Int, enabled: Schema.Boolean }),
execute: (input) => Effect.sync(() => executed.push(input)).pipe(Effect.as({ content: "ok" })),
}),
),
},
])
const snapshot = yield* registry.snapshot()
yield* snapshot.execute({
...identity,
call: {
type: "tool-call",
id: "call-repair",
name: "repairable",
input: '{"count":"2","enabled":"true","extra":true}',
},
})
expect(executed).toEqual([{ count: 2, enabled: true }])
yield* registry.transform((draft) => {
draft.update("repairable", (tool) => {
tool.input = Schema.Struct({ count: Schema.Boolean, enabled: Schema.Boolean })
})
})
const updated = yield* registry.snapshot()
yield* updated.execute({
...identity,
call: {
type: "tool-call",
id: "call-updated",
name: "repairable",
input: { count: "false", enabled: "true" },
},
})
expect(executed).toEqual([
{ count: 2, enabled: true },
{ count: false, enabled: true },
])
yield* registry.transform((draft) => draft.remove("repairable"))
const removed = yield* registry.snapshot()
expect(
(yield* removed
.execute({
...identity,
call: { type: "tool-call", id: "call-removed", name: "repairable", input: {} },
})
.pipe(Effect.flip)).message,
).toBe('No tool named "repairable" is currently available. Please use a tool from the available tool list.')
}),
)
it.effect("repairs namespaced inner tool input called from Code Mode", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const registry = yield* Tool.Service
const executed: unknown[] = []
yield* plugins.activate([{ ...ToolInputRepairPlugin.Plugin, revision: "1" }])
yield* registry.transform((draft) =>
draft.add({
name: "count",
options: { namespace: "example" },
description: "Record a count",
input: Schema.Struct({ count: Schema.Int }),
execute: (input) => Effect.sync(() => executed.push(input)).pipe(Effect.as({ content: "ok" })),
}),
)
const snapshot = yield* registry.snapshot()
yield* snapshot.execute({
...identity,
call: {
type: "tool-call",
id: "call-codemode-repair",
name: "execute",
input: { code: 'return await tools.example.count({ count: "3" })' },
},
})
expect(executed).toEqual([{ count: 3 }])
}),
)
@@ -1,466 +0,0 @@
import { describe, expect } from "bun:test"
import { Agent } from "@opencode/core/agent"
import { ToolInputRepairPlugin } from "@opencode/core/plugin/tool-input-repair"
import { Session } from "@opencode/core/session"
import { SessionMessage } from "@opencode/core/session/message"
import type { ToolHooks } from "@opencode/plugin/effect/tool"
import { Tool } from "@opencode/schema/tool"
import { Effect, type JsonSchema } from "effect"
import { it } from "../lib/effect"
import { host } from "./host"
function run(input: unknown, inputSchema: JsonSchema.JsonSchema) {
const event: ToolHooks["execute.before"] = {
tool: "test",
input,
sessionID: Session.ID.make("ses_repair"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_repair"),
id: Tool.CallID.make("call_repair"),
}
const events: ToolHooks = {
"execute.before": event,
"execute.after": { ...event, status: "error", error: new Tool.Error({ message: "unused" }) },
}
const base = host()
return ToolInputRepairPlugin.Plugin.effect(
host({
tool: {
...base.tool,
transform: (callback) =>
Effect.sync(() => {
const tool = {
id: "test",
name: "test",
description: "Test repair",
input: inputSchema,
execute: () => Effect.succeed({ content: "unused" }),
}
callback({
list: () => [tool],
get: (id) => (id === tool.id ? tool : undefined),
add: () => {},
namespace: () => {},
update: () => {},
remove: () => {},
})
return { dispose: Effect.void }
}),
hook: (name, callback) => callback(events[name]).pipe(Effect.orDie, Effect.as({ dispose: Effect.void })),
},
}),
).pipe(Effect.as(event))
}
const object = (properties: Record<string, unknown>, required?: string[]) => ({
type: "object" as const,
properties,
...(required ? { required } : {}),
})
describe("tool input repair plugin", () => {
it.effect("preserves valid input identity, nested containers, and unknown properties", () =>
Effect.gen(function* () {
const nested = { enabled: true }
const items = [2, 3]
const input = { count: 2, nested, items, extra: "keep" }
const event = yield* run(
input,
object({
count: { type: "integer" },
nested: object({ enabled: { type: "boolean" } }),
items: { type: "array", items: { type: "number" } },
}),
)
expect(event.input).toBe(input)
expect((event.input as typeof input).nested).toBe(nested)
expect((event.input as typeof input).items).toBe(items)
}),
)
it.effect("parses root objects and repairs nested stringified containers", () =>
Effect.gen(function* () {
const schema = object({
count: { type: "integer" },
item: object({ enabled: { type: "boolean" } }),
list: { type: "array", items: { type: "integer" } },
})
expect(
(yield* run('{"count":"2","item":"{\\"enabled\\":\\"false\\"}","list":"[\\"3\\"]"}', schema)).input,
).toEqual({
count: 2,
item: { enabled: false },
list: [3],
})
expect((yield* run("{broken", schema)).input).toBe("{broken")
expect((yield* run("[]", schema)).input).toBe("[]")
expect((yield* run(null, schema)).input).toBeNull()
}),
)
it.effect("removes extras only from explicitly closed objects without mutating inputs", () =>
Effect.gen(function* () {
const input = {
known: "2",
extra: true,
closed: { keep: "3", extra: true },
open: { keep: "4", extra: true },
items: [{ keep: "5", extra: true }],
}
const event = yield* run(input, {
...object({
known: { type: "integer" },
closed: { ...object({ keep: { type: "integer" } }), additionalProperties: false },
open: object({ keep: { type: "integer" } }),
items: {
type: "array",
items: { ...object({ keep: { type: "integer" } }), additionalProperties: false },
},
}),
additionalProperties: false,
})
expect(event.input).toEqual({
known: 2,
closed: { keep: 3 },
open: { keep: 4, extra: true },
items: [{ keep: 5 }],
})
expect(input.extra).toBeTrue()
expect(input.closed.extra).toBeTrue()
expect(input.items[0]?.extra).toBeTrue()
expect((yield* run({ extra: true }, { ...object({}), additionalProperties: false })).input).toEqual({})
}),
)
it.effect("preserves unknown keys when patterned ownership cannot be determined", () =>
Effect.gen(function* () {
const input = { known: 1, match: "2", extra: true }
const event = yield* run(input, {
...object({ known: { type: "integer" } }),
additionalProperties: false,
patternProperties: { "^match$": { type: "integer" } },
})
expect(event.input).toBe(input)
expect((event.input as typeof input).match).toBe("2")
expect((event.input as typeof input).extra).toBeTrue()
}),
)
it.effect("preserves properties that may belong to composed object schemas", () =>
Effect.gen(function* () {
const input = { name: "example", extra: true }
for (const keyword of ["allOf", "anyOf", "oneOf"]) {
const event = yield* run(input, {
type: "object",
[keyword]: [object({ name: { type: "string" } })],
additionalProperties: false,
})
expect(event.input).toBe(input)
}
}),
)
it.effect("removes only optional nonnullable nulls and non-object empty placeholders", () =>
Effect.gen(function* () {
const input = {
optional: null,
required: null,
nullable: null,
union: null,
constant: null,
permissive: null,
referenced: null,
placeholder: {},
array: {},
requiredPlaceholder: {},
object: {},
unknown: null,
}
const event = yield* run(
input,
object(
{
optional: { type: "string" },
required: { type: "string" },
nullable: { type: "string", nullable: true },
union: { anyOf: [{ type: "integer" }, { type: "null" }] },
constant: { anyOf: [{ type: "integer" }, { const: null }] },
permissive: { anyOf: [{ type: "integer" }, true] },
referenced: { anyOf: [{ type: "integer" }, { $ref: "#/$defs/nullable" }] },
placeholder: { type: "integer" },
array: { type: "array", items: { type: "string" } },
requiredPlaceholder: { type: "boolean" },
object: { type: "object" },
unknown: {},
},
["required", "requiredPlaceholder"],
),
)
expect(event.input).toEqual({
required: null,
nullable: null,
union: null,
constant: null,
permissive: null,
referenced: null,
requiredPlaceholder: {},
object: {},
unknown: null,
})
expect(input.optional).toBeNull()
expect(input.placeholder).toEqual({})
}),
)
it.effect("preserves nulls whose validity is hidden inside compositions", () =>
Effect.gen(function* () {
const input = { wrapped: null, enumerated: null, permissive: null, constant: null }
const event = yield* run(
input,
object({
wrapped: { anyOf: [{ type: ["string", "null"] }] },
enumerated: { anyOf: [{ enum: [null, "keep"] }] },
permissive: { anyOf: [{}] },
constant: { type: "string", const: "keep" },
}),
)
expect(event.input).toBe(input)
}),
)
it.effect("preserves optional-looking nulls when the parent composes requirements", () =>
Effect.gen(function* () {
const input = { value: null }
const event = yield* run(input, {
...object({ value: { type: "string" } }),
allOf: [{ required: ["value"] }],
})
expect(event.input).toBe(input)
}),
)
it.effect("coerces numeric and boolean strings while preserving invalid and existing values", () =>
Effect.gen(function* () {
const input = {
number: "1.5",
integer: "42",
enabled: "true",
disabled: "false",
valid: 3,
empty: " ",
infinite: "Infinity",
fractional: "1.5",
unsafe: "9007199254740992",
uppercase: "TRUE",
}
const event = yield* run(
input,
object({
number: { type: "number" },
integer: { type: "integer" },
enabled: { type: "boolean" },
disabled: { type: "boolean" },
valid: { type: "integer" },
empty: { type: "number" },
infinite: { type: "number" },
fractional: { type: "integer" },
unsafe: { type: "integer" },
uppercase: { type: "boolean" },
}),
)
expect(event.input).toEqual({ ...input, number: 1.5, integer: 42, enabled: true, disabled: false })
}),
)
it.effect("wraps compatible scalars after repairing array items", () =>
Effect.gen(function* () {
const event = yield* run(
{
text: "one",
integer: "42",
boolean: "false",
item: '{"count":"2"}',
incompatible: 2,
fractional: 1.5,
unconstrained: "4",
},
object({
text: { type: "array", items: { type: "string" } },
integer: { type: "array", items: { type: "integer" } },
boolean: { type: "array", items: { type: "boolean" } },
item: { type: "array", items: object({ count: { type: "integer" } }) },
incompatible: { type: "array", items: { type: "string" } },
fractional: { type: "array", items: { type: "integer" } },
unconstrained: { type: "array" },
}),
)
expect(event.input).toEqual({
text: ["one"],
integer: [42],
boolean: [false],
item: [{ count: 2 }],
incompatible: 2,
fractional: 1.5,
unconstrained: "4",
})
}),
)
it.effect("repairs nested question-like inputs without mutating original containers", () =>
Effect.gen(function* () {
const question = { question: "Pick one", multiple: "false", options: { label: "First", description: null } }
const input = { questions: [question] }
const event = yield* run(
input,
object({
questions: {
type: "array",
items: object({
question: { type: "string" },
multiple: { type: "boolean" },
options: {
type: "array",
items: object({ label: { type: "string" }, description: { type: "string" } }, ["label"]),
},
}),
},
}),
)
expect(event.input).toEqual({
questions: [{ question: "Pick one", multiple: false, options: [{ label: "First" }] }],
})
expect(input).toEqual({ questions: [question] })
expect(question.options.description).toBeNull()
}),
)
it.effect("repairs unique nullable alternatives while preserving accepted union values", () =>
Effect.gen(function* () {
const input = {
number: "2",
boolean: "false",
nullable: null,
typed: "3",
typedBoolean: "true",
accepted: "4",
valid: 5,
}
const event = yield* run(
input,
object({
number: { anyOf: [{ type: "number" }, { type: "null" }] },
boolean: { oneOf: [{ type: "boolean" }, { type: "null" }] },
nullable: { anyOf: [{ type: "number" }, { type: "null" }] },
typed: { type: ["integer", "null"] },
typedBoolean: { type: ["boolean", "null"] },
accepted: { anyOf: [{ type: "string" }, { type: "number" }] },
valid: { type: ["number", "null"] },
}),
)
expect(event.input).toEqual({ ...input, number: 2, boolean: false, typed: 3, typedBoolean: true })
}),
)
it.effect("repairs tuple positions and rest items while preserving valid array identity", () =>
Effect.gen(function* () {
const valid = [2, false]
const input = { prefix: ["2", "false", "3"], draft: '["4","true"]', valid, scalar: "5" }
const event = yield* run(
input,
object({
prefix: {
type: "array",
prefixItems: [{ type: "integer" }, { type: "boolean" }],
items: { type: "number" },
},
draft: { type: "array", items: [{ type: "integer" }, { type: "boolean" }] },
valid: { type: "array", prefixItems: [{ type: "integer" }, { type: "boolean" }] },
scalar: { type: "array", prefixItems: [{ type: "integer" }] },
}),
)
expect(event.input).toEqual({ prefix: [2, false, 3], draft: [4, true], valid, scalar: "5" })
expect((event.input as typeof input).valid).toBe(valid)
expect(input.prefix).toEqual(["2", "false", "3"])
}),
)
it.effect("repairs typed dictionaries and straightforward local references", () =>
Effect.gen(function* () {
const input = {
modern: "2",
legacy: "false",
nested: { count: "3" },
dictionary: { first: "4" },
missing: "5",
pointer: "6",
escaped: "7",
}
const event = yield* run(input, {
...object({
modern: { $ref: "#/$defs/integer" },
legacy: { $ref: "#/definitions/boolean" },
nested: { $ref: "#/$defs/nested" },
dictionary: { type: "object", additionalProperties: { $ref: "#/$defs/integer" } },
missing: { $ref: "#/$defs/missing" },
pointer: { $ref: "#/$defs/nested/properties/count" },
escaped: { $ref: "#/$defs/a~1b~0c" },
}),
$defs: {
integer: { type: "integer" },
"a/b~c": { type: "integer" },
nested: object({ count: { $ref: "#/$defs/integer" } }),
},
definitions: { boolean: { type: "boolean" } },
})
expect(event.input).toEqual({
modern: 2,
legacy: false,
nested: { count: 3 },
dictionary: { first: 4 },
missing: "5",
pointer: "6",
escaped: 7,
})
expect(input.nested.count).toBe("3")
expect(input.dictionary.first).toBe("4")
}),
)
it.effect("leaves ambiguous unions, compositions, and unsupported roots unchanged", () =>
Effect.gen(function* () {
const input = { numeric: "2", objects: { value: "3" }, both: "4", composed: "5", unknown: "6" }
const event = yield* run(
input,
object({
numeric: { anyOf: [{ type: "number" }, { type: "integer" }] },
objects: {
oneOf: [
object({ value: { type: "integer" } }, ["value"]),
object({ value: { type: "number" } }, ["value"]),
],
},
both: { anyOf: [{ type: "integer" }], oneOf: [{ type: "integer" }] },
composed: { allOf: [{ type: "integer" }] },
unknown: {},
}),
)
expect(event.input).toBe(input)
expect((yield* run(input, { properties: { numeric: { type: "integer" } } })).input).toBe(input)
expect((yield* run(input, { allOf: [object({ numeric: { type: "integer" } })] })).input).toBe(input)
}),
)
})
+2 -10
View File
@@ -28,16 +28,8 @@ describe("Provider", () => {
}
})
test("passes flat settings to native packages without Core settings", () => {
expect(
Provider.nativeSettings({
apiKey: "secret",
reasoningEffort: "high",
chunkTimeout: 1000,
compaction: { type: "native" },
transport: "websocket",
}),
).toEqual({
test("passes flat settings to native packages without opencode transport keys", () => {
expect(Provider.nativeSettings({ apiKey: "secret", reasoningEffort: "high", chunkTimeout: 1000 })).toEqual({
apiKey: "secret",
reasoningEffort: "high",
})
+10 -3
View File
@@ -207,13 +207,20 @@ it.effect("auto compaction estimates current content against the buffered prompt
const inputLimited = { context: 400_000, input: 272_000, output: 128_000 }
expect(compaction.required(input(251_999, inputLimited))).toBe(false)
expect(compaction.required(input(252_000, inputLimited))).toBe(true)
const native = (tokens: number, limit: { context: number; input?: number; output: number } = inputLimited) => {
const native = (
tokens: number,
limit: { context: number; input?: number; output: number } = inputLimited,
threshold?: number,
) => {
const selected = input(tokens, limit)
return { ...selected, resolved: { ...selected.resolved, compaction: { type: "native" as const } } }
return { ...selected, resolved: { ...selected.resolved, compaction: { mode: "provider" as const, threshold } } }
}
expect(compaction.required(native(251_999))).toBe(false)
expect(compaction.required(native(252_000))).toBe(true)
expect(compaction.required(native(1_000_000, { context: 0, input: undefined, output: 0 }))).toBe(false)
expect(compaction.required(native(99_999, inputLimited, 100_000))).toBe(false)
expect(compaction.required(native(100_000, inputLimited, 100_000))).toBe(true)
expect(compaction.required(native(252_000, inputLimited, 500_000))).toBe(true)
expect(compaction.required(native(1_000_000, { context: 0, input: undefined, output: 0 }, 100_000))).toBe(false)
const contextLimited = { context: 100_000, output: 10_000 }
expect(compaction.required(input(79_999, contextLimited))).toBe(false)
@@ -164,7 +164,7 @@ const setup = Effect.fnUntraced(function* (options: { endpoint?: boolean; plugin
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
limit: { context: 200_000, output: 32_000 },
compaction: { type: "native" },
compaction: { mode: "provider" },
},
)
const sessionID = SessionSchema.ID.create()
+2 -4
View File
@@ -2863,8 +2863,7 @@ describe("SessionRunnerLLM", () => {
scenario("automatically persists native windows, retains earlier users, and waits for fresh usage", function* (s) {
s.currentModel = LanguageModel.make({ id: "native", provider: "openai", route: OpenAIResponses.route })
modelLimits.set("native", { context: 42_000, output: 32_000 })
s.compaction = { type: "native" }
s.compaction = { mode: "provider", threshold: 10_000 }
const agents = yield* Agent.Service
yield* agents.transform((editor) =>
editor.update(Agent.defaultID, (agent) => {
@@ -2915,8 +2914,7 @@ describe("SessionRunnerLLM", () => {
scenario("recovers an overflowing native window locally from original durable history", function* (s) {
s.currentModel = LanguageModel.make({ id: "native", provider: "openai", route: OpenAIResponses.route })
modelLimits.set("native", { context: 42_000, output: 32_000 })
s.compaction = { type: "native" }
s.compaction = { mode: "provider", threshold: 10_000 }
yield* s.llm.push(TestLLM.textWithUsage("Earlier answer", "before-native", 10_000))
yield* s.runPrompt("Original durable request")
yield* s.llm.push(
-3
View File
@@ -106,9 +106,6 @@ test("Core reuses the canonical shared schemas", async () => {
[coreModel.Info, Model.Info],
[coreProvider.ID, Provider.ID],
[coreProvider.Request, Provider.Request],
[coreProvider.Compaction, Provider.Compaction],
[coreProvider.Transport, Provider.Transport],
[coreProvider.Settings, Provider.Settings],
[coreProvider.Info, Provider.Info],
[corePermission.Effect, Permission.Effect],
[corePermission.Rule, Permission.Rule],
+2
View File
@@ -3,6 +3,7 @@ import { app } from "electron"
import { Effect, Layer } from "effect"
import { Ipc } from "./ipc"
import { DesktopInitialization } from "./lifecycle/desktop-initialization"
import { installContextMenu } from "./lifecycle/environment"
import { ApplicationLifecycle } from "./lifecycle"
import { BackgroundService } from "./service/background-service"
import { DesktopCli } from "./service/desktop-cli"
@@ -12,6 +13,7 @@ const runIpc = Effect.fn("Desktop.runIpc")(function* () {
const lifecycle = yield* ApplicationLifecycle.Service
const ipc = yield* Ipc.registerIpcHandlers
if (lifecycle.restoreWindows().length) ipc.installMenu()
yield* installContextMenu
yield* Effect.callback<void>((resume) => {
const quit = () => resume(Effect.void)
app.once("will-quit", quit)
@@ -3,7 +3,6 @@ import http from "node:http"
import { homedir, tmpdir } from "node:os"
import { getCACertificates, setDefaultCACertificates } from "node:tls"
import { app } from "electron"
import contextMenu from "electron-context-menu"
import { Effect, FileSystem, Path } from "effect"
import { CHANNEL } from "../constants"
import { DesktopPaths } from "../paths"
@@ -25,7 +24,6 @@ const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports"
export const configureApplication = Effect.fn("Application.configure")(function* () {
const path = yield* Path.Path
contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false })
try {
process.chdir(homedir())
} catch {}
@@ -48,6 +46,13 @@ export const configureApplication = Effect.fn("Application.configure")(function*
}
})
// electron-context-menu attaches to every existing and future window, so it can load once the first
// window is up instead of holding up startup with its dependency tree.
export const installContextMenu = Effect.gen(function* () {
const { default: contextMenu } = yield* Effect.promise(() => import("electron-context-menu"))
contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false })
})
export function acquireApplicationLock() {
if (app.requestSingleInstanceLock()) return true
app.quit()
+38 -76
View File
@@ -13161,7 +13161,7 @@
"properties": {
"action": {
"type": "string",
"enum": ["provider.use", "permission"]
"enum": ["provider.use"]
},
"resource": {
"type": "string"
@@ -13254,6 +13254,12 @@
"Config.ModelEncoded": {
"type": "object",
"properties": {
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"transport": {
"$ref": "#/components/schemas/Provider.Transport"
},
"modelID": {
"type": "string"
},
@@ -13270,7 +13276,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Config.Provider.Settings"
"type": "object"
},
"headers": {
"type": "object",
@@ -13293,7 +13299,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Config.Provider.Settings"
"type": "object"
},
"headers": {
"type": "object",
@@ -13373,47 +13379,15 @@
"required": ["package"],
"additionalProperties": false
},
"Config.Provider.Settings": {
"Config.ProviderEncoded": {
"type": "object",
"properties": {
"timeout": {
"anyOf": [
{
"type": "number"
},
{
"type": "boolean",
"enum": [false]
}
]
},
"chunkTimeout": {
"type": "number"
},
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"transport": {
"$ref": "#/components/schemas/Provider.Transport"
}
},
"allOf": [
{
"type": "object",
"additionalProperties": {
"anyOf": [
{},
{
"type": "null"
}
]
}
}
]
},
"Config.ProviderEncoded": {
"type": "object",
"properties": {
},
"canonical": {
"type": "string"
},
@@ -13430,7 +13404,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Config.Provider.Settings"
"type": "object"
},
"headers": {
"type": "object",
@@ -15507,8 +15481,14 @@
"package": {
"type": "string"
},
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"transport": {
"$ref": "#/components/schemas/Provider.Transport"
},
"settings": {
"$ref": "#/components/schemas/Provider.Settings"
"type": "object"
},
"headers": {
"type": "object",
@@ -15621,7 +15601,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Provider.Settings"
"type": "object"
},
"headers": {
"type": "object",
@@ -16438,23 +16418,27 @@
{
"type": "object",
"properties": {
"type": {
"mode": {
"type": "string",
"enum": ["summary"]
"enum": ["local"]
}
},
"required": ["type"],
"required": ["mode"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"mode": {
"type": "string",
"enum": ["native"]
"enum": ["provider"]
},
"threshold": {
"type": "integer",
"exclusiveMinimum": 0
}
},
"required": ["type"],
"required": ["mode"],
"additionalProperties": false
}
]
@@ -16481,8 +16465,14 @@
"package": {
"type": "string"
},
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"transport": {
"$ref": "#/components/schemas/Provider.Transport"
},
"settings": {
"$ref": "#/components/schemas/Provider.Settings"
"type": "object"
},
"headers": {
"type": "object",
@@ -16517,35 +16507,7 @@
"additionalProperties": false
},
"Provider.Settings": {
"type": "object",
"properties": {
"timeout": {
"anyOf": [
{
"type": "number"
},
{
"type": "boolean",
"enum": [false]
}
]
},
"chunkTimeout": {
"type": "number"
},
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"transport": {
"$ref": "#/components/schemas/Provider.Transport"
}
},
"allOf": [
{
"type": "object",
"additionalProperties": {}
}
]
"type": "object"
},
"Provider.Transport": {
"type": "string",
+10 -12
View File
@@ -6,21 +6,10 @@ import { Capabilities, Compatibility, Family, ID, VariantID } from "../model.js"
import { Provider } from "../provider.js"
import { optional } from "../schema.js"
export const Settings = Schema.StructWithRest(
Schema.Struct({
timeout: Schema.Union([Schema.Finite, Schema.Literal(false)]).pipe(optional),
chunkTimeout: Schema.Finite.pipe(optional),
compaction: Provider.Compaction.pipe(optional),
transport: Provider.Transport.pipe(optional),
}),
[Schema.Record(Schema.String, Schema.UndefinedOr(Schema.Json))],
).annotate({ identifier: "Config.Provider.Settings" })
export type Settings = typeof Settings.Type
const JsonRecord = Schema.Record(Schema.String, Schema.Json)
export const Overlays = {
settings: Settings.pipe(optional),
settings: JsonRecord.pipe(optional),
headers: Schema.Record(Schema.String, Schema.String).pipe(optional),
body: JsonRecord.pipe(optional),
}
@@ -52,6 +41,10 @@ class Limit extends Schema.Class<Limit>("Config.Model.Limit")({
}) {}
class Model extends Schema.Class<Model>("Config.Model")({
compaction: Provider.Compaction.pipe(optional),
transport: Provider.Transport.pipe(optional).annotate({
description: "Session transport for this model. Defaults to the provider transport.",
}),
modelID: ID.pipe(optional),
family: Family.pipe(optional),
name: Schema.String.pipe(optional),
@@ -69,6 +62,11 @@ class Model extends Schema.Class<Model>("Config.Model")({
}) {}
export class Info extends Schema.Class<Info>("Config.Provider")({
compaction: Provider.Compaction.pipe(optional),
transport: Provider.Transport.pipe(optional).annotate({
description:
"Session transport for this provider's models. Defaults to the built-in policy; \"websocket\" on a route without a WebSocket channel warns and falls back to HTTP.",
}),
canonical: Provider.ID.pipe(optional),
name: Schema.String.pipe(optional),
env: Schema.String.pipe(Schema.Array, optional),
+3
View File
@@ -110,6 +110,9 @@ export const Info = Schema.Struct({
name: Schema.String,
compatibility: Compatibility.pipe(optional),
package: Provider.Package.pipe(optional),
compaction: Provider.Compaction.pipe(optional),
/** Session transport; omitted inherits the provider transport, then defaults to HTTP. */
transport: Provider.Transport.pipe(optional),
...Provider.Overlays,
capabilities: Capabilities,
variants: Schema.Array(Variant),
+12 -19
View File
@@ -2,7 +2,7 @@ export * as Provider from "./provider.js"
import { Effect, Schema } from "effect"
import { Integration } from "./integration.js"
import { optional, statics } from "./schema.js"
import { optional, PositiveInt, statics } from "./schema.js"
import { ephemeral, inventory } from "./event.js"
export const ID = Schema.String.pipe(
@@ -32,35 +32,25 @@ export type Package = typeof Package.Type
export const Activation = Schema.Literals(["auto", "enabled", "disabled"])
export type Activation = typeof Activation.Type
export const Compaction = Schema.Union([
Schema.Struct({ type: Schema.Literal("summary") }),
Schema.Struct({ type: Schema.Literal("native") }),
])
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Provider.Compaction" })
export type Compaction = typeof Compaction.Type
export const Compaction = Schema.Union([
Schema.Struct({ mode: Schema.Literal("local") }),
Schema.Struct({ mode: Schema.Literal("provider"), threshold: PositiveInt.pipe(optional) }),
]).annotate({ identifier: "Provider.Compaction" })
/** "websocket" on a route without a WebSocket channel warns and falls back to HTTP. */
export const Transport = Schema.Literals(["http", "websocket"]).annotate({ identifier: "Provider.Transport" })
export type Transport = typeof Transport.Type
export const Settings = Schema.StructWithRest(
Schema.Struct({
timeout: Schema.Union([Schema.Finite, Schema.Literal(false)]).pipe(optional),
chunkTimeout: Schema.Finite.pipe(optional),
compaction: Compaction.pipe(optional),
transport: Transport.pipe(optional),
}),
[Schema.Record(Schema.String, Schema.Any)],
).annotate({ identifier: "Provider.Settings" })
export type Settings = typeof Settings.Type
export const Overlays = {
settings: Settings.pipe(optional),
settings: Schema.Record(Schema.String, Schema.Any).pipe(optional),
headers: Schema.Record(Schema.String, Schema.String).pipe(optional),
body: Schema.Record(Schema.String, Schema.Any).pipe(optional),
}
export const Settings = Schema.Record(Schema.String, Schema.Any).annotate({ identifier: "Provider.Settings" })
export type Settings = typeof Settings.Type
export interface Request extends Schema.Schema.Type<typeof Request> {}
export const Request = Schema.Struct({
settings: Settings.pipe(Schema.withConstructorDefault(Effect.succeed({}))),
@@ -76,6 +66,9 @@ export const Info = Schema.Struct({
name: Schema.String,
activation: Activation,
package: Package,
compaction: Compaction.pipe(optional),
/** Session transport for this provider's models; omitted means HTTP. */
transport: Transport.pipe(optional),
...Overlays,
})
.annotate({ identifier: "Provider.Info" })
@@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test"
import { DateTime, Schema } from "effect"
import { Agent } from "../src/agent.js"
import { ConfigAgent } from "../src/config/agent.js"
import { ConfigProvider } from "../src/config/provider.js"
import { FileSystem } from "../src/filesystem.js"
import { Form } from "../src/form.js"
import { Mcp } from "../src/mcp.js"
@@ -169,7 +168,6 @@ describe("contract hygiene", () => {
test("reusable public identifiers are stable and unique", () => {
const identifiers = [
Agent.Color,
ConfigProvider.Settings,
FileSystem.Submatch,
Form.Field,
Form.Fields,
@@ -228,7 +226,7 @@ describe("contract hygiene", () => {
}
})
test("current source limits Any to reviewed boundaries and avoids mutable contract wrappers", async () => {
test("current source limits Any to provider options and avoids mutable contract wrappers", async () => {
const files = [...new Bun.Glob("*.ts").scanSync(new URL("../src", import.meta.url).pathname)].filter(
(file) => !file.endsWith("-v1.ts"),
)
@@ -239,12 +237,11 @@ describe("contract hygiene", () => {
expect(
sources
.filter((item) => item.file !== "provider.ts" && item.file !== "integration.ts")
.filter((item) => item.file !== "provider.ts")
.map((item) => item.source)
.join("\n"),
).not.toContain("Schema.Any")
expect(sources.find((item) => item.file === "provider.ts")?.source.match(/Schema\.Any/g)).toHaveLength(3)
expect(sources.find((item) => item.file === "integration.ts")?.source.match(/Schema\.Any/g)).toHaveLength(2)
expect(sources.find((item) => item.file === "provider.ts")?.source.match(/Schema\.Any/g)).toHaveLength(4)
expect(source).not.toContain("Schema.mutable")
})
+19 -14
View File
@@ -56,16 +56,23 @@ describe("Model.Compatibility", () => {
})
describe("Model.Info", () => {
test("provider compaction policy is a typed setting", () => {
test("provider compaction policy is optional and uses the canonical closed schema", () => {
const model = Model.Info.default(Provider.ID.openai, Model.ID.make("gpt-5.4-mini"))
expect(Schema.encodeSync(Model.Info)({ ...model, settings: { compaction: undefined } }).settings).toEqual({})
expect(
Schema.decodeUnknownSync(Model.Info)({ ...model, settings: { compaction: { type: "native" } } }).settings,
).toEqual({
compaction: { type: "native" },
expect(Schema.encodeSync(Model.Info)({ ...model, compaction: undefined })).not.toHaveProperty("compaction")
expect(Schema.decodeUnknownSync(Model.Info)({ ...model, compaction: { mode: "provider" } }).compaction).toEqual({
mode: "provider",
})
expect(Schema.decodeUnknownSync(Provider.Compaction)({ type: "summary" })).toEqual({ type: "summary" })
expect(() => Schema.decodeUnknownSync(Provider.Compaction)({ type: "automatic" })).toThrow()
expect(Schema.decodeUnknownSync(Provider.Compaction)({ mode: "local" })).toEqual({ mode: "local" })
expect(Schema.encodeSync(Provider.Compaction)({ mode: "provider", threshold: undefined })).toEqual({
mode: "provider",
})
expect(Schema.decodeUnknownSync(Provider.Compaction)({ mode: "provider", threshold: 120_000 })).toEqual({
mode: "provider",
threshold: 120_000,
})
for (const threshold of [0, -1, 1.5])
expect(() => Schema.decodeUnknownSync(Provider.Compaction)({ mode: "provider", threshold })).toThrow()
expect(() => Schema.decodeUnknownSync(Provider.Compaction)({ mode: "automatic" })).toThrow()
})
test("uses practical token limits for unknown models", () => {
@@ -76,12 +83,10 @@ describe("Model.Info", () => {
})
describe("Model.Capabilities", () => {
test("decodes the optional transport setting", () => {
test("decodes the optional transport preference", () => {
const model = Model.Info.default(Provider.ID.openai, Model.ID.make("gpt-5.4-mini"))
expect(Schema.encodeSync(Model.Info)({ ...model, settings: { transport: undefined } }).settings).toEqual({})
expect(Schema.decodeUnknownSync(Model.Info)({ ...model, settings: { transport: "websocket" } }).settings).toEqual({
transport: "websocket",
})
expect(() => Schema.decodeUnknownSync(Model.Info)({ ...model, settings: { transport: "sse" } })).toThrow()
expect(Schema.encodeSync(Model.Info)({ ...model, transport: undefined })).not.toHaveProperty("transport")
expect(Schema.decodeUnknownSync(Model.Info)({ ...model, transport: "websocket" }).transport).toBe("websocket")
expect(() => Schema.decodeUnknownSync(Model.Info)({ ...model, transport: "sse" })).toThrow()
})
})
+16 -2
View File
@@ -218,13 +218,20 @@ export const DEFAULT_THEME = {
imageText: "$hue.cyan.600",
codeBlock: "$hue.neutral.900",
},
"@dialog": {
"@context:elevated": {
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: {
@@ -433,12 +440,19 @@ export const DEFAULT_THEME = {
imageText: "$hue.cyan.400",
codeBlock: "$hue.neutral.100",
},
"@dialog": {
"@context:elevated": {
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
+5 -1
View File
@@ -11,7 +11,11 @@ export function expandTheme<Definition extends ModeDefinition>(definition: Defin
return {
...definition,
...expandTokens(definition),
...(definition["@dialog"] ? { "@dialog": expandTokens(definition["@dialog"]) } : {}),
...Object.fromEntries(
Object.entries(definition)
.filter(([key]) => key.startsWith("@context:"))
.map(([key, value]) => [key, expandTokens(value as ThemeTokensDefinition)]),
),
}
}
+2 -2
View File
@@ -13,7 +13,6 @@ export {
MarkdownDefinition,
MarkdownToken,
ModeDefinition,
SurfaceName,
SyntaxDefinition,
SyntaxToken,
ThemeDefinition,
@@ -27,13 +26,14 @@ export {
type MergeModeDefinition,
type Mode,
type StatefulColorDefinition,
type ContextKey,
type TextDefinition,
type ThemeTokensDefinition,
} from "./schema.js"
export type {
ActionStates,
Categorical,
ContextName,
FormfieldColor,
Hue,
HueSource,
+33 -60
View File
@@ -7,22 +7,20 @@ import {
ActionState,
ActionVariant,
BaseHue,
FeedbackKind,
HueAlias,
HueStep,
SurfaceName,
ThemeDefinition,
ThemeDocument,
} from "./schema.js"
import type {
ActionStateKey,
ActionStates,
ContextName,
HueDefinition,
HueScale,
Mode,
ResolvedActionState,
ResolvedTheme,
ResolvedThemeTokens,
StatefulColor,
StatefulColorDefinition,
ThemeTokensDefinition,
} from "./index.js"
@@ -44,40 +42,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?: Mode) {
export function resolveThemeDocument(document: ThemeDocument, mode?: "light" | "dark") {
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,
selected.mode,
)
return resolveExpandedTheme({
...merged,
categorical: merged["categorical"] ?? DEFAULT_CATEGORICAL,
} as ThemeDefinition)
}
export function resolveTheme(definition: ThemeDefinition, mode: Mode): ResolvedTheme {
return resolveExpandedTheme(expandTheme(decodeThemeDefinition(definition)), mode)
export function resolveTheme(definition: ThemeDefinition): ResolvedTheme {
return resolveExpandedTheme(expandTheme(decodeThemeDefinition(definition)))
}
function resolveExpandedTheme(definition: ThemeDefinition, mode: Mode): ResolvedTheme {
function resolveExpandedTheme(definition: ThemeDefinition): 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 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)
const resolved = resolveView(base, hue, categorical, hueSteps)
const context = (name: ContextName) => {
const override = definition[`@context:${name}`]
if (!override) return resolved
return resolveView(contextualize(base, override), hue, categorical, hueSteps)
}
const contextual = {
elevated: context("elevated"),
overlay: context("overlay"),
}
return { ...resolved, contextual } as ResolvedTheme
}
function tokens(definition: ThemeDefinition): ThemeTokensDefinition {
@@ -94,23 +92,27 @@ function tokens(definition: ThemeDefinition): ThemeTokensDefinition {
function contextualize(base: ThemeTokensDefinition, override: ThemeTokensDefinition) {
const result = mergeTheme(base, override)
const baseText = base.text?.action
const contextText = override.text?.action
const baseBackground = base.background?.action
const contextBackground = override.background?.action
const text = result["text"] as NonNullable<ThemeTokensDefinition["text"]>
const background = result["background"] as NonNullable<ThemeTokensDefinition["background"]>
return {
...result,
text: { ...text, action: contextualActions(base.text?.action, override.text?.action) },
background: { ...background, action: contextualActions(base.background?.action, override.background?.action) },
text: { ...text, action: contextualActions(baseText, contextText) },
background: { ...background, action: contextualActions(baseBackground, contextBackground) },
} as ThemeTokensDefinition
}
function contextualActions(
base: Partial<Record<ActionVariant, StatefulColorDefinition>> | undefined,
surface: Partial<Record<ActionVariant, StatefulColorDefinition>> | undefined,
context: Partial<Record<ActionVariant, StatefulColorDefinition>> | undefined,
) {
return Object.fromEntries(
ActionVariant.literals.map((variant) => {
const baseVariant = base?.[variant]
const surfaceVariant = surface?.[variant]
const contextVariant = context?.[variant]
return [
variant,
Object.fromEntries(
@@ -118,8 +120,8 @@ function contextualActions(
const key = state === "default" ? undefined : (`$${state}` as ActionStateKey)
return [
key ?? "default",
(key ? surfaceVariant?.[key] : undefined) ??
surfaceVariant?.default ??
(key ? contextVariant?.[key] : undefined) ??
contextVariant?.default ??
(key ? baseVariant?.[key] : undefined) ??
baseVariant?.default,
]
@@ -137,36 +139,7 @@ function resolveView(
hueSteps: Pick<ResolvedThemeTokens, "source" | "increase" | "decrease">,
): ResolvedThemeTokens {
const source: Record<string, unknown> = { hue, ...definition }
const resolved = createResolver(source)(source, "theme") as ResolvedThemeTokens
return {
...resolved,
hue,
categorical,
text: {
...resolved.text,
action: statefulActions(resolved.text.action),
formfield: statefulColor(resolved.text.formfield),
},
background: {
...resolved.background,
action: statefulActions(resolved.background.action),
formfield: statefulColor(resolved.background.formfield),
},
...hueSteps,
}
}
function statefulActions(actions: Readonly<Record<ActionVariant, StatefulColor>>) {
return Object.fromEntries(ActionVariant.literals.map((variant) => [variant, statefulColor(actions[variant])])) as Readonly<
Record<ActionVariant, StatefulColor>
>
}
function statefulColor(color: StatefulColor): StatefulColor {
return {
...color,
state: (states: ActionStates) => color[ActionState.literals.find((state) => states[state]) ?? "default"],
}
return { ...(createResolver(source)(source, "theme") as ResolvedThemeTokens), hue, categorical, ...hueSteps }
}
function compileHueSteps(
+9 -6
View File
@@ -16,9 +16,6 @@ export const ActionState = Schema.Literals(["disabled", "pressed", "focused", "s
export type ActionState = Schema.Schema.Type<typeof ActionState>
export type ActionStateKey = `$${ActionState}`
export const SurfaceName = Schema.Literal("dialog")
export type SurfaceName = Schema.Schema.Type<typeof SurfaceName>
export const FormfieldState = ActionState
export type FormfieldState = ActionState
export type FormfieldStateKey = `$${FormfieldState}`
@@ -43,6 +40,9 @@ export const CategoricalDefinition = Schema.Array(HueName).check(Schema.isMinLen
export type CategoricalDefinition = Schema.Schema.Type<typeof CategoricalDefinition>
const HueColorValue = Schema.Union([HexColor, Schema.TemplateLiteral(["$hue.", HueName, ".", HueStep])])
const ContextKey = Schema.Literals(["@context:elevated", "@context:overlay"])
export type ContextKey = Schema.Schema.Type<typeof ContextKey>
const HueScaleDefinition = Schema.Record(HueStep, HexColor)
const HueValueDefinition = Schema.Union([Schema.TemplateLiteral(["$hue.", HueName]), HueScaleDefinition])
@@ -229,7 +229,8 @@ const ThemeDefinitionFields = Schema.Struct({
hue: HueDefinition,
categorical: Schema.optional(CategoricalDefinition),
...ThemeTokensDefinition.fields,
"@dialog": Schema.optional(ThemeTokensDefinition),
"@context:elevated": Schema.optional(ThemeTokensDefinition),
"@context:overlay": Schema.optional(ThemeTokensDefinition),
})
export const ThemeDefinition = ThemeDefinitionFields
export type ThemeDefinition = Schema.Schema.Type<typeof ThemeDefinition>
@@ -238,7 +239,8 @@ const FileThemeDefinition = Schema.Struct({
hue: Schema.optional(HueOverrideDefinition),
categorical: Schema.optional(CategoricalDefinition),
...ThemeTokensDefinition.fields,
"@dialog": Schema.optional(ThemeTokensDefinition),
"@context:elevated": Schema.optional(ThemeTokensDefinition),
"@context:overlay": Schema.optional(ThemeTokensDefinition),
})
export type FileThemeDefinition = Schema.Schema.Type<typeof FileThemeDefinition>
@@ -247,7 +249,8 @@ const MergeModeDefinition = Schema.Struct({
hue: Schema.optional(HueOverrideDefinition),
categorical: Schema.optional(CategoricalDefinition),
...ThemeTokensDefinition.fields,
"@dialog": Schema.optional(ThemeTokensDefinition),
"@context:elevated": Schema.optional(ThemeTokensDefinition),
"@context:overlay": Schema.optional(ThemeTokensDefinition),
})
export type MergeModeDefinition = Schema.Schema.Type<typeof MergeModeDefinition>
export const ModeDefinition = Schema.Union([MergeModeDefinition, FileThemeDefinition])
+4 -9
View File
@@ -7,7 +7,6 @@ import type {
HueAlias,
HueStep,
MarkdownToken,
SurfaceName,
SyntaxToken,
} from "./schema.js"
@@ -17,10 +16,7 @@ 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 ActionStates = Readonly<Partial<Record<ActionState, boolean>>>
export type StatefulColor = Readonly<Record<ResolvedActionState, RGBA>> & {
readonly state: (states: ActionStates) => RGBA
}
export type StatefulColor = Readonly<Record<ResolvedActionState, RGBA>>
export type FormfieldColor = StatefulColor
export type ResolvedThemeTokens = {
@@ -73,9 +69,8 @@ export type ResolvedThemeTokens = {
readonly markdown: Readonly<Record<MarkdownToken, RGBA>>
}
export type ContextName = "elevated" | "overlay"
export type ResolvedTheme = 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
readonly contextual: Readonly<Record<ContextName, ResolvedThemeTokens>>
}
+2 -1
View File
@@ -173,12 +173,13 @@ function migrateMode(theme: Theme, mode: Mode): FileThemeDefinition {
imageText: color("markdownImageText"),
codeBlock: color("markdownCodeBlock"),
},
"@dialog": {
"@context:elevated": {
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.surface("dialog").text.status).toEqual(theme.text.status)
expect(theme.contextual.elevated.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.surface("dialog").text.status).toEqual(theme.text.status)
expect(theme.contextual.elevated.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()
const elevatedTheme = useTheme("elevated")
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()
const theme = useTheme("elevated")
const renderer = useRenderer()
return (
<box
@@ -487,7 +487,7 @@ function PanelBox(props: ParentProps) {
paddingRight={2}
paddingTop={1}
paddingBottom={1}
backgroundColor={theme.background.raised.base}
backgroundColor={theme.background.default}
flexDirection="column"
onMouseUp={(event) => {
if (renderer.getSelection()?.getSelectedText()) return
@@ -500,7 +500,7 @@ function PanelBox(props: ParentProps) {
}
function PanelTitle(props: ParentProps) {
const theme = useTheme()
const theme = useTheme("elevated")
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()
const theme = useTheme("elevated")
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()
const theme = useTheme("elevated")
const [hovered, setHovered] = createSignal(false)
return (
<box
backgroundColor={
props.hoverBackground && hovered() && !props.disabled ? theme.background.raised.high : undefined
props.hoverBackground && hovered() && !props.disabled ? theme.background.action.primary.hovered : 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()
const theme = useTheme("elevated")
const value = () => {
const value = props.values.at(-1)
if (value === undefined) return "--"
@@ -1,6 +1,6 @@
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { createSignal, Show } from "solid-js"
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 { useConfig } from "../config"
import { useClipboard } from "../context/clipboard"
import { Keymap } from "../context/keymap"
@@ -27,11 +27,35 @@ export function DialogErrorDetails(props: {
const location = useLocation()
const route = useRoute()
const toast = useToast()
const theme = useTheme().surface("dialog")
const theme = useTheme("elevated")
const renderer = useRenderer()
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
@@ -62,10 +86,11 @@ 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(-20)
if (event.name === "pagedown") return scroll?.scrollBy(20)
if (event.name === "pageup") return scroll?.scrollBy(-maxHeight())
if (event.name === "pagedown") return scroll?.scrollBy(maxHeight())
if (event.name === "home") return scroll?.scrollTo(0)
if (event.name === "end" && scroll) return scroll.scrollTo(scroll.scrollHeight)
})
@@ -101,8 +126,7 @@ export function DialogErrorDetails(props: {
<box>
<scrollbox
ref={(element: ScrollBoxRenderable) => (scroll = element)}
maxHeight={20}
contentOptions={{ minHeight: 0 }}
height={height()}
scrollbarOptions={{ visible: false }}
scrollAcceleration={getScrollAcceleration(config)}
>
@@ -127,7 +151,9 @@ export function DialogErrorDetails(props: {
</span>
<span style={{ fg: theme.text.subdued }}>{copied() ? "" : " copy details"}</span>
</text>
<text fg={theme.text.subdued}>↑/↓ scroll</text>
<Show when={scrollable()}>
<text fg={theme.text.subdued}>↑/↓ scroll</text>
</Show>
</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().surface("dialog")
const theme = useTheme("elevated")
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().surface("dialog")
const theme = useTheme("elevated")
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().surface("dialog")
const theme = useTheme("elevated")
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().surface("dialog")
const overlayTheme = useTheme()
const theme = useTheme("elevated")
const overlayTheme = useTheme("overlay")
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.raised.high}
backgroundColor={overlayTheme.background.default}
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().surface("dialog")
const theme = useTheme("elevated")
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().surface("dialog")
const theme = useTheme("elevated")
const [error, setError] = createSignal<string>()
let settled = false
@@ -724,7 +724,7 @@ function OAuthView(props: {
open?: boolean
}) {
const dialog = useDialog()
const theme = useTheme().surface("dialog")
const theme = useTheme("elevated")
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().surface("dialog")
const theme = useTheme("elevated")
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().surface("dialog")
const theme = useTheme("elevated")
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().surface("dialog")
const theme = useTheme("elevated")
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().surface("dialog")
const theme = useTheme("elevated")
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().surface("dialog")
const theme = useTheme("elevated")
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().surface("dialog")
const theme = useTheme("elevated")
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().surface("dialog")
const theme = useTheme("elevated")
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().surface("dialog")
const theme = useTheme("elevated")
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().surface("dialog")
const theme = useTheme("elevated")
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().surface("dialog")
const overlayTheme = useTheme()
const theme = useTheme("elevated")
const overlayTheme = useTheme("overlay")
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.raised.high}
backgroundColor={overlayTheme.background.default}
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().surface("dialog")
const theme = useTheme("elevated")
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().surface("dialog")
const theme = useTheme("elevated")
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()
const theme = useTheme("overlay")
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.raised.high}
backgroundColor={theme.background.default}
border={["left"]}
borderColor={theme.text.feedback.info.default}
customBorderChars={SplitBorder.customBorderChars}
+5 -6
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 { useTheme } from "../context/theme"
import { ThemeContextProvider, useTheme } from "../context/theme"
import { Slot } from "../plugin/render"
export function PanelHost(props: {
@@ -19,9 +19,6 @@ 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"
@@ -30,7 +27,7 @@ export function PanelHost(props: {
minWidth={0}
minHeight={0}
focusable
backgroundColor={background()}
backgroundColor={theme.background.default}
onMouseDown={props.onFocus}
>
<Slot
@@ -58,7 +55,9 @@ export function PanelHost(props: {
return (
<InteractivityProvider enabled={props.focused}>
<Content />
<ThemeContextProvider context={panels.presentation() === "panel" ? "elevated" : undefined}>
<Content />
</ThemeContextProvider>
</InteractivityProvider>
)
}
@@ -77,7 +77,7 @@ export function Autocomplete(props: {
const data = useData()
const keymap = Keymap.use()
const keymapCommands = Keymap.useCommands()
const theme = useTheme()
const theme = useTheme("overlay")
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.raised.high}
backgroundColor={theme.background.default}
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()
const theme = useTheme("elevated")
return (
<box
@@ -21,7 +21,7 @@ export function Reconnecting(props: { managed?: boolean }) {
width={48}
maxWidth="90%"
flexDirection="column"
backgroundColor={theme.background.raised.base}
backgroundColor={theme.background.default}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
@@ -10,29 +10,29 @@ export function SessionTabsRailControls(props: {
tabs: SessionTabsController
belowHighlighted: boolean
}) {
const theme = useTheme()
const theme = useTheme("elevated")
const keymap = Keymap.use()
const [hovered, setHovered] = createSignal(false)
const hoverColor = createMemo(() =>
tint(theme.background.raised.base, theme.background.raised.high, theme.background.raised.high.a),
tint(theme.background.default, theme.background.action.primary.hovered, theme.background.action.primary.hovered.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.raised.base}>
<box height={1} position="relative" flexShrink={0} backgroundColor={theme.background.default}>
<SessionTabHalfRow
top={-1}
edge="top"
width={props.width}
color={hovered() ? hoverColor() : theme.background.raised.base}
background={theme.background.raised.base}
color={hovered() ? hoverColor() : theme.background.default}
background={theme.background.default}
/>
<box
height={1}
position="relative"
flexDirection="row"
justifyContent="center"
backgroundColor={hovered() ? theme.background.raised.high : undefined}
backgroundColor={hovered() ? theme.background.action.primary.hovered : 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.raised.base}
bg={hovered() && props.belowHighlighted ? hoverColor() : theme.background.default}
selectable={false}
>
{(hovered() ? "▀" : props.belowHighlighted ? "▄" : " ").repeat(props.width)}
+39 -44
View File
@@ -366,9 +366,7 @@ export function createTabMarquee(animations: () => boolean) {
function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsController; onClose: () => void }) {
const dimensions = useTerminalDimensions()
const theme = useTheme()
const background = () => theme.background.raised.base
const actionHovered = () => theme.background.raised.high
const theme = useTheme("elevated")
const dialog = useDialog()
onCleanup(Keymap.use().mode.push("menu"))
Keymap.createLayer(() => ({
@@ -432,7 +430,7 @@ function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsCo
height={actions().length}
width={CONTEXT_MENU_WIDTH}
flexDirection="column"
backgroundColor={background()}
backgroundColor={theme.background.default}
onMouseDown={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) props.onClose()
event.preventDefault()
@@ -445,7 +443,7 @@ function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsCo
width="100%"
paddingLeft={1}
paddingRight={1}
backgroundColor={selected() === index() ? actionHovered() : undefined}
backgroundColor={selected() === index() ? theme.background.action.primary.hovered : undefined}
onMouseOver={() => setSelected(index())}
onMouseOut={() => setSelected(undefined)}
onMouseUp={(event) => {
@@ -517,10 +515,7 @@ function VerticalSessionTabs(props: {
const data = props.controller ? undefined : useData()
const dimensions = useTerminalDimensions()
const renderer = useRenderer()
const theme = useTheme()
const background = () => theme.background.raised.base
const actionSelected = () => theme.background.action.primary.selected
const actionHovered = () => theme.background.raised.high
const theme = useTheme("elevated")
const base = useTheme()
const config = useConfig().data
const animations = () => props.animations ?? config.animations ?? true
@@ -530,9 +525,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, background(), 0.55)
const separatorUpperPulseColor = createMemo(() => tint(background(), theme.text.default, 0.04))
const separatorLowerPulseColor = createMemo(() => tint(background(), theme.text.default, 0.05))
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 [addHovered, setAddHovered] = createSignal(false)
const marquee = createTabMarquee(animations)
const hovered = marquee.hovered
@@ -569,7 +564,7 @@ function VerticalSessionTabs(props: {
})
const items = ordered
const highlightColor = createMemo(() =>
tint(background(), actionHovered(), actionHovered().a),
tint(theme.background.default, theme.background.action.primary.hovered, theme.background.action.primary.hovered.a),
)
const highlighted = (sessionID: string | undefined) =>
sessionID !== undefined && (activeID() === sessionID || hovered() === sessionID || dragging() === sessionID)
@@ -676,7 +671,7 @@ function VerticalSessionTabs(props: {
flexDirection="column"
position="relative"
paddingTop={1}
backgroundColor={background()}
backgroundColor={theme.background.default}
onMouseOut={marquee.leaveHovered}
onMouseUp={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
@@ -699,7 +694,7 @@ function VerticalSessionTabs(props: {
}}
flexGrow={1}
minHeight={0}
backgroundColor={background()}
backgroundColor={theme.background.default}
scrollbarOptions={{ visible: false }}
>
<box flexShrink={0} flexDirection="column" gap={1} paddingY={compact() ? 1 : 0}>
@@ -737,13 +732,13 @@ function VerticalSessionTabs(props: {
const detailFades = createMemo(
() => marqueeOverflows(tabDetail(), titleWidth()) && titleWidth() > FADE_WIDTH,
)
const tabBackground = createMemo(() => {
if (selected() && !compact()) return actionSelected()
const background = createMemo(() => {
if (selected() && !compact()) return theme.background.action.primary.selected
if ((compact() && selected()) || hovered() === tab.sessionID || dragging() === tab.sessionID)
return actionHovered()
return background()
return theme.background.action.primary.hovered
return theme.background.default
})
const pulseBackground = createMemo(() => tint(background(), tabBackground(), tabBackground().a))
const pulseBackground = createMemo(() => tint(theme.background.default, background(), background().a))
const runs = () => status().runs
const numberIgnition = createNumberIgnition(runs, () => status().promptPulse, animations)
const numberColor = () => {
@@ -820,10 +815,10 @@ function VerticalSessionTabs(props: {
return lastPreviousGlowHue ?? unreadColor()
}
const separatorUpperColor = createMemo(() =>
tint(background(), previousGlowHue(), 0.1 * previousGlowLevel()),
tint(theme.background.default, previousGlowHue(), 0.1 * previousGlowLevel()),
)
const separatorLowerColor = createMemo(() =>
tint(background(), glowHue(), 0.12 * glowLevel()),
tint(theme.background.default, glowHue(), 0.12 * glowLevel()),
)
const titleColor = (index: number, separator: boolean) => {
const level = titleGlow.value().level
@@ -848,7 +843,7 @@ function VerticalSessionTabs(props: {
width="100%"
position="relative"
flexDirection="column"
backgroundColor={tabBackground()}
backgroundColor={background()}
onMouseOver={(event) => {
setHoverY(event.y)
marquee.enter(tab.sessionID, title(), compact() ? Infinity : hoveredTitleWidth())
@@ -890,7 +885,7 @@ function VerticalSessionTabs(props: {
width={width()}
color={pulseBackground()}
background={
highlighted(items()[index() - 1]?.sessionID) ? highlightColor() : background()
highlighted(items()[index() - 1]?.sessionID) ? highlightColor() : theme.background.default
}
/>
<SessionTabHalfRow
@@ -905,7 +900,7 @@ function VerticalSessionTabs(props: {
: highlighted(items()[index() + 1]?.sessionID)
)
? highlightColor()
: background()
: theme.background.default
}
/>
</Show>
@@ -951,8 +946,8 @@ function VerticalSessionTabs(props: {
color={separatorLowerPulseColor()}
width={indicatorWidth}
outerColor={separatorUpperPulseColor()}
flashColor={tint(background(), theme.text.default, 0.22)}
outerFlashColor={tint(background(), theme.text.default, 0.18)}
flashColor={tint(theme.background.default, theme.text.default, 0.22)}
outerFlashColor={tint(theme.background.default, theme.text.default, 0.18)}
flashTail={8}
glowColor={separatorLowerColor()}
outerGlowColor={separatorUpperColor()}
@@ -960,7 +955,7 @@ function VerticalSessionTabs(props: {
outerGlowTail={5}
completionColor={separatorLowerColor()}
outerCompletionColor={separatorUpperColor()}
backgroundColor={background()}
backgroundColor={theme.background.default}
/>
<Show when={index() === items().length - 1}>
<TabPulse
@@ -975,18 +970,18 @@ function VerticalSessionTabs(props: {
outerComplete={false}
glow={glows()}
outerGlow={false}
color={tint(background(), theme.text.default, 0.04)}
color={tint(theme.background.default, theme.text.default, 0.04)}
width={indicatorWidth}
outerColor={tint(background(), theme.text.default, 0.006)}
flashColor={tint(background(), theme.text.default, 0.18)}
outerColor={tint(theme.background.default, theme.text.default, 0.006)}
flashColor={tint(theme.background.default, theme.text.default, 0.18)}
flashTail={8}
glowColor={tint(background(), glowHue(), 0.1 * glowLevel())}
outerGlowColor={background()}
glowColor={tint(theme.background.default, glowHue(), 0.1 * glowLevel())}
outerGlowColor={theme.background.default}
glowTail={8}
outerGlowTail={5}
completionColor={tint(background(), glowHue(), 0.1 * glowLevel())}
outerCompletionColor={background()}
backgroundColor={background()}
completionColor={tint(theme.background.default, glowHue(), 0.1 * glowLevel())}
outerCompletionColor={theme.background.default}
backgroundColor={theme.background.default}
/>
</Show>
<box height={1} width="100%" flexDirection="row" position="relative">
@@ -1118,10 +1113,10 @@ function VerticalSessionTabs(props: {
alignItems="center"
backgroundColor={
newTab() && !compact()
? actionSelected()
? theme.background.action.primary.selected
: addHovered() || (compact() && newTab())
? actionHovered()
: background()
? theme.background.action.primary.hovered
: theme.background.default
}
onMouseOver={() => setAddHovered(true)}
onMouseOut={() => setAddHovered(false)}
@@ -1150,14 +1145,14 @@ function VerticalSessionTabs(props: {
edge="top"
width={width()}
color={highlightColor()}
background={highlighted(items().at(-1)?.sessionID) ? highlightColor() : background()}
background={highlighted(items().at(-1)?.sessionID) ? highlightColor() : theme.background.default}
/>
<SessionTabHalfRow
top={1}
edge="bottom"
width={width()}
color={highlightColor()}
background={background()}
background={theme.background.default}
/>
</Show>
<text
@@ -1216,10 +1211,10 @@ function VerticalSessionTabs(props: {
top={0}
edge="top"
width={tooltipWidth()}
color={background()}
color={theme.background.default}
background={base.background.default}
/>
<box height={2} paddingX={1} backgroundColor={background()}>
<box height={2} paddingX={1} backgroundColor={theme.background.default}>
<text fg={theme.text.default} wrapMode="none" selectable={false}>
{Locale.truncateWidth(
data?.session.get(sessionID())?.title ??
@@ -1236,7 +1231,7 @@ function VerticalSessionTabs(props: {
top={3}
edge="bottom"
width={tooltipWidth()}
color={background()}
color={theme.background.default}
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()
const theme = useTheme("elevated")
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.raised.base} paddingLeft={1} paddingRight={1}>
<box backgroundColor={theme.background.default} 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()
const theme = useTheme("elevated")
const themes = useThemes()
const renderer = useRenderer()
const [failure, setFailure] = createSignal<string>()
@@ -155,8 +155,8 @@ export function TerminalPane(props: {
})
createEffect(() => {
const tokens = themes.currentTokens()
terminalTheme = terminalPalette(tokens, themes.mode(), tokens.background.raised.base)
const tokens = themes.currentTokens().contextual.elevated
terminalTheme = terminalPalette(tokens, themes.mode(), tokens.background.default)
applyTerminalTheme()
})
@@ -284,7 +284,7 @@ export function TerminalPane(props: {
minWidth={0}
minHeight={0}
overflow="hidden"
backgroundColor={themes.currentTokens().background.raised.base}
backgroundColor={themes.currentTokens().contextual.elevated.background.default}
onSizeChange={function () {
size = { cols: Math.max(1, this.width - 2), rows: this.height }
if (controller && restored) interact()
+23 -20
View File
@@ -5,7 +5,7 @@ import {
resolveThemeDocument,
themeModes,
type ResolvedTheme,
type SurfaceName,
type ContextName,
} 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, type ComponentTheme } from "../theme/component"
import { createComponentTheme, createComponentThemeView, 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
current: ComponentTheme["contextual"][ContextName]
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)
const current = createComponentTheme(tokens, mode)
createEffect(() => renderer.setBackgroundColor(tokens().background.default))
@@ -377,31 +377,34 @@ const themeContext = createSimpleContext({
export function useThemes() {
return themeContext.use().themes
}
export function useTheme(): ComponentTheme {
return themeContext.use().current
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 const ThemeProvider = themeContext.provider
/** 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 = createComponentTheme(() => {
const name = props.context
return name ? value.themes.currentTokens().surface(name) : value.current
})
return (
<themeContext.context.Provider value={{ current, themes: value.themes, ready: value.ready }}>
{props.children}
</themeContext.context.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 }>) {
const value = themeContext.use()
const current = createComponentThemeView(() => {
const name = props.context
return name ? value.themes.currentTokens().contextual[name] : value.current
}, value.themes.mode)
return (
<themeContext.context.Provider value={{ current, themes: value.themes, ready: value.ready }}>
{props.children}
</themeContext.context.Provider>
)
}
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 } from "@opentui/solid"
import { createSignal, Show } from "solid-js"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { createMemo, createSignal, Show } from "solid-js"
import { Spinner } from "../../component/spinner"
import { useConfig } from "../../config"
import { useClipboard } from "../../context/clipboard"
@@ -86,10 +86,12 @@ function Answer(props: { question: string; answer: string }) {
const toast = useToast()
const clipboard = useClipboard()
const plugins = usePlugin()
const theme = useTheme().surface("dialog")
const overlay = useTheme()
const theme = useTheme("elevated")
const overlay = useTheme("overlay")
const syntax = useThemes().currentSyntax
const config = useConfig().data
const dimensions = useTerminalDimensions()
const maxHeight = createMemo(() => Math.max(3, Math.floor(dimensions().height / 2)))
const [copied, setCopied] = createSignal(false)
let scroll: ScrollBoxRenderable | undefined
@@ -109,8 +111,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(-20)
if (event.name === "pagedown") return scroll.scrollBy(20)
if (event.name === "pageup") return scroll.scrollBy(-maxHeight())
if (event.name === "pagedown") return scroll.scrollBy(maxHeight())
if (event.name === "home") return scroll.scrollTo(0)
if (event.name === "end") return scroll.scrollTo(scroll.scrollHeight)
})
@@ -126,16 +128,14 @@ function Answer(props: { question: string; answer: string }) {
esc
</text>
</box>
<box paddingTop={1}>
<text fg={theme.text.subdued} wrapMode="word">
{props.question}
</text>
</box>
<text fg={theme.text.subdued} wrapMode="word">
{props.question}
</text>
</box>
<scrollbox
ref={(element: ScrollBoxRenderable) => (scroll = element)}
maxHeight={20}
backgroundColor={overlay.background.raised.high}
maxHeight={maxHeight()}
backgroundColor={overlay.background.default}
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.raised.high}
bg={overlay.background.default}
/>
</box>
</scrollbox>
@@ -12,7 +12,7 @@ export function DiffFileMenu(props: {
onClose: () => void
}) {
const dimensions = useTerminalDimensions()
const theme = props.context.theme
const theme = props.context.theme.contextual.overlay
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.raised.high}
backgroundColor={hovered() ? theme.background.action.primary.hovered : theme.background.default}
onMouseOver={() => setHovered(true)}
onMouseOut={() => setHovered(false)}
onMouseDown={(event) => {
@@ -27,7 +27,7 @@ export type DiffViewerFileTreeProps = {
}
export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
const theme = useTheme()
const theme = useTheme("elevated")
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.raised.base, 0.45))
const faint = createMemo(() => tint(theme.text.subdued, theme.background.default, 0.45))
// Rails are pure texture; keep them barely above the surface.
const rail = createMemo(() => tint(theme.text.subdued, theme.background.raised.base, 0.7))
const rail = createMemo(() => tint(theme.text.subdued, theme.background.default, 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.raised.base} />
<box id="diff-tree-top-edge" height={1} flexShrink={0} backgroundColor={theme.background.default} />
<box
flexGrow={1}
minWidth={0}
@@ -64,7 +64,7 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
backgroundColor={theme.background.raised.base}
backgroundColor={theme.background.default}
>
<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.raised.high
return theme.background.raised.base
if (hovered()) return theme.background.action.primary.hovered
return theme.background.default
}
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.surface("dialog")
const theme = props.context.theme.contextual.elevated
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.surface("dialog")
const theme = props.context.theme.contextual.elevated
const shortcut =
(...ids: string[]) =>
() =>
@@ -14,10 +14,10 @@ export function StoryFooter(props: {
message?: string
controls: readonly StoryFooterControl[]
}) {
const theme = props.context.theme
const theme = props.context.theme.contextual.elevated
return (
<box flexShrink={0} flexDirection="column" backgroundColor={theme.background.raised.base}>
<box flexShrink={0} flexDirection="column" backgroundColor={theme.background.default}>
<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
const theme = props.context.theme.contextual.elevated
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.raised.base}>
<box width={dimensions().width} height={dimensions().height} backgroundColor={theme.background.default}>
<box paddingLeft={2} paddingRight={2} paddingTop={1} flexGrow={1}>
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
Workerd Modal workspace driver
+11 -10
View File
@@ -115,6 +115,7 @@ 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)
@@ -136,13 +137,13 @@ function map(
return {
background: RGBA.defaultBackground(theme.background.default),
footer: {
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),
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),
running: exact(theme.text.status.running),
question: exact(theme.text.status.question),
permission: exact(theme.text.status.permission),
@@ -156,9 +157,9 @@ function map(
error: exact(theme.text.feedback.error.default),
muted: exact(theme.text.subdued),
text: exact(theme.text.default),
shade: exact(theme.background.raised.base),
surface: exact(theme.background.raised.base),
pane: exact(theme.background.raised.high),
shade: exact(elevated.background.default),
surface: exact(elevated.background.default),
pane: exact(theme.contextual.overlay.background.default),
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()
const theme = useTheme("elevated")
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.raised.base}
backgroundColor={theme.background.default}
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().surface("dialog")
const theme = useTheme("elevated")
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().surface("dialog")
const theme = useTheme("elevated")
const syntax = useThemes().currentSyntax
const gutter = createMemo(() =>
props.content
+73 -54
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()
const theme = useTheme("elevated")
const themeMode = themes.mode
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
@@ -75,7 +75,8 @@ export function FormPrompt(props: { form: FormWithLocation }) {
drafts.delete(props.form.id)
const [tabHover, setTabHover] = createSignal<number | "confirm" | null>(null)
const [reviewContentHeight, setReviewContentHeight] = createSignal(0)
const [reviewHeight, setReviewHeight] = createSignal(1)
const [reviewScrollable, setReviewScrollable] = createSignal(false)
const [store, setStore] = createStore<FormDraft>(
draft ?? {
tab: 0,
@@ -91,6 +92,7 @@ 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"]
@@ -139,7 +141,6 @@ 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) : []
@@ -210,7 +211,32 @@ 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
@@ -768,7 +794,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
return (
<box
backgroundColor={theme.background.raised.base}
backgroundColor={theme.background.default}
border={["left"]}
borderColor={theme.hue.interactive[themeMode() === "light" ? 800 : 200]}
customBorderChars={SplitBorder.customBorderChars}
@@ -813,7 +839,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
? theme.background.formfield.selected
: tabHover() === index()
? theme.background.formfield.focused
: theme.background.raised.base
: theme.background.default
}
onMouseOver={() => setTabHover(index())}
onMouseOut={() => setTabHover(null)}
@@ -835,7 +861,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
? theme.background.formfield.selected
: tabHover() === "confirm"
? theme.background.formfield.focused
: theme.background.raised.base
: theme.background.default
}
onMouseOver={() => setTabHover("confirm")}
onMouseOut={() => setTabHover(null)}
@@ -943,7 +969,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
>
<box flexDirection="row">
<box
backgroundColor={active() ? theme.background.formfield.focused : theme.background.raised.base}
backgroundColor={active() ? theme.background.formfield.focused : theme.background.default}
paddingRight={1}
>
<text
@@ -951,7 +977,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
>{`${i() + 1}.`}</text>
</box>
<box
backgroundColor={active() ? theme.background.formfield.focused : theme.background.raised.base}
backgroundColor={active() ? theme.background.formfield.focused : theme.background.default}
flexDirection="row"
>
<Show when={multi()}>
@@ -997,7 +1023,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
>
<box flexDirection="row">
<box
backgroundColor={other() ? theme.background.formfield.focused : theme.background.raised.base}
backgroundColor={other() ? theme.background.formfield.focused : theme.background.default}
paddingRight={1}
>
<text fg={other() ? theme.text.formfield.focused : theme.text.subdued}>
@@ -1007,7 +1033,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
<box
flexDirection="row"
flexGrow={1}
backgroundColor={other() ? theme.background.formfield.focused : theme.background.raised.base}
backgroundColor={other() ? theme.background.formfield.focused : theme.background.default}
>
<Show when={multi()}>
<text
@@ -1076,63 +1102,56 @@ export function FormPrompt(props: { form: FormWithLocation }) {
<Show when={confirm()}>
<scrollbox
maxHeight={reviewMaxHeight()}
contentOptions={{ minHeight: 0 }}
height={reviewHeight()}
scrollbarOptions={{ visible: false }}
ref={(r: ScrollBoxRenderable) => (review = r)}
>
<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])
<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:
invalid() || missing()
? theme.text.feedback.error.default
: answered()
? theme.text.default
: theme.text.subdued,
fg: acknowledged()
? theme.text.feedback.success.default
: theme.text.feedback.error.default,
}}
>
{invalid() ?? (answered() ? value() : missing() ? "(required)" : "(not answered)")}
{acknowledged() ? "Acknowledged" : "(acknowledgement required)"}
</span>
</text>
</box>
)
}}
</For>
</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>
</scrollbox>
</Show>
</box>
@@ -1156,7 +1175,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
{"↑↓"} <span style={{ fg: theme.text.subdued }}>select</span>
</text>
</Show>
<Show when={confirm() && reviewContentHeight() > reviewMaxHeight()}>
<Show when={confirm() && reviewScrollable()}>
<text fg={theme.text.default}>
{"↑↓"} <span style={{ fg: theme.text.subdued }}>scroll</span>
</text>
+28 -21
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, useTheme, useThemes } from "../../context/theme"
import { createSyntaxStyleMemo, ThemeContextProvider, 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()
const theme = useTheme("elevated")
const model = createMemo(
() =>
ctx
@@ -2179,7 +2179,7 @@ function RevertMessage(props: {
}>
}) {
const ctx = use()
const theme = useTheme()
const theme = useTheme("elevated")
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.raised.base}
borderColor={theme.background.default}
>
<box
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
backgroundColor={hover() ? theme.raise(theme.background.raised.base) : theme.background.raised.base}
backgroundColor={hover() ? theme.raise(theme.background.default) : theme.background.default}
>
<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()
const theme = useTheme("elevated")
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.raised.base}
backgroundColor={theme.background.default}
>
<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.raised.base) : theme.background.raised.base}
backgroundColor={hover() ? theme.raise(theme.background.default) : theme.background.default}
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.raised.base,
fg: theme.background.default,
bold: true,
}}
>
{" skill "}
</span>
<span style={{ bg: theme.raise(theme.background.raised.base), fg: theme.text.subdued }}>
<span style={{ bg: theme.raise(theme.background.default), 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.raised.base,
fg: theme.background.default,
bold: true,
}}
>
{` ${label} `}
</span>
<span style={{ bg: theme.raise(theme.background.raised.base), fg: theme.text.subdued }}>
<span style={{ bg: theme.raise(theme.background.default), 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()
const theme = useTheme("elevated")
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.raised.base) : theme.background.raised.base}
backgroundColor={hover() ? theme.raise(theme.background.default) : theme.background.default}
flexDirection="row"
>
<text fg={theme.text.subdued} wrapMode="none" truncate flexGrow={1} flexShrink={1} minWidth={0}>
@@ -2758,11 +2758,10 @@ function InlineTool(props: {
)
}
function StatusBadge(props: { children: string; raised?: boolean }) {
function StatusBadge(props: { children: string }) {
const theme = useTheme()
const background = () => (props.raised ? theme.background.raised.base : theme.background.default)
return (
<text flexShrink={0} bg={theme.raise(background())} fg={theme.text.subdued}>
<text flexShrink={0} bg={theme.raise(theme.background.default)} fg={theme.text.subdued}>
{" "}
{props.children}{" "}
</text>
@@ -2782,8 +2781,16 @@ 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)
@@ -2799,9 +2806,9 @@ function BlockTool(props: BlockToolProps) {
paddingBottom={1}
paddingLeft={2}
gap={1}
backgroundColor={hover() ? theme.raise(background()) : background()}
backgroundColor={hover() ? theme.raise(theme.background.default) : theme.background.default}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.background.default}
borderColor={props.borderColor}
onMouseOver={() => props.onClick && setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={() => {
@@ -3034,7 +3041,7 @@ function ShellDisplay(props: {
</Show>
</Show>
<Show when={props.background}>
<StatusBadge raised>Background</StatusBadge>
<StatusBadge>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()
const theme = useTheme("elevated")
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()
const theme = useTheme("elevated")
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.raised.base}
backgroundColor={theme.background.default}
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.raised.base)}
backgroundColor={theme.raise(theme.background.default)}
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()
const theme = useTheme("elevated")
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.raised.base}
backgroundColor={theme.background.default}
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.raised.base)}
backgroundColor={theme.raise(theme.background.default)}
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()
const theme = useTheme("elevated")
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.raised.base}
backgroundColor={theme.background.default}
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.raised.base}
backdrop={theme.background.default}
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.raised.base,
backgroundColor: theme.background.default,
foregroundColor: theme.scrollbar.default,
},
}}
+13 -15
View File
@@ -1,12 +1,17 @@
import type { RGBA } from "@opentui/core"
import type { Accessor } from "solid-js"
import type { ResolvedTheme, SurfaceName } from "@opencode/theme/tui"
import type { Mode, ResolvedTheme, ResolvedThemeTokens } from "@opencode/theme/tui"
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 {
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>) {
return {
get hue() {
return view().hue
@@ -38,15 +43,8 @@ export function createComponentTheme(
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) => 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
},
raise: (color: RGBA) => (mode() === "light" ? view().increase(color) : view().decrease(color)),
}
}
export type ComponentTheme = ResolvedTheme
export type ComponentTheme = ReturnType<typeof createComponentTheme>
+1 -1
View File
@@ -11,7 +11,7 @@ export type DialogAlertProps = {
export function DialogAlert(props: DialogAlertProps) {
const dialog = useDialog()
const theme = useTheme().surface("dialog")
const theme = useTheme("elevated")
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().surface("dialog")
const theme = useTheme("elevated")
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().surface("dialog")
const overlayTheme = useTheme()
const theme = useTheme("elevated")
const overlayTheme = useTheme("overlay")
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.raised.high}
backgroundColor={overlayTheme.background.default}
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().surface("dialog")
const theme = useTheme("elevated")
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().surface("dialog")
const theme = useTheme("elevated")
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().surface("dialog")
const theme = useTheme("elevated")
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().surface("dialog")
const theme = useTheme("elevated")
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().surface("dialog")
const theme = useTheme("elevated")
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
+4 -6
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 { ThemeContextProvider, useTheme } from "../context/theme"
import { useTheme } from "../context/theme"
import { InputRenderable, MouseButton, Renderable, RGBA } from "@opentui/core"
import { createStore } from "solid-js/store"
import { useToast } from "./toast"
@@ -25,13 +25,12 @@ export function Dialog(
}>,
) {
const dimensions = useTerminalDimensions()
const theme = useTheme().surface("dialog")
const theme = useTheme("elevated")
const renderer = useRenderer()
let dismiss = false
return (
<ThemeContextProvider context="dialog">
<box
<box
onMouseDown={() => {
dismiss = !!renderer.getSelection()
}}
@@ -68,8 +67,7 @@ export function Dialog(
>
{props.children}
</box>
</box>
</ThemeContextProvider>
</box>
)
}
+2 -2
View File
@@ -6,7 +6,7 @@ export function PaneResizeHandle(props: {
left: number
highlight?: "left" | "right"
}) {
const theme = useTheme()
const theme = useTheme("elevated")
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.raised.high : undefined
props.resize.hovered() || props.resize.resizing() ? theme.background.action.primary.hovered : undefined
}
/>
</box>
+2 -2
View File
@@ -22,7 +22,7 @@ function ToastSurface(props: {
onHover?: (hovered: boolean) => void
onActivate: () => void
}) {
const theme = useTheme()
const theme = useTheme("overlay")
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.raised.high}
backgroundColor={theme.background.default}
>
<Show
when={props.toast.title}

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