Compare commits

..
Author SHA1 Message Date
James Long 681338c533 refactor(theme): detect theme format by fields (#49853) 2026-09-18 17:19:21 -04:00
Dax 5464aa4a74 feat(core): add autonomous goal command (#45379) 2026-09-18 20:59:08 +00:00
James Long 36083c9520 docs(www): document theme format and workflow (#49855) 2026-09-18 16:46:32 -04:00
James Long 168aba6183 feat(theme): register native V2 opencode theme (#49846) 2026-09-18 16:33:59 -04:00
Kit Langton ec19dc09c7 fix(sdk): support Node package imports (#49810) 2026-09-18 13:10:59 -07:00
Aiden Cline fca4701688 fix(ai): allow future Anthropic option values (#49835) 2026-09-18 14:24:11 -05:00
James Long c720ca20ed refactor(theme): rename default tokens to base (#49837) 2026-09-18 15:20:15 -04:00
James Long dae1e8abbd docs(www): finalize background color (#49203) 2026-09-18 15:07:49 -04:00
James Long ffe4d1522c refactor(theme): derive V1 hue angles from swatches (#49839) 2026-09-18 15:00:09 -04:00
James Long b1860465cd refactor(theme): split base and mode overrides (#49826) 2026-09-18 14:48:27 -04:00
CestercianandAiden Cline cab11795e5 fix(tui): pass markdown renderer into /btw answer dialog (#49804)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-09-18 13:32:11 -05:00
James Long 810e79a6d4 refactor(theme): align hue direction with mode (#49820) 2026-09-18 13:29:19 -04:00
James Long d81604714f feat(theme): resolve state precedence (#49813) 2026-09-18 12:55:30 -04:00
James Long d1e828c217 refactor(theme): replace theme contexts with a dialog surface (#49661) 2026-09-18 12:47:21 -04:00
Aiden Clineandrekram1-node 437cdc03a9 feat(core): repair malformed tool arguments before validation (#45002)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-18 10:26:02 -05:00
Aiden Cline 219ecada68 refactor(core): move provider policies into settings (#49666) 2026-09-18 10:18:33 -05:00
Dax Raad 9e1fa80fb7 refactor(tui): simplify scrollbox sizing 2026-09-18 11:17:42 -04:00
James Long 278db3023f fix(core): bound consumed job results (#47030) 2026-09-18 10:52:34 -04:00
Dax Raad c2067d59af fix(tui): simplify btw dialog sizing 2026-09-18 10:27:57 -04:00
Shoubhit Dash f5d40d6f30 fix(ai): keep valid Responses provider options when a sibling is malformed (#49769) 2026-09-18 18:59:16 +05:30
Shoubhit Dash c8e654410d refactor(ai): decode Anthropic and Gemini provider options with one schema (#49760) 2026-09-18 18:14:20 +05:30
172 changed files with 4380 additions and 3238 deletions
+1 -1
View File
@@ -12,7 +12,7 @@
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `LanguageModel.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, and `LLM.generateObject`. Use `LLMRequest.update(...)` when deriving canonical request data; do not add a duplicate `LLM.updateRequest(...)` path. Two ways to construct the same thing is one too many.
- Keep provider-defined string enums forward-compatible. Expose known values for autocomplete while accepting future values with `Known | (string & {})`; use `Schema.String` at runtime unless rejecting unknown values is required for correctness.
- Prefer forward compatibility for provider-defined options that OpenCode only passes through. For pass-through string enums, expose known values for autocomplete while accepting future values with `Known | (string & {})`, and accept any string at runtime. Closed literals are appropriate when OpenCode branches on a value, transforms its associated structure, or otherwise cannot correctly handle an unknown variant. New options whose shape or behavior requires implementation remain unsupported until they are handled; do not blindly forward unknown structures.
- Order reasoning-effort values from lowest to highest: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. Provider-specific subsets follow the same relative order in types, schemas, option lists, and tests.
## Tests
+2 -2
View File
@@ -8,7 +8,7 @@ import { OpenResponsesOptions } from "./utils/open-responses-options.js"
export type ReasoningEffort = OpenResponsesOptions.ReasoningEffort
const Options = Schema.Struct({
reasoningEffort: OpenResponsesOptions.Options.fields.reasoningEffort,
reasoningEffort: Schema.optional(OpenResponsesOptions.ReasoningEffort),
enableThinking: Schema.optional(Schema.Boolean),
thinkingBudget: Schema.optional(Schema.Int),
preserveThinking: Schema.optional(Schema.Boolean),
@@ -19,7 +19,7 @@ const Options = Schema.Struct({
}),
),
toolStream: Schema.optional(Schema.Boolean),
parallelToolCalls: OpenResponsesOptions.Options.fields.parallelToolCalls,
parallelToolCalls: Schema.optional(Schema.Boolean),
repetitionPenalty: Schema.optional(Schema.Number),
responseFormat: Schema.optional(
Schema.Struct({
@@ -6,9 +6,9 @@ import { OpenResponsesOptions } from "./utils/open-responses-options.js"
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
const Options = Schema.Struct({
reasoningEffort: OpenResponsesOptions.Options.fields.reasoningEffort,
reasoningEffort: Schema.optional(OpenResponsesOptions.ReasoningEffort),
enableThinking: Schema.optional(Schema.Boolean),
store: OpenResponsesOptions.Options.fields.store,
store: Schema.optional(Schema.Boolean),
previousResponseId: Schema.optional(Schema.String),
conversation: Schema.optional(Schema.String),
})
+79 -167
View File
@@ -1,5 +1,5 @@
import { Buffer } from "node:buffer"
import { Effect, Option, Schema } from "effect"
import { Effect, Option, Schema, SchemaGetter } from "effect"
import { Tool } from "@opencode/schema/tool"
import { Route } from "../route/client.js"
import { Auth } from "../route/auth.js"
@@ -21,11 +21,12 @@ import {
type JsonSchema,
type MediaPart,
type ProviderMetadata,
type ProviderOptions,
type ToolCallPart,
type ToolDefinition,
type ToolResultPart,
} from "../schema/index.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { JsonObject, knownString, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { classifyProviderFailure } from "../provider-error.js"
import { effortUpdate, resolveEffortUpdates } from "../effort-updates.js"
import * as Cache from "./utils/cache.js"
@@ -52,57 +53,10 @@ const SSE_EVENTS = new Set([
])
export const framing = Framing.sseEvents(SSE_EVENTS)
export type ThinkingBlockBinding = {
readonly prefix_mismatch_behavior?: "error" | "drop_block" | (string & {})
}
export type ThinkingInput =
| {
readonly type: "adaptive"
readonly display?: "summarized" | "omitted"
readonly block_binding?: ThinkingBlockBinding
}
| {
readonly type: "disabled"
}
| ({
readonly type: "enabled"
readonly display?: "summarized" | "omitted"
readonly block_binding?: ThinkingBlockBinding
} & (
| { readonly budgetTokens: number; readonly budget_tokens?: number }
| { readonly budgetTokens?: number; readonly budget_tokens: number }
))
export interface OptionsInput {
/** Advanced in-band compaction. The caller owns checkpoint persistence and recovery. */
readonly contextManagement?: ContextManagement
readonly [key: string]: unknown
readonly thinking?: ThinkingInput
readonly effort?: string
readonly service_tier?: "auto" | "standard_only"
readonly serviceTier?: "auto" | "standard_only"
// SDK Metadata:2649 {user_id?: string | null}
readonly metadata?: { readonly user_id?: string | null }
// SDK MessageCreateParamsContainer:2596 ContainerParams|string
readonly container?:
| string
| { readonly id?: string | null; readonly skills?: ReadonlyArray<Record<string, unknown>> | null }
readonly inference_geo?: string | null
readonly inferenceGeo?: string | null
readonly cache_control?: { readonly type: "ephemeral"; readonly ttl?: "5m" | "1h" }
readonly cacheControl?: { readonly type: "ephemeral"; readonly ttl?: "5m" | "1h" }
// SDK OutputConfig:2684 {effort, format: JSONOutputFormat}
readonly output_config?: {
readonly effort?: string | null
readonly format?: { readonly type: "json_schema"; readonly schema: Record<string, unknown> } | null
}
readonly outputConfig?: {
readonly effort?: string | null
readonly format?: { readonly type: "json_schema"; readonly schema: Record<string, unknown> } | null
}
}
export type ThinkingBlockBinding = typeof AnthropicThinkingBlockBinding.Type
export type ThinkingInput = typeof Thinking.Encoded
/** Caller-facing provider options; unknown keys are accepted and ignored. `Options.Type` is the wire-ready form. */
export type OptionsInput = ProviderOptions & typeof Options.Encoded
export type ProviderOptionsInput = OptionsInput
export const ContextManagement = Schema.Struct({
@@ -129,6 +83,7 @@ const AnthropicCacheControl = Schema.Struct({
type: Schema.tag("ephemeral"),
ttl: Schema.optional(Schema.Literals(["5m", "1h"])),
})
const AnthropicServiceTier = knownString<"auto" | "standard_only">()
const AnthropicTextBlock = Schema.Struct({
type: Schema.tag("text"),
@@ -317,25 +272,21 @@ const AnthropicToolChoice = Schema.Union([
])
const AnthropicThinkingBlockBinding = Schema.Struct({
prefix_mismatch_behavior: Schema.optional(Schema.String),
prefix_mismatch_behavior: Schema.optional(knownString<"error" | "drop_block">()),
})
const AnthropicThinking = Schema.Union([
Schema.Struct({
type: Schema.tag("enabled"),
budget_tokens: Schema.Number,
display: Schema.optional(Schema.Literals(["summarized", "omitted"])),
block_binding: Schema.optional(AnthropicThinkingBlockBinding),
}),
Schema.Struct({
type: Schema.tag("adaptive"),
display: Schema.optional(Schema.Literals(["summarized", "omitted"])),
block_binding: Schema.optional(AnthropicThinkingBlockBinding),
}),
Schema.Struct({
type: Schema.tag("disabled"),
}),
])
const AnthropicThinkingFields = {
display: Schema.optional(knownString<"summarized" | "omitted">()),
block_binding: Schema.optional(AnthropicThinkingBlockBinding),
}
const AnthropicThinkingEnabled = Schema.Struct({
type: Schema.tag("enabled"),
budget_tokens: Schema.Number,
...AnthropicThinkingFields,
})
const AnthropicThinkingAdaptive = Schema.Struct({ type: Schema.tag("adaptive"), ...AnthropicThinkingFields })
const AnthropicThinkingDisabled = Schema.Struct({ type: Schema.tag("disabled") })
const AnthropicThinking = Schema.Union([AnthropicThinkingEnabled, AnthropicThinkingAdaptive, AnthropicThinkingDisabled])
type AnthropicThinking = typeof AnthropicThinking.Type
// SDK OutputConfig:2684 {effort?: "low"|"medium"|"high"|"xhigh"|"max"|null, format?: JSONOutputFormat:2399}
@@ -360,6 +311,53 @@ const AnthropicContainer = Schema.Union([
}),
])
// =============================================================================
// Provider Options
// =============================================================================
// Callers spell the budget as `budgetTokens` or the wire `budget_tokens`; the
// keys are disjoint per variant so the input type requires exactly one and the
// transform can narrow on it. Decoding straight to the wire block keeps the
// alias out of the rest of the file.
const ThinkingEnabledInput = Schema.Union([
Schema.Struct({ type: Schema.tag("enabled"), budgetTokens: Schema.Number, ...AnthropicThinkingFields }),
Schema.Struct({ type: Schema.tag("enabled"), budget_tokens: Schema.Number, ...AnthropicThinkingFields }),
]).pipe(
Schema.decodeTo(AnthropicThinkingEnabled, {
decode: SchemaGetter.transform((input) => ({
type: "enabled" as const,
budget_tokens: "budgetTokens" in input ? input.budgetTokens : input.budget_tokens,
display: input.display,
block_binding: input.block_binding,
})),
encode: SchemaGetter.passthrough({ strict: false }),
}),
)
const Thinking = Schema.Union([ThinkingEnabledInput, AnthropicThinkingAdaptive, AnthropicThinkingDisabled])
const OutputConfigInput = Schema.Struct({
effort: optionalNull(Schema.String),
format: optionalNull(AnthropicJsonOutputFormat),
})
// Both key spellings are accepted; `fromRequest` prefers the snake_case one.
const Options = Schema.Struct({
/** Advanced in-band compaction. The caller owns checkpoint persistence and recovery. */
contextManagement: Schema.optional(ContextManagement),
thinking: Schema.optional(Thinking),
effort: Schema.optional(Schema.String),
service_tier: Schema.optional(AnthropicServiceTier),
serviceTier: Schema.optional(AnthropicServiceTier),
metadata: Schema.optional(AnthropicMetadata),
container: Schema.optional(AnthropicContainer),
inference_geo: optionalNull(Schema.String),
inferenceGeo: optionalNull(Schema.String),
cache_control: Schema.optional(AnthropicCacheControl),
cacheControl: Schema.optional(AnthropicCacheControl),
output_config: Schema.optional(OutputConfigInput),
outputConfig: Schema.optional(OutputConfigInput),
})
const decodeOptions = ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))
const AnthropicBodyFields = {
context_management: Schema.optional(
Schema.Struct({
@@ -391,7 +389,7 @@ const AnthropicBodyFields = {
container: Schema.optional(Schema.NullOr(AnthropicContainer)),
inference_geo: Schema.optional(Schema.NullOr(Schema.String)),
metadata: Schema.optional(AnthropicMetadata),
service_tier: Schema.optional(Schema.Literals(["auto", "standard_only"])),
service_tier: Schema.optional(AnthropicServiceTier),
}
export const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields)
export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesBody>
@@ -1001,64 +999,6 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
return messages
})
const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* (request: LLMRequest) {
const input = request.providerOptions as Record<string, unknown> | undefined
const rawServiceTier =
(input as Record<string, unknown> | undefined)?.service_tier ??
(input as Record<string, unknown> | undefined)?.serviceTier
const service_tier =
rawServiceTier === "auto" || rawServiceTier === "standard_only"
? (rawServiceTier as "auto" | "standard_only")
: undefined
const rawMetadata = (input as Record<string, unknown> | undefined)?.metadata
const metadata =
ProviderShared.isRecord(rawMetadata) && (typeof rawMetadata.user_id === "string" || rawMetadata.user_id === null)
? { user_id: rawMetadata.user_id as string | null }
: undefined
const container =
typeof (input as Record<string, unknown> | undefined)?.container === "string" ||
ProviderShared.isRecord((input as Record<string, unknown> | undefined)?.container)
? ((input as Record<string, unknown>).container as
| string
| { id?: string | null; skills?: ReadonlyArray<Record<string, unknown>> | null })
: undefined
const rawInferenceGeo =
(input as Record<string, unknown> | undefined)?.inference_geo ??
(input as Record<string, unknown> | undefined)?.inferenceGeo
const inference_geo = typeof rawInferenceGeo === "string" ? rawInferenceGeo : undefined
const rawCacheControl =
(input as Record<string, unknown> | undefined)?.cache_control ??
(input as Record<string, unknown> | undefined)?.cacheControl
const cache_control =
ProviderShared.isRecord(rawCacheControl) && rawCacheControl.type === "ephemeral"
? (rawCacheControl as { type: "ephemeral"; ttl?: "5m" | "1h" })
: undefined
const rawOutputConfig =
(input as Record<string, unknown> | undefined)?.output_config ??
(input as Record<string, unknown> | undefined)?.outputConfig
const outputConfigEffort =
typeof (input as Record<string, unknown> | undefined)?.effort === "string"
? ((input as Record<string, unknown>).effort as string)
: ProviderShared.isRecord(rawOutputConfig) && typeof rawOutputConfig.effort === "string"
? (rawOutputConfig.effort as string)
: undefined
const outputConfigFormat =
ProviderShared.isRecord(rawOutputConfig) && ProviderShared.isRecord(rawOutputConfig.format)
? (rawOutputConfig.format as { type: "json_schema"; schema: Record<string, unknown> })
: undefined
const thinking = yield* resolveThinking(input?.thinking)
return {
thinking: applyThinkingBindingDefault(request.model, thinking),
effort: outputConfigEffort,
format: outputConfigFormat,
service_tier,
metadata,
container,
inference_geo,
cache_control,
}
})
// Accept gateway namespaces and Vertex suffixes without treating a snapshot date as a minor version.
const claudeVersion = (id: string) => {
const match = /(?:^|[./])claude-(?<family>[a-z]+)-(?<major>\d+)(?:[.-](?<minor>\d{1,2}))?(?:$|[-:@])/.exec(
@@ -1097,35 +1037,12 @@ const applyThinkingBindingDefault = (model: LLMRequest["model"], thinking: Anthr
}
}
const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function* (input: unknown) {
if (!ProviderShared.isRecord(input)) return undefined
if (input.type === "disabled") return { type: "disabled" as const }
if (input.type !== "adaptive" && input.type !== "enabled") return undefined
const block_binding = yield* ProviderShared.validateWith(
Schema.decodeUnknownEffect(Schema.UndefinedOr(AnthropicThinkingBlockBinding)),
)(input.block_binding)
const display =
input.display === "summarized" || input.display === "omitted"
? (input.display as "summarized" | "omitted")
: undefined
if (input.type === "adaptive") return { type: "adaptive" as const, display, block_binding }
const budget =
typeof input.budgetTokens === "number"
? input.budgetTokens
: typeof input.budget_tokens === "number"
? input.budget_tokens
: undefined
if (budget === undefined)
return yield* ProviderShared.invalidRequest("Anthropic thinking provider option requires budgetTokens")
return { type: "enabled" as const, budget_tokens: budget, display, block_binding }
})
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
const management = yield* ProviderShared.validateWith(
Schema.decodeUnknownEffect(Schema.UndefinedOr(ContextManagement)),
)(request.providerOptions?.contextManagement)
const options = yield* resolveOptions(request)
const updates = resolveEffortUpdates(request, options.effort)
const options = yield* decodeOptions(request.providerOptions ?? {})
const management = options.contextManagement
const outputConfig = options.output_config ?? options.outputConfig
const format = outputConfig?.format ?? undefined
const updates = resolveEffortUpdates(request, options.effort ?? outputConfig?.effort ?? undefined)
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
// Allocate the 4-breakpoint budget in invalidation order: tools → system →
@@ -1161,12 +1078,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
)
}
const output_config =
updates.effort === undefined && options.format === undefined
? undefined
: {
...(updates.effort === undefined ? {} : { effort: updates.effort }),
...(options.format === undefined ? {} : { format: options.format }),
}
updates.effort === undefined && format === undefined ? undefined : { effort: updates.effort, format }
const body = {
model: request.model.id,
system,
@@ -1179,14 +1091,14 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
top_p: generation?.topP,
top_k: generation?.topK,
stop_sequences: generation?.stop,
thinking: options.thinking,
thinking: applyThinkingBindingDefault(request.model, options.thinking),
output_config,
// top-level passthrough per SDK MessageCreateParamsBase:4638,4643,4649,4654,4670
cache_control: options.cache_control,
cache_control: options.cache_control ?? options.cacheControl,
container: options.container,
inference_geo: options.inference_geo,
inference_geo: options.inference_geo ?? options.inferenceGeo ?? undefined,
metadata: options.metadata,
service_tier: options.service_tier,
service_tier: options.service_tier ?? options.serviceTier,
}
if (!management) return body
return {
+45 -68
View File
@@ -14,12 +14,13 @@ import {
type LLMRequest,
type MediaPart,
type ProviderMetadata,
type ProviderOptions,
type TextPart,
type ToolCallPart,
type ToolDefinition,
} from "../schema/index.js"
import { classifyProviderFailure } from "../provider-error.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { JsonObject, knownString, lenient, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { GeminiToolSchema } from "./utils/gemini-tool-schema.js"
import { Lifecycle } from "./utils/lifecycle.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
@@ -50,35 +51,8 @@ const omitsFunctionCallIds = (modelID: string) => {
return match !== null && Number(match[1]) < 3
}
export interface OptionsInput {
readonly [key: string]: unknown
readonly cachedContent?: string
readonly safetySettings?: ReadonlyArray<{
readonly category:
| "HARM_CATEGORY_UNSPECIFIED"
| "HARM_CATEGORY_HATE_SPEECH"
| "HARM_CATEGORY_DANGEROUS_CONTENT"
| "HARM_CATEGORY_HARASSMENT"
| "HARM_CATEGORY_SEXUALLY_EXPLICIT"
| "HARM_CATEGORY_CIVIC_INTEGRITY"
| (string & {})
readonly threshold:
| "HARM_BLOCK_THRESHOLD_UNSPECIFIED"
| "BLOCK_LOW_AND_ABOVE"
| "BLOCK_MEDIUM_AND_ABOVE"
| "BLOCK_ONLY_HIGH"
| "BLOCK_NONE"
| "OFF"
| (string & {})
}>
readonly serviceTier?: "standard" | "flex" | "priority" | (string & {})
readonly thinkingConfig?: {
readonly thinkingBudget?: number
readonly includeThoughts?: boolean
readonly thinkingLevel?: "minimal" | "low" | "medium" | "high" | (string & {})
}
}
/** Caller-facing provider options; unknown keys are accepted and ignored. */
export type OptionsInput = ProviderOptions & typeof Options.Encoded
export type ProviderOptionsInput = OptionsInput
// =============================================================================
@@ -161,17 +135,50 @@ const GeminiToolConfig = Schema.Struct({
}),
})
const GeminiThinkingLevel = knownString<"minimal" | "low" | "medium" | "high">()
const GeminiThinkingConfig = Schema.Struct({
thinkingBudget: Schema.optional(Schema.Number),
includeThoughts: Schema.optional(Schema.Boolean),
thinkingLevel: Schema.optional(Schema.String),
thinkingLevel: Schema.optional(GeminiThinkingLevel),
})
const GeminiSafetySetting = Schema.Struct({
category: Schema.String,
threshold: Schema.String,
category: knownString<
| "HARM_CATEGORY_UNSPECIFIED"
| "HARM_CATEGORY_HATE_SPEECH"
| "HARM_CATEGORY_DANGEROUS_CONTENT"
| "HARM_CATEGORY_HARASSMENT"
| "HARM_CATEGORY_SEXUALLY_EXPLICIT"
| "HARM_CATEGORY_CIVIC_INTEGRITY"
>(),
threshold: knownString<
| "HARM_BLOCK_THRESHOLD_UNSPECIFIED"
| "BLOCK_LOW_AND_ABOVE"
| "BLOCK_MEDIUM_AND_ABOVE"
| "BLOCK_ONLY_HIGH"
| "BLOCK_NONE"
| "OFF"
>(),
})
// =============================================================================
// Provider Options
// =============================================================================
// Malformed fields are dropped rather than failing the request; a `thinkingConfig`
// object that omits `includeThoughts` asks for thoughts.
const GeminiThinkingConfigInput = Schema.Struct({
thinkingBudget: lenient(Schema.Number),
includeThoughts: lenient(Schema.Boolean),
thinkingLevel: lenient(GeminiThinkingLevel),
})
const Options = Schema.Struct({
cachedContent: lenient(Schema.String),
safetySettings: lenient(Schema.Array(GeminiSafetySetting)),
serviceTier: lenient(knownString<"standard" | "flex" | "priority">()),
thinkingConfig: lenient(GeminiThinkingConfigInput),
})
const decodeOptions = ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))
const GeminiGenerationConfig = Schema.Struct({
maxOutputTokens: Schema.optional(Schema.Number),
temperature: Schema.optional(Schema.Number),
@@ -431,44 +438,11 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
return contents
})
const resolveOptions = (request: LLMRequest) => {
const input = request.providerOptions
const value = input?.thinkingConfig
const thinkingConfig = {
thinkingBudget:
ProviderShared.isRecord(value) && typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined,
includeThoughts:
ProviderShared.isRecord(value) && typeof value.includeThoughts === "boolean"
? value.includeThoughts
: ProviderShared.isRecord(value)
? true
: undefined,
thinkingLevel:
ProviderShared.isRecord(value) && typeof value.thinkingLevel === "string" ? value.thinkingLevel : undefined,
}
return {
cachedContent: typeof input?.cachedContent === "string" ? input.cachedContent : undefined,
safetySettings: mapSafetySettings(input?.safetySettings),
serviceTier: typeof input?.serviceTier === "string" ? input.serviceTier : undefined,
thinkingConfig: Object.values(thinkingConfig).some((item) => item !== undefined) ? thinkingConfig : undefined,
}
}
function mapSafetySettings(value: unknown) {
if (!Array.isArray(value)) return undefined
const settings = value.flatMap((item) =>
ProviderShared.isRecord(item) && typeof item.category === "string" && typeof item.threshold === "string"
? [{ category: item.category, threshold: item.threshold }]
: [],
)
return settings
}
const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) {
const flattened = ProviderShared.flattenToolRequest(request)
const hasTools = flattened.tools.length > 0
const generation = request.generation
const options = resolveOptions(request)
const options = yield* decodeOptions(request.providerOptions ?? {})
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const generationConfig = {
maxOutputTokens: generation?.maxTokens,
@@ -479,7 +453,10 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
presencePenalty: generation?.presencePenalty,
seed: generation?.seed,
stopSequences: generation?.stop,
thinkingConfig: options.thinkingConfig,
thinkingConfig:
options.thinkingConfig === undefined
? undefined
: { ...options.thinkingConfig, includeThoughts: options.thinkingConfig.includeThoughts ?? true },
}
return {
+11 -1
View File
@@ -1,6 +1,6 @@
import { Buffer } from "node:buffer"
import { Tool } from "@opencode/schema/tool"
import { Effect, Schema, Stream } from "effect"
import { Effect, Option, Schema, Stream } from "effect"
import * as Sse from "effect/unstable/encoding/Sse"
import { Headers, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import {
@@ -29,6 +29,16 @@ const isJson = Schema.is(Schema.Json)
export const JsonObject = Schema.Record(Schema.String, Schema.Unknown)
export const optionalArray = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.Array(schema))
export const optionalNull = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.NullOr(schema))
/** Optional field whose malformed value decodes to `undefined` instead of failing the enclosing struct. */
export const lenient = <const S extends Schema.Top>(schema: S) =>
Schema.optionalKey(
Schema.UndefinedOr(schema).pipe(Schema.catchDecoding(() => Effect.succeed(Option.some(undefined)))),
)
/** Provider-defined string enum: known values for autocomplete, any string accepted at runtime. */
export const knownString = <Known extends string>() =>
Schema.declare<Known | (string & {})>((value): value is Known | (string & {}) => typeof value === "string", {
expected: "string",
})
export const OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH = 64
@@ -1,5 +1,6 @@
import { Option, Schema } from "effect"
import { Schema } from "effect"
import { ReasoningEffort, ReasoningEfforts, type LLMRequest } from "../../schema/index.js"
import { lenient } from "../shared.js"
export { ReasoningEffort, ReasoningEfforts }
@@ -49,21 +50,22 @@ export const StreamOptions = Schema.Struct({
includeObfuscation: Schema.optional(Schema.Boolean),
})
// Malformed options are dropped one at a time so a bad `topLogprobs` cannot discard `store` or `reasoningEffort`.
export const Options = Schema.Struct({
store: Schema.optional(Schema.Boolean),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
safetyIdentifier: Schema.optional(Schema.String),
streamOptions: Schema.optional(StreamOptions),
topLogprobs: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 20 }))),
reasoningEffort: Schema.optional(ReasoningEffort),
reasoningSummary: Schema.optional(Schema.Literals(["auto", "concise", "detailed"])),
include: Schema.optional(Schema.Array(ResponseIncludableSchema)),
textVerbosity: Schema.optional(TextVerbositySchema),
serviceTier: Schema.optional(ServiceTierSchema),
truncation: Schema.optional(TruncationSchema),
allowedTools: Schema.optional(AllowedTools),
maxToolCalls: Schema.optional(Schema.Int),
parallelToolCalls: Schema.optional(Schema.Boolean),
store: lenient(Schema.Boolean),
metadata: lenient(Schema.Record(Schema.String, Schema.String)),
safetyIdentifier: lenient(Schema.String),
streamOptions: lenient(StreamOptions),
topLogprobs: lenient(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 20 }))),
reasoningEffort: lenient(ReasoningEffort),
reasoningSummary: lenient(Schema.Literals(["auto", "concise", "detailed"])),
include: lenient(Schema.Array(ResponseIncludableSchema)),
textVerbosity: lenient(TextVerbositySchema),
serviceTier: lenient(ServiceTierSchema),
truncation: lenient(TruncationSchema),
allowedTools: lenient(AllowedTools),
maxToolCalls: lenient(Schema.Int),
parallelToolCalls: lenient(Schema.Boolean),
})
export type Options = typeof Options.Type
@@ -71,11 +73,10 @@ export type Resolved = Omit<Options, "allowedTools"> & {
readonly allowedTools?: AllowedTools & { readonly mode: NonNullable<AllowedTools["mode"]> }
}
const decodeOptions = Schema.decodeUnknownOption(Options)
const decodeOptions = Schema.decodeUnknownSync(Options)
export const resolve = (request: LLMRequest): Resolved => {
const input = Option.getOrUndefined(decodeOptions(request.providerOptions))
if (!input) return {}
const input = decodeOptions(request.providerOptions ?? {})
return {
...input,
include: input.include?.length ? input.include : undefined,
@@ -4,6 +4,22 @@ import { Anthropic } from "../../src/providers.js"
const model = Anthropic.provider.model("claude-sonnet-4-5")
LLM.request({ model, prompt: "Hello", providerOptions: { thinking: { type: "adaptive" } } })
LLM.request({
model,
prompt: "Hello",
providerOptions: {
serviceTier: "future-tier",
thinking: { type: "adaptive", display: "future-display" },
},
})
LLM.request({
model,
prompt: "Hello",
providerOptions: {
// @ts-expect-error Anthropic cache TTL values are protocol constraints.
cacheControl: { type: "ephemeral", ttl: "future-ttl" },
},
})
LLM.request({
model,
@@ -166,7 +166,89 @@ describe("Anthropic Messages route", () => {
}),
).pipe(Effect.flip)
expect(error.message).toContain("Anthropic thinking provider option requires budgetTokens")
expect(error.reason._tag).toBe("InvalidRequest")
expect(error.message).toContain("budgetTokens")
}),
)
it.effect("lowers passthrough provider options and accepts either key spelling", () =>
Effect.gen(function* () {
const snake = yield* compileRequest(
LLMRequest.update(request, {
providerOptions: {
service_tier: "auto",
metadata: { user_id: "user_1" },
container: { id: "container_1" },
inference_geo: "us",
cache_control: { type: "ephemeral", ttl: "1h" },
output_config: { format: { type: "json_schema", schema: { type: "object" } } },
},
}),
)
const camel = yield* compileRequest(
LLMRequest.update(request, {
providerOptions: {
serviceTier: "standard_only",
container: "container_2",
inferenceGeo: "eu",
cacheControl: { type: "ephemeral" },
outputConfig: { effort: "low" },
},
}),
)
expect(snake.body).toMatchObject({
service_tier: "auto",
metadata: { user_id: "user_1" },
container: { id: "container_1" },
inference_geo: "us",
cache_control: { type: "ephemeral", ttl: "1h" },
output_config: { format: { type: "json_schema", schema: { type: "object" } } },
})
expect(camel.body).toMatchObject({
service_tier: "standard_only",
container: "container_2",
inference_geo: "eu",
cache_control: { type: "ephemeral" },
output_config: { effort: "low" },
})
}),
)
it.effect("forwards unknown values for pass-through string enums", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLMRequest.update(request, {
providerOptions: {
service_tier: "future-tier",
thinking: { type: "adaptive", display: "future-display" },
},
}),
)
expect(prepared.body).toMatchObject({
service_tier: "future-tier",
thinking: { type: "adaptive", display: "future-display" },
})
}),
)
it.effect("ignores unknown provider options and rejects malformed known ones", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(LLMRequest.update(request, { providerOptions: { unknownOption: true } }))
const malformed = [
{ service_tier: 1 },
{ metadata: { user_id: 42 } },
{ cache_control: { type: "ephemeral", ttl: "future-ttl" } },
{ output_config: { format: { type: "text" } } },
{ thinking: { type: "automatic" } },
]
const errors = yield* Effect.forEach(malformed, (providerOptions) =>
compileRequest(LLMRequest.update(request, { providerOptions })).pipe(Effect.flip),
)
expect(prepared.body).not.toHaveProperty("unknownOption")
expect(errors.map((error) => error.reason._tag)).toEqual(malformed.map(() => "InvalidRequest"))
}),
)
@@ -248,6 +248,21 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("keeps valid Chat options when a sibling option is malformed", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).chat("gpt-4o-mini"),
prompt: "think",
providerOptions: { store: true, reasoningEffort: "max", topLogprobs: 25 },
}),
)
expect(prepared.body.store).toBe(true)
expect(prepared.body.reasoning_effort).toBe("max")
}),
)
it.effect("maps the request prompt cache key when the compatibility flag is set", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -1945,6 +1945,30 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("drops a malformed provider option without discarding its siblings", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
prompt: "hi",
providerOptions: {
topLogprobs: 25,
metadata: { tenant: 7 },
reasoningEffort: "high",
serviceTier: "priority",
maxToolCalls: 4,
},
}),
)
expect(prepared.body.top_logprobs).toBeUndefined()
expect(prepared.body.metadata).toBeUndefined()
expect(prepared.body.reasoning).toEqual({ effort: "high" })
expect(prepared.body.service_tier).toBe("priority")
expect(prepared.body.max_tool_calls).toBe(4)
}),
)
it.effect("accepts the full ResponseIncludable union", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
+5 -5
View File
@@ -9,7 +9,7 @@ import { Keymap } from "../../tui/src/context/keymap"
export function ErrorOverlay(props: { component: string; error: unknown; onClose: () => void }) {
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const theme = useTheme("elevated")
const theme = useTheme().surface("dialog")
const focus = renderer.currentFocusedRenderable
onCleanup(Keymap.use().mode.push("modal"))
Keymap.createLayer(() => ({
@@ -36,17 +36,17 @@ export function ErrorOverlay(props: { component: string; error: unknown; onClose
<Dialog centered onClose={props.onClose}>
<box maxHeight={Math.max(1, dimensions().height - 3)} paddingX={2} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between" flexShrink={0}>
<text fg={theme.text.feedback.error.default} attributes={TextAttributes.BOLD}>
<text fg={theme.text.feedback.error.base} attributes={TextAttributes.BOLD}>
Error while hot reloading
</text>
<text fg={theme.text.subdued} onMouseUp={props.onClose}>
<text fg={theme.text.muted} onMouseUp={props.onClose}>
esc
</text>
</box>
<text maxHeight={Math.max(1, dimensions().height - 9)} fg={theme.text.default}>
<text maxHeight={Math.max(1, dimensions().height - 9)} fg={theme.text.base}>
{props.error instanceof Error ? props.error.message : String(props.error)}
</text>
<text flexShrink={0} fg={theme.text.subdued}>
<text flexShrink={0} fg={theme.text.muted}>
{props.component} · Fix the component and save to retry.
</text>
</box>
+18
View File
@@ -1,9 +1,27 @@
#!/usr/bin/env bun
import { $ } from "bun"
import path from "node:path"
import { fileURLToPath } from "node:url"
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
await $`rm -rf dist`
await $`bun tsc -p tsconfig.build.json`
const root = path.resolve("src")
const result = await Bun.build({
entrypoints: await Array.fromAsync(new Bun.Glob("**/*.ts").scan({ cwd: root, absolute: true })).then((files) =>
files.filter((file) => !file.endsWith(".d.ts")),
),
root,
outdir: "dist",
target: "node",
format: "esm",
packages: "external",
splitting: true,
naming: {
entry: "[dir]/[name].[ext]",
chunk: "chunks/[name]-[hash].[ext]",
},
})
if (!result.success) throw new AggregateError(result.logs, "Failed to build Client")
+65 -61
View File
@@ -8,7 +8,9 @@ export type LocationPublicRef = { directory: string }
export type ModelRef = { id: string; providerID: string; variant?: string }
export type ProviderSettings = { [x: string]: any }
export type ProviderCompaction = { type: "summary" } | { type: "native" }
export type ProviderTransport = "http" | "websocket"
export type AgentColor = string
@@ -218,19 +220,8 @@ export type ModelReasoningField = "reasoning" | "reasoning_content" | "reasoning
export type ModelMaxTokensField = "max_completion_tokens" | "max_tokens"
export type ProviderCompaction = { mode: "local" } | { mode: "provider"; threshold?: number }
export type ProviderTransport = "http" | "websocket"
export type ModelCapabilities = { tools: boolean; input: Array<string>; output: Array<string> }
export type ModelVariant = {
id: string
settings?: { [x: string]: any }
headers?: { [x: string]: string }
body?: { [x: string]: any }
}
export type MoneyUSDPerMillionTokens = number
export type GenerateTextResponse = { data: { text: string } }
@@ -465,11 +456,19 @@ export type V2EventServerConnected = {
data: {}
}
export type ProviderRequest = {
settings: ProviderSettings
headers: { [x: string]: string }
body: { [x: string]: any }
}
export type ProviderSettings = {
timeout?: number | false
chunkTimeout?: number
compaction?: ProviderCompaction
transport?: ProviderTransport
} & { [x: string]: any }
export type ConfigProviderSettings = {
timeout?: number | false
chunkTimeout?: number
compaction?: ProviderCompaction
transport?: ProviderTransport
} & { [x: string]: JsonValue | null }
export type PermissionRule = { action: string; resource: string; effect: PermissionEffect }
@@ -1443,20 +1442,6 @@ export type ModelCompatibility = {
supportsPromptCacheKey?: boolean
}
export type ProviderInfo = {
id: string
canonical?: string
integrationID?: string
name: string
activation: "auto" | "enabled" | "disabled"
package: string
compaction?: ProviderCompaction
transport?: ProviderTransport
settings?: { [x: string]: any }
headers?: { [x: string]: string }
body?: { [x: string]: any }
}
export type ModelCost = {
tier?: { type: "context"; size: number }
input: MoneyUSDPerMillionTokens
@@ -1671,6 +1656,31 @@ export type SessionInboxMove = {
payload: SessionInboxMovePayload
}
export type ProviderRequest = {
settings: ProviderSettings
headers: { [x: string]: string }
body: { [x: string]: any }
}
export type ModelVariant = {
id: string
settings?: ProviderSettings
headers?: { [x: string]: string }
body?: { [x: string]: any }
}
export type ProviderInfo = {
id: string
canonical?: string
integrationID?: string
name: string
activation: "auto" | "enabled" | "disabled"
package: string
settings?: ProviderSettings
headers?: { [x: string]: string }
body?: { [x: string]: any }
}
export type PermissionRuleset = Array<PermissionRule>
export type SessionRevertStaged = {
@@ -1854,29 +1864,6 @@ export type FormField =
export type FormState = { status: "pending" } | { status: "answered"; answer: FormAnswer } | { status: "cancelled" }
export type ModelInfo = {
id: string
modelID: string
providerID: string
canonical?: string
family?: string
name: string
compatibility?: ModelCompatibility
package?: string
compaction?: ProviderCompaction
transport?: ProviderTransport
settings?: { [x: string]: any }
headers?: { [x: string]: string }
body?: { [x: string]: any }
capabilities: ModelCapabilities
variants: Array<ModelVariant>
time: { released: number }
cost: Array<ModelCost>
status: "alpha" | "beta" | "deprecated" | "active"
enabled: boolean
limit: { context: number; input?: number; output: number }
}
export type FormField1 =
| FormStringField1
| FormNumberField1
@@ -1902,6 +1889,27 @@ export type ReferenceInfo = {
source: ReferenceSource
}
export type ModelInfo = {
id: string
modelID: string
providerID: string
canonical?: string
family?: string
name: string
compatibility?: ModelCompatibility
package?: string
settings?: ProviderSettings
headers?: { [x: string]: string }
body?: { [x: string]: any }
capabilities: ModelCapabilities
variants: Array<ModelVariant>
time: { released: number }
cost: Array<ModelCost>
status: "alpha" | "beta" | "deprecated" | "active"
enabled: boolean
limit: { context: number; input?: number; output: number }
}
export type AgentInfo = {
id: string
name: string
@@ -2085,31 +2093,27 @@ export type ConfigEntry =
warming?: boolean | { prompt?: string; interval?: string; duration?: string }
providers?: {
[x: string]: {
compaction?: ProviderCompaction
transport?: ProviderTransport
canonical?: string
name?: string
env?: Array<string>
package?: string
settings?: { [x: string]: JsonValue }
settings?: ConfigProviderSettings
headers?: { [x: string]: string }
body?: { [x: string]: JsonValue }
models?: {
[x: string]: {
compaction?: ProviderCompaction
transport?: ProviderTransport
modelID?: string
family?: string
name?: string
compatibility?: ModelCompatibility
package?: string
settings?: { [x: string]: JsonValue }
settings?: ConfigProviderSettings
headers?: { [x: string]: string }
body?: { [x: string]: JsonValue }
capabilities?: ModelCapabilities
variants?: Array<{
id: string
settings?: { [x: string]: JsonValue }
settings?: ConfigProviderSettings
headers?: { [x: string]: string }
body?: { [x: string]: JsonValue }
}>
+6 -1
View File
@@ -128,6 +128,8 @@ function prepareOptions(model: Info, pkg: string) {
const customFetch = options.fetch
const chunkTimeout = options.chunkTimeout
delete options.chunkTimeout
delete options.compaction
delete options.transport
options.fetch = async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
const opts = { ...(init ?? {}) }
const signals = [
@@ -388,7 +390,10 @@ function requestSettings(settings: Readonly<Record<string, unknown>> | undefined
if (settings === undefined) return undefined
const result = Object.fromEntries(
Object.entries(settings).filter(
([key]) => !["apiKey", "authToken", "baseURL", "chunkTimeout", "fetch", "timeout"].includes(key),
([key]) =>
!["apiKey", "authToken", "baseURL", "chunkTimeout", "compaction", "fetch", "timeout", "transport"].includes(
key,
),
),
)
return Object.keys(result).length === 0 ? undefined : result
@@ -68,8 +68,6 @@ export const Plugin = define({
if (item.canonical !== undefined) provider.canonical = item.canonical
if (item.name !== undefined) provider.name = item.name
if (item.package !== undefined) provider.package = item.package
if (item.compaction !== undefined) provider.compaction = { ...item.compaction }
if (item.transport !== undefined) provider.transport = item.transport
if (item.settings !== undefined) provider.settings = Provider.mergeOverlay(provider.settings, item.settings)
if (item.headers !== undefined) provider.headers = Provider.mergeHeaders(provider.headers, item.headers)
if (item.body !== undefined) provider.body = Provider.mergeOverlay(provider.body, item.body)
@@ -116,8 +114,6 @@ export const Plugin = define({
if (config.compatibility !== undefined)
model.compatibility = { ...model.compatibility, ...config.compatibility }
if (config.package !== undefined) model.package = config.package
if (config.compaction !== undefined) model.compaction = { ...config.compaction }
if (config.transport !== undefined) model.transport = config.transport
if (config.settings !== undefined) model.settings = Provider.mergeOverlay(model.settings, config.settings)
if (config.headers !== undefined) model.headers = Provider.mergeHeaders(model.headers, config.headers)
if (config.body !== undefined) model.body = Provider.mergeOverlay(model.body, config.body)
+1 -1
View File
@@ -1,7 +1,7 @@
export * as Watcher from "./watcher.js"
// @ts-ignore
import { createWrapper } from "@parcel/watcher/wrapper"
import { createWrapper } from "@parcel/watcher/wrapper.js"
import type ParcelWatcher from "@parcel/watcher"
import { FileSystem } from "@opencode/schema/filesystem"
import { makeGlobalNode } from "@opencode/util/effect/app-node"
+61 -14
View File
@@ -36,6 +36,7 @@ export type Status = Background["status"]
const decodeBackground = Schema.decodeUnknownResult(Background)
const backgroundPrefix = "job.background/"
const COMPLETED_LIMIT = 25
export type Info = {
id: string
@@ -57,6 +58,7 @@ type Active = {
scope: Scope.Closeable
blockingSessions: Map<SessionSchema.ID, number>
isBackgrounded: boolean
consumed: boolean
recovery?: Recovery
}
@@ -69,6 +71,7 @@ type FinishResult = {
info?: Info
done?: Deferred.Deferred<Info>
scope?: Scope.Closeable
generation?: Scope.Closeable
}
type BackgroundResult = {
@@ -81,11 +84,12 @@ type StartResult = { info: Info } | { info: Info; scope: Scope.Closeable }
type BlockWait = {
done: Deferred.Deferred<Info>
backgrounded: Deferred.Deferred<Info>
generation: Scope.Closeable
}
type BlockStart =
| { type: "missing" }
| { type: "finished"; info: Info }
| { type: "finished"; info: Info; generation: Scope.Closeable }
| { type: "backgrounded"; info: Info }
| { type: "wait"; wait: BlockWait }
@@ -168,6 +172,9 @@ function decrementSession(input: Map<SessionSchema.ID, number>, sessionID: Sessi
/**
* Makes one scoped, process-local registry. Explicitly recoverable background
* work also owns a durable notification marker until its notification is admitted.
* Unconsumed results survive the start-to-wait handoff. Foreground block/cancel
* and non-recoverable wait results enter a 25-entry consumed history. Recoverable
* wait results stay available for background registration and acknowledgment.
*/
export const make = Effect.gen(function* () {
const kv = yield* KV.Service
@@ -176,6 +183,19 @@ export const make = Effect.gen(function* () {
scope: yield* Scope.Scope,
}
const consume = (id: string, generation: Scope.Closeable) =>
SynchronizedRef.update(state.jobs, (jobs) => {
const job = jobs.get(id)
if (!job || job.scope !== generation || job.info.status === "running" || job.consumed) return jobs
const next = new Map(jobs)
// Order history by first consumption, not by start time or subsequent reads.
next.delete(id)
next.set(id, { ...job, consumed: true })
const completed = [...next].filter(([, job]) => job.consumed && !job.info.notificationID)
for (const [id] of completed.slice(0, -COMPLETED_LIMIT)) next.delete(id)
return next
})
const persistBackground = Effect.fnUntraced(function* (job: Active) {
if (!job.recovery || !job.info.notificationID) return
yield* kv.set(`${backgroundPrefix}${job.info.notificationID}`, {
@@ -260,6 +280,7 @@ export const make = Effect.gen(function* () {
scope,
blockingSessions: new Map<SessionSchema.ID, number>(),
isBackgrounded: false,
consumed: false,
recovery: input.recovery,
}
return [{ info: snapshot(job), scope }, new Map(jobs).set(id, job)]
@@ -280,12 +301,19 @@ export const make = Effect.gen(function* () {
const wait: Interface["wait"] = Effect.fn("Job.wait")(function* (input) {
const job = (yield* SynchronizedRef.get(state.jobs)).get(input.id)
if (!job) return { timedOut: false }
if (job.info.status !== "running") return { info: snapshot(job), timedOut: false }
if (input.timeout === undefined) return { info: yield* Deferred.await(job.done), timedOut: false }
if (input.timeout <= 0) return { info: snapshot(job), timedOut: true }
const info = yield* Deferred.await(job.done).pipe(Effect.timeoutOption(input.timeout))
if (info._tag === "Some") return { info: info.value, timedOut: false }
return { info: snapshot(job), timedOut: true }
return yield* Effect.gen(function* () {
if (job.info.status !== "running") return { info: snapshot(job), timedOut: false }
if (input.timeout === undefined) return { info: yield* Deferred.await(job.done), timedOut: false }
if (input.timeout <= 0) return { info: snapshot(job), timedOut: true }
const info = yield* Deferred.await(job.done).pipe(Effect.timeoutOption(input.timeout))
if (info._tag === "Some") return { info: info.value, timedOut: false }
return { info: snapshot(job), timedOut: true }
}).pipe(
// Recoverable wait -> background is a supported handoff, even after failure.
Effect.tap((result) =>
result.info.status === "running" || job.recovery ? Effect.void : consume(input.id, job.scope),
),
)
})
const removeBlock = Effect.fnUntraced(function* (input: BlockInput) {
@@ -303,10 +331,10 @@ export const make = Effect.gen(function* () {
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [BlockStart, Map<string, Active>] => {
const job = jobs.get(input.id)
if (!job) return [{ type: "missing" }, jobs]
if (job.info.status !== "running") return [{ type: "finished", info: snapshot(job) }, jobs]
if (job.info.status !== "running") return [{ type: "finished", info: snapshot(job), generation: job.scope }, jobs]
if (job.isBackgrounded) return [{ type: "backgrounded", info: snapshot(job) }, jobs]
return [
{ type: "wait", wait: { done: job.done, backgrounded: job.backgrounded } },
{ type: "wait", wait: { done: job.done, backgrounded: job.backgrounded, generation: job.scope } },
new Map(jobs).set(input.id, {
...job,
blockingSessions: incrementSession(job.blockingSessions, input.sessionID),
@@ -314,12 +342,18 @@ export const make = Effect.gen(function* () {
]
})
if (result.type === "missing") return undefined
if (result.type === "finished") return { type: "finished", info: result.info }
if (result.type === "finished") {
yield* consume(input.id, result.generation)
return { type: "finished", info: result.info }
}
if (result.type === "backgrounded") return { type: "backgrounded", info: result.info }
return yield* Effect.raceFirst(
Deferred.await(result.wait.done).pipe(Effect.map((info) => ({ type: "finished" as const, info }))),
Deferred.await(result.wait.backgrounded).pipe(Effect.map((info) => ({ type: "backgrounded" as const, info }))),
).pipe(Effect.ensuring(removeBlock(input)))
).pipe(
Effect.tap((outcome) => (outcome.type === "finished" ? consume(input.id, result.wait.generation) : Effect.void)),
Effect.ensuring(removeBlock(input)),
)
})
const markBackground = Effect.fnUntraced(function* (job: Active) {
@@ -383,7 +417,7 @@ export const make = Effect.gen(function* () {
Effect.fnUntraced(function* (jobs): Effect.fn.Return<readonly [FinishResult, Map<string, Active>]> {
const job = jobs.get(id)
if (!job) return [{}, jobs]
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
if (job.info.status !== "running") return [{ info: snapshot(job), generation: job.scope }, jobs]
const next = {
...job,
blockingSessions: new Map<SessionSchema.ID, number>(),
@@ -394,11 +428,15 @@ export const make = Effect.gen(function* () {
},
}
yield* persistBackground(next)
return [{ info: snapshot(next), done: job.done, scope: job.scope }, new Map(jobs).set(id, next)]
return [
{ info: snapshot(next), done: job.done, scope: job.scope, generation: job.scope },
new Map(jobs).set(id, next),
]
}),
)
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info)
if (result.scope) yield* Scope.close(result.scope, Exit.void)
if (result.generation) yield* consume(id, result.generation)
return result.info
})
@@ -414,7 +452,16 @@ export const make = Effect.gen(function* () {
}).pipe(Effect.withSpan("Job.pendingBackground"))
const completeBackground: Interface["completeBackground"] = Effect.fn("Job.completeBackground")((notificationID) =>
kv.remove(`${backgroundPrefix}${notificationID}`),
SynchronizedRef.updateEffect(state.jobs, (jobs) =>
Effect.gen(function* () {
yield* kv.remove(`${backgroundPrefix}${notificationID}`)
const entry = [...jobs].find(([, job]) => job.info.notificationID === notificationID)
if (!entry || entry[1].info.status === "running") return jobs
const next = new Map(jobs)
next.delete(entry[0])
return next
}),
),
)
return Service.of({
+9 -5
View File
@@ -118,9 +118,9 @@ export interface Resolved {
/** Catalog token limits used by Core for context management. */
readonly limit: Info["limit"]
/** Model policy overrides the provider policy; omitted means local compaction. */
readonly compaction?: Info["compaction"]
readonly compaction?: Provider.Compaction
/** Model transport overrides the provider transport; omitted means HTTP. */
readonly transport?: Info["transport"]
readonly transport?: Provider.Transport
}
export interface Interface {
@@ -178,7 +178,11 @@ export const fromCatalogModel = (
Effect.flatMap((resolved) => validateProviderVariables(model, resolved)),
Effect.flatMap((resolved) => {
// Reject provider compaction policies up front so the misconfiguration surfaces before any step runs.
if (model.compaction?.mode !== "provider" || resolved.route.compact?.trigger || resolved.route.compact?.endpoint)
if (
model.settings?.compaction?.type !== "native" ||
resolved.route.compact?.trigger ||
resolved.route.compact?.endpoint
)
return Effect.succeed(resolved)
return Effect.fail(
new UnsupportedCompactionError({ providerID: model.providerID, modelID: model.id, route: resolved.route.id }),
@@ -377,8 +381,8 @@ export const layer = Layer.effect(
capabilities: selected.capabilities,
cost: selected.cost,
limit: selected.limit,
compaction: selected.compaction,
transport: selected.transport,
compaction: runtimeInfo.settings?.compaction,
transport: runtimeInfo.settings?.transport,
}
})
return Service.of({
-2
View File
@@ -188,8 +188,6 @@ const layer = Layer.effect(
...model,
...(provider?.canonical === undefined ? {} : { canonical: provider.canonical }),
package: model.package ?? provider?.package,
compaction: model.compaction ?? provider?.compaction,
transport: model.transport ?? provider?.transport,
settings: Provider.mergeOverlay(provider?.settings, model.settings),
headers: Provider.mergeHeaders(provider?.headers, model.headers),
body: Provider.mergeOverlay(provider?.body, model.body),
+102
View File
@@ -0,0 +1,102 @@
export * as GoalPlugin from "./goal.js"
import { define } from "@opencode/plugin/effect/plugin"
import type { Session } from "@opencode/schema/session"
import { Effect, Option, Schema, Stream } from "effect"
const GoalState = Schema.Struct({
goal: Schema.String,
active: Schema.Boolean,
})
const workers = new WeakSet<object>()
export const Plugin = define({
id: "opencode.goal",
effect: Effect.fn(function* (ctx) {
const key = (sessionID: Session.ID) => `session/${sessionID}/goal`
const read = Effect.fn(function* (sessionID: Session.ID) {
return Option.getOrUndefined(Schema.decodeUnknownOption(GoalState)(yield* ctx.storage.get(key(sessionID))))
})
const evaluate = Effect.fn(function* (sessionID: Session.ID) {
const state = yield* read(sessionID)
if (!state?.active) return
const result = yield* ctx.session.generate({
sessionID,
prompt: [
"Evaluate progress toward the goal below using the current session context.",
"Reply with exactly COMPLETE if it is fully complete.",
"Otherwise reply with CONTINUE followed by one concise instruction for the next step.",
`Goal: ${state.goal}`,
].join("\n\n"),
})
const current = yield* read(sessionID)
if (!current?.active || current.goal !== state.goal) return
const evaluation = result.text.trim()
if (/^COMPLETE\b/i.test(evaluation)) {
yield* ctx.session.synthetic({
sessionID,
text: `Goal: ${state.goal}\n\nThe goal has been completed.`,
description: "Goal completed",
delivery: "steer",
resume: false,
})
yield* ctx.storage.set(key(sessionID), { goal: state.goal, active: false })
return
}
yield* ctx.session.synthetic({
sessionID,
text: [
`Goal: ${state.goal}`,
`Next step: ${evaluation.replace(/^CONTINUE\s*/i, "")}`,
"Continue working autonomously until the goal is complete.",
].join("\n\n"),
description: "Goal continuing",
delivery: "steer",
resume: true,
})
})
if (!workers.has(ctx.app)) {
workers.add(ctx.app)
yield* ctx.event.subscribe().pipe(
Stream.mapEffect(
(event) => {
if (event.type !== "session.execution.succeeded") return Effect.void
return evaluate(event.data.sessionID).pipe(
Effect.catch((error) =>
Effect.logError("goal evaluation failed", { sessionID: event.data.sessionID, error }),
),
)
},
{ concurrency: "unbounded", unordered: true },
),
Stream.runDrain,
Effect.forkDetach({ startImmediately: true }),
)
}
yield* ctx.command.transform((draft) => {
draft.add({
name: "goal",
description: "Work autonomously toward a goal",
execute: Effect.fn(function* ({ sessionID, prompt, delivery }) {
const goal = prompt.text.trim()
if (!goal) return yield* Effect.fail(new Error("Usage: /goal <goal>"))
yield* ctx.storage.set(key(sessionID), { goal, active: true })
yield* ctx.session.synthetic({
sessionID,
text: `Goal: ${goal}\n\nContinue until the goal is fully complete. Use tools and make changes as needed.`,
description: `Goal started: ${goal}`,
delivery,
resume: true,
})
}),
})
})
}),
})
+4
View File
@@ -87,6 +87,7 @@ import { AgentPlugin } from "./agent.js"
import BrowserPlugin from "@opencode/plugin-browser"
import { CommandPlugin } from "./command.js"
import { NativeCompactionPlugin } from "./compaction.js"
import { GoalPlugin } from "./goal.js"
import { IdentityPlugin } from "./identity.js"
import { PlanPlugin } from "./plan.js"
import { ModelsDevPlugin } from "./models-dev.js"
@@ -95,6 +96,7 @@ import { ProviderPlugins } from "./provider.js"
import { WebSearchPlugins } from "./websearch/index.js"
import { SkillPlugin } from "./skill.js"
import { VcsHgPlugin } from "./vcs/hg.js"
import { ToolInputRepairPlugin } from "./tool-input-repair.js"
import { OptimizePlugin } from "./optimize.js"
import { VcsGitPlugin } from "./vcs/git.js"
import { WarmingPlugin } from "./warming.js"
@@ -205,6 +207,7 @@ export const requirements = LayerNode.group([
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
const pre = [
ToolInputRepairPlugin.Plugin,
ConfigWorktreePlugin.Plugin,
BrowserPlugin,
ConfigMcpPlugin.Plugin,
@@ -214,6 +217,7 @@ const pre = [
AgentPlugin.Plugin,
PlanPlugin.Plugin,
CommandPlugin.Plugin,
GoalPlugin.Plugin,
SkillPlugin.Plugin,
VcsHgPlugin.Plugin,
ModelsDevPlugin,
+3 -1
View File
@@ -168,7 +168,9 @@ export const AzurePlugin = define({
resolveResourceName(draft.settings, resourceName) ?? resourceName,
)
if (responsesWebSocketCapable(item.provider, draft))
draft.transport = item.provider.transport ?? "websocket"
draft.settings = Provider.mergeOverlay(draft.settings, {
transport: item.provider.settings?.transport ?? "websocket",
})
})
}
}
+3 -1
View File
@@ -270,7 +270,9 @@ export const OpenAIPlugin = define({
// ChatGPT-plan tokens only authorize codex-eligible models, and the
// subscription covers usage, so hide the rest and zero the cost.
models.update(model.providerID, model.id, (draft) => {
draft.transport = models.provider.get(model.providerID)?.provider.transport ?? "websocket"
draft.settings = Provider.mergeOverlay(draft.settings, {
transport: models.provider.get(model.providerID)?.provider.settings?.transport ?? "websocket",
})
if (!chatgpt) return
if (Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(draft.body?.reasoning)) {
draft.enabled = false
+3 -1
View File
@@ -98,7 +98,9 @@ export const XAIPlugin = define({
yield* ctx.model.transform((models) => {
for (const model of models.list(providerID)) {
models.update(providerID, model.id, (draft) => {
draft.transport = models.provider.get(providerID)?.provider.transport ?? "websocket"
draft.settings = Provider.mergeOverlay(draft.settings, {
transport: models.provider.get(providerID)?.provider.settings?.transport ?? "websocket",
})
})
}
})
@@ -0,0 +1,177 @@
export * as ToolInputRepairPlugin from "./tool-input-repair.js"
import { define } from "@opencode/plugin/effect/plugin"
import type { ToolEditor } from "@opencode/plugin/effect/tool"
import { Effect, JsonSchema, Option, Predicate, Schema } from "effect"
import { definition } from "../tool/runtime.js"
// Repairs apply only when the input schema unambiguously supports them:
// - Stringified root or nested object: '{"limit":"20"}' -> { limit: 20 }
// - Closed object: { limit: "20", extra: true } -> { limit: 20 }
// - Optional null or empty-object placeholder: { limit: null } -> {}
// - Numeric or boolean string: { limit: "20", enabled: "false" } -> { limit: 20, enabled: false }
// - Nullable field: { count: "2" } -> { count: 2 }
// - Stringified array or compatible item: { tags: '["a"]', count: "2" } -> { tags: ["a"], count: [2] }
// - Positional tuple: { pair: ["2", "false"] } -> { pair: [2, false] }
// - Typed dictionary: { counts: { first: "2" } } -> { counts: { first: 2 } }
// - Nested fields and local references: { items: [{ count: "2" }] } -> { items: [{ count: 2 }] }
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
const maxDepth = 6
export const Plugin = define({
id: "opencode.tool.input.repair",
effect: Effect.fn(function* (ctx) {
let get: ToolEditor["get"] = () => undefined
yield* ctx.tool.transform((draft) => {
// The draft sees later tool transforms too; reload replaces this lookup.
get = draft.get
})
yield* ctx.tool.hook("execute.before", (event) =>
Effect.sync(() => {
// The outer Code Mode tool is built per snapshot rather than registered, so it cannot be
// looked up here. Its `{ code }` input is trivial; the tools it calls are repaired normally.
if (event.tool === "execute") return
const tool = get(event.tool)
if (!tool) return
const schema = definition(tool).inputSchema
if (schema.type !== "object") return
event.input = repair(event.input, schema, schema, 0)
}),
)
}),
})
function repair(value: unknown, schema: JsonSchema.JsonSchema, root: JsonSchema.JsonSchema, depth: number): unknown {
if (depth > maxDepth) return value
if (typeof schema.$ref === "string") {
const definitions = /^#\/\$defs\/[^/]+$/.test(schema.$ref)
? root.$defs
: /^#\/definitions\/[^/]+$/.test(schema.$ref)
? root.definitions
: undefined
if (!Predicate.isObject(definitions)) return value
const target = Object.fromEntries(
Object.entries(definitions).filter((entry): entry is [string, JsonSchema.JsonSchema] =>
Predicate.isObject(entry[1]),
),
)[
schema.$ref
.slice(schema.$ref.lastIndexOf("/") + 1)
.replaceAll("~1", "/")
.replaceAll("~0", "~")
]
return target ? repair(value, target, root, depth + 1) : value
}
if (Array.isArray(schema.type)) {
if (value === null && schema.type.includes("null")) return value
if (schema.type.includes(typeof value)) return value
const types = schema.type.filter((type) => type !== "null")
return types.length === 1 ? repair(value, { ...schema, type: types[0] }, root, depth + 1) : value
}
if (schema.type === undefined) {
if (Array.isArray(schema.anyOf) && Array.isArray(schema.oneOf)) return value
const branches = Array.isArray(schema.anyOf) ? schema.anyOf : schema.oneOf
if (!Array.isArray(branches) || value === null) return value
if (branches.some((branch) => !Predicate.isObject(branch) || branch.type === typeof value)) return value
const candidates = branches.filter((branch) => Predicate.isObject(branch) && branch.type !== "null")
return candidates.length === 1 ? repair(value, candidates[0], root, depth + 1) : value
}
if (schema.type === "number" || schema.type === "integer") {
if (typeof value !== "string" || value.trim() === "") return value
const parsed = Number(value)
return Number.isFinite(parsed) && (schema.type !== "integer" || Number.isSafeInteger(parsed)) ? parsed : value
}
if (schema.type === "boolean") return value === "true" ? true : value === "false" ? false : value
if (schema.type === "object") return repairObject(value, schema, root, depth)
if (schema.type === "array") return repairArray(value, schema, root, depth)
return value
}
function repairObject(
value: unknown,
schema: JsonSchema.JsonSchema,
root: JsonSchema.JsonSchema,
depth: number,
): unknown {
const parsed = typeof value === "string" ? Option.getOrUndefined(decodeJson(value)) : value
if (!Predicate.isObject(parsed)) return value
const properties = Predicate.isObject(schema.properties) ? schema.properties : {}
const required = Array.isArray(schema.required) ? schema.required : []
const patterned = Predicate.isObject(schema.patternProperties)
const composed = Array.isArray(schema.allOf) || Array.isArray(schema.anyOf) || Array.isArray(schema.oneOf)
return Object.keys(parsed).reduce<Record<string, unknown>>((result, key) => {
const current = result[key]
const declared = Object.hasOwn(properties, key)
const property = declared ? properties[key] : !patterned ? schema.additionalProperties : undefined
if (!declared && schema.additionalProperties === false && !patterned && !composed) {
const next = { ...result }
delete next[key]
return next
}
if (!Predicate.isObject(property)) return result
// Only a bare single-type property provably rejects null and `{}`. Compositions, enums,
// constants, references and nullable flags may accept them, so those are left alone.
const plain =
typeof property.type === "string" &&
property.type !== "null" &&
!composed &&
["anyOf", "oneOf", "allOf", "enum", "const", "$ref", "nullable"].every((keyword) => !(keyword in property))
const placeholder = Predicate.isObject(current) && Object.keys(current).length === 0 && property.type !== "object"
if (declared && !required.includes(key) && plain && (current === null || placeholder)) {
const next = { ...result }
delete next[key]
return next
}
const repaired = repair(current, property, root, depth + 1)
return repaired === current ? result : { ...result, [key]: repaired }
}, parsed)
}
function repairArray(
value: unknown,
schema: JsonSchema.JsonSchema,
root: JsonSchema.JsonSchema,
depth: number,
): unknown {
const parsed = typeof value === "string" ? Option.getOrUndefined(decodeJson(value)) : value
const tuple = Array.isArray(schema.prefixItems)
? schema.prefixItems
: Array.isArray(schema.items)
? schema.items
: undefined
if (Array.isArray(parsed)) {
const repaired = parsed.map((item, index) => {
const member = tuple
? (tuple[index] ?? (Array.isArray(schema.prefixItems) ? schema.items : schema.additionalItems))
: schema.items
return Predicate.isObject(member) ? repair(item, member, root, depth + 1) : item
})
return repaired.every((item, index) => item === parsed[index]) ? parsed : repaired
}
if (tuple || !Predicate.isObject(schema.items)) return value
const repaired = repair(value, schema.items, root, depth + 1)
const type = schema.items.type
const compatible =
type === "object"
? Predicate.isObject(repaired)
: type === "array"
? Array.isArray(repaired)
: type === "integer"
? typeof repaired === "number" && Number.isSafeInteger(repaired)
: type === "number"
? typeof repaired === "number" && Number.isFinite(repaired)
: (type === "string" || type === "boolean") && typeof repaired === type
return compatible ? [repaired] : value
}
+9 -3
View File
@@ -132,11 +132,11 @@ export const loadPackage = Effect.fn("Provider.loadPackage")(function* (input: s
return yield* importPackage(specifier, entrypoint)
})
/** opencode transport settings consumed in aisdk.ts; native packages never receive them. */
const TRANSPORT_KEYS = ["chunkTimeout", "fetch", "timeout"] as const
/** opencode settings consumed in Core; native packages never receive them. */
const CORE_KEYS = ["chunkTimeout", "compaction", "fetch", "timeout", "transport"] as const
export function nativeSettings(settings: Settings): Settings {
return Struct.omit(settings, TRANSPORT_KEYS)
return Struct.omit(settings, CORE_KEYS)
}
export function mergeOverlay(
@@ -177,6 +177,12 @@ export function mergeHeaders(
export const Request = Provider.Request
export type Request = Provider.Request
export const Compaction = Provider.Compaction
export type Compaction = Provider.Compaction
export const Transport = Provider.Transport
export type Transport = Provider.Transport
export const Settings = Provider.Settings
export type Settings = Provider.Settings
+3 -8
View File
@@ -736,7 +736,7 @@ export const layer = Layer.effect(
const compact = Effect.fn("SessionCompaction.compact")(function* (input: AutoInput): Effect.fn.Return<Outcome> {
const request = { ...input, reason: "auto" as const }
if (input.overflow) return yield* recoverLocally(request)
if (input.context.model.compaction?.mode !== "provider") return yield* execute(request)
if (input.context.model.compaction?.type !== "native") return yield* execute(request)
return yield* executeProvider(request)
})
const required = (input: RequiredInput) => {
@@ -759,12 +759,7 @@ export const layer = Layer.effect(
limit.input === undefined ? Number.POSITIVE_INFINITY : limit.input - config.buffer,
context - Math.max(output, config.buffer),
)
const policy = input.resolved.compaction
const threshold =
policy?.mode === "provider" && policy.threshold !== undefined
? Math.min(policy.threshold, promptCeiling)
: promptCeiling
return estimateTokens(input) >= threshold
return estimateTokens(input) >= promptCeiling
}
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
if (findTailStart(input.messages, state.get().tokens) === undefined)
@@ -792,7 +787,7 @@ export const layer = Layer.effect(
inputID: input.inputID,
started: input.started,
}
return context.model.compaction?.mode === "provider" ? executeProvider(request) : execute(request)
return context.model.compaction?.type === "native" ? executeProvider(request) : execute(request)
},
}),
)
+17
View File
@@ -178,4 +178,21 @@ export const firstUserMessage = Effect.fn("SessionHistory.firstUserMessage")(fun
return message?.type === "user" ? message : undefined
})
/** Returns the session's first synthetic message. */
export const firstSyntheticMessage = Effect.fn("SessionHistory.firstSyntheticMessage")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
) {
const row = yield* db
.select()
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "synthetic")))
.orderBy(asc(SessionMessageTable.seq))
.get()
.pipe(Effect.orDie)
if (!row) return undefined
const message = yield* decodeMessageRow(row).pipe(Effect.orElseSucceed(() => undefined))
return message?.type === "synthetic" ? message : undefined
})
export * as SessionHistory from "./history.js"
+6 -5
View File
@@ -101,14 +101,15 @@ export const layer = Layer.effect(
const session = yield* store.get(sessionID)
if (!session) return
const firstUser = yield* SessionHistory.firstUserMessage(db, session.id)
if (!firstUser) return
const firstPrompt = firstUser ?? (yield* SessionHistory.firstSyntheticMessage(db, session.id))
if (!firstPrompt) return
const text = !isUntitled(session)
? yield* store.context(session.id).pipe(
Effect.map((messages) => {
const original = `Original request:\n${firstUser.text.slice(0, MAX_FIRST_MESSAGE_LENGTH)}`
const original = `Original request:\n${firstPrompt.text.slice(0, MAX_FIRST_MESSAGE_LENGTH)}`
const recent = messages
.flatMap((message) => {
if (message.type === "user" && message.id !== firstUser.id) return [`User: ${message.text.trim()}`]
if (message.type === "user" && message.id !== firstPrompt.id) return [`User: ${message.text.trim()}`]
if (message.type !== "assistant") return []
const text = message.content
.flatMap((part) => (part.type === "text" ? [part.text.trim()] : []))
@@ -121,9 +122,9 @@ export const layer = Layer.effect(
const prefix = `${original}\n\nRecent conversation:\n`
return `${prefix}${recent.slice(-(MAX_CONTEXT_LENGTH - prefix.length))}`
}),
Effect.orElseSucceed(() => firstUser.text),
Effect.orElseSucceed(() => firstPrompt.text),
)
: firstUser.text
: firstPrompt.text
const selection = yield* context.selectTitle(session)
if (!selection) return
const title =
+6
View File
@@ -155,14 +155,18 @@ it.effect("projects request settings, headers, and body overlays", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
let body: unknown
let options: Record<string, unknown> | undefined
yield* aisdk.hook.sdk((event) => {
body = event.options.body
options = event.options
event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
})
const input = model("@ai-sdk/google", {
apiKey: "secret",
thinkingConfig: { thinkingBudget: 1024 },
compaction: { type: "native" },
transport: "websocket",
})
const resolved = yield* aisdk.model({
...input,
@@ -185,6 +189,8 @@ it.effect("projects request settings, headers, and body overlays", () =>
})
expect(prepared.body.headers).toEqual({ "x-test": "header" })
expect(body).toEqual({ safety_setting: "strict" })
expect(options).not.toHaveProperty("compaction")
expect(options).not.toHaveProperty("transport")
}),
)
+16 -25
View File
@@ -34,7 +34,7 @@ function required<T>(value: T | undefined): T {
const decode = Schema.decodeUnknownSync(Info)
describe("ConfigProviderPlugin.Plugin", () => {
it.effect("inherits provider compaction policy with model overrides and rejects unsupported routes", () =>
it.effect("inherits the provider compaction setting with model overrides and rejects unsupported routes", () =>
Effect.gen(function* () {
const models = yield* Model.Service
yield* addPlugin([
@@ -44,12 +44,10 @@ describe("ConfigProviderPlugin.Plugin", () => {
providers: {
custom: {
package: "@opencode/ai/providers/openai/responses",
compaction: { mode: "provider", threshold: 120_000 },
settings: { compaction: { type: "native" } },
models: {
native: {},
reset: { compaction: { mode: "provider" } },
threshold: { compaction: { mode: "provider", threshold: 90_000 } },
local: { compaction: { mode: "local" }, package: "@opencode/ai/providers/openai/chat" },
local: { settings: { compaction: { type: "summary" } }, package: "@opencode/ai/providers/openai/chat" },
unsupported: { package: "@opencode/ai/providers/openai/chat" },
},
},
@@ -62,16 +60,9 @@ describe("ConfigProviderPlugin.Plugin", () => {
const local = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("local")))
const unsupported = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("unsupported")))
const defaultModel = required(yield* models.get(Provider.ID.make("default"), Model.ID.make("chat")))
expect(native.compaction).toEqual({ mode: "provider", threshold: 120_000 })
expect((yield* models.get(Provider.ID.make("custom"), Model.ID.make("reset")))?.compaction).toEqual({
mode: "provider",
})
expect((yield* models.get(Provider.ID.make("custom"), Model.ID.make("threshold")))?.compaction).toEqual({
mode: "provider",
threshold: 90_000,
})
expect(local.compaction).toEqual({ mode: "local" })
expect(defaultModel.compaction).toBeUndefined()
expect(native.settings?.compaction).toEqual({ type: "native" })
expect(local.settings?.compaction).toEqual({ type: "summary" })
expect(defaultModel.settings?.compaction).toBeUndefined()
yield* ModelResolver.fromCatalogModel(native)
yield* ModelResolver.fromCatalogModel(local)
yield* ModelResolver.fromCatalogModel(defaultModel)
@@ -92,8 +83,8 @@ describe("ConfigProviderPlugin.Plugin", () => {
providers: {
custom: {
package: "@opencode/ai/providers/openai/responses",
transport: "http",
models: { inherited: {}, override: { transport: "websocket" } },
settings: { transport: "http" },
models: { inherited: {}, override: { settings: { transport: "websocket" } } },
},
default: { package: "@opencode/ai/providers/openai/responses", models: { untouched: {} } },
},
@@ -103,9 +94,9 @@ describe("ConfigProviderPlugin.Plugin", () => {
const inherited = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("inherited")))
const override = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("override")))
const untouched = required(yield* models.get(Provider.ID.make("default"), Model.ID.make("untouched")))
expect(inherited.transport).toBe("http")
expect(override.transport).toBe("websocket")
expect(untouched.transport).toBeUndefined()
expect(inherited.settings?.transport).toBe("http")
expect(override.settings?.transport).toBe("websocket")
expect(untouched.settings?.transport).toBeUndefined()
}),
)
@@ -131,7 +122,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
editor.models.update(providerID, modelID, () => {})
})
yield* builtin.plugin.effect(host)
expect((yield* models.get(providerID, modelID))?.transport).toBe("websocket")
expect((yield* models.get(providerID, modelID))?.settings?.transport).toBe("websocket")
yield* addPlugin([
new Document({
@@ -139,16 +130,16 @@ describe("ConfigProviderPlugin.Plugin", () => {
info: decode({
providers: {
[builtin.id]: {
transport: "http",
models: { override: { modelID: builtin.model, transport: "websocket" } },
settings: { transport: "http" },
models: { override: { modelID: builtin.model, settings: { transport: "websocket" } } },
},
},
}),
}),
])
expect((yield* models.get(providerID, modelID))?.transport).toBe("http")
expect((yield* models.get(providerID, Model.ID.make("override")))?.transport).toBe("websocket")
expect((yield* models.get(providerID, modelID))?.settings?.transport).toBe("http")
expect((yield* models.get(providerID, Model.ID.make("override")))?.settings?.transport).toBe("websocket")
}),
)
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { expect, test } from "bun:test"
import { Ignore } from "@opencode/core/filesystem/ignore"
// @ts-ignore
import { createWrapper } from "@parcel/watcher/wrapper"
import { createWrapper } from "@parcel/watcher/wrapper.js"
test("parcel patterns ignore built-in folders at any depth", async () => {
let ignoreGlobs: string[] = []
+77
View File
@@ -0,0 +1,77 @@
import { describe, expect } from "bun:test"
import type { CommandDefinition } from "@opencode/plugin/effect/command"
import { Event } from "@opencode/schema/event"
import { Session } from "@opencode/schema/session"
import { SessionEvent } from "@opencode/schema/session-event"
import { SessionInbox } from "@opencode/schema/session-inbox"
import { SessionMessage } from "@opencode/schema/session-message"
import { DateTime, Deferred, Effect, PubSub, Stream } from "effect"
import { GoalPlugin } from "@opencode/core/plugin/goal"
import { it } from "../lib/effect"
import { host } from "./host"
const sessionID = Session.ID.make("ses_goal_test")
describe("GoalPlugin.Plugin", () => {
it.effect("continues a goal until evaluation reports completion", () =>
Effect.gen(function* () {
const event: SessionEvent.Execution.Succeeded = {
id: Event.ID.create(),
created: 0,
durable: { aggregateID: sessionID, seq: Event.Seq.make(0), version: Event.Version.make(1) },
type: "session.execution.succeeded",
data: { sessionID },
}
const events = yield* PubSub.unbounded<typeof event>()
const completed = yield* Deferred.make<void>()
const storage = new Map<string, unknown>()
const descriptions = new Array<string>()
let command: CommandDefinition | undefined
yield* GoalPlugin.Plugin.effect(
host({
command: {
list: () => Effect.die("unused command.list"),
reload: () => Effect.die("unused command.reload"),
transform: (callback) => {
callback({ add: (definition) => (command = definition) })
return Effect.succeed({ dispose: Effect.void })
},
},
event: { subscribe: () => Stream.fromPubSub(events) },
storage: {
get: (key) => Effect.succeed(storage.get(key) as never),
set: (key, value) => Effect.sync(() => storage.set(key, value)),
remove: (key) => Effect.sync(() => storage.delete(key)),
scan: () => Effect.die("unused storage.scan"),
},
session: {
generate: () => Effect.succeed({ text: "COMPLETE" }),
synthetic: (input) =>
Effect.gen(function* () {
descriptions.push(input.description ?? "")
if (input.description === "Goal completed") yield* Deferred.succeed(completed, undefined)
return SessionInbox.Synthetic.make({
id: SessionMessage.ID.create(),
sessionID: input.sessionID,
time: { created: DateTime.makeUnsafe(0) },
type: "synthetic",
payload: { text: input.text, description: input.description },
delivery: input.delivery ?? "steer",
})
}),
},
}),
)
yield* Effect.yieldNow
if (!command) return yield* Effect.die("Goal command was not registered")
yield* command.execute({ sessionID, prompt: { text: "Finish the task" }, delivery: "steer" })
yield* PubSub.publish(events, event)
yield* Deferred.await(completed)
expect(descriptions).toEqual(["Goal started: Finish the task", "Goal completed"])
expect(storage.get(`session/${sessionID}/goal`)).toEqual({ goal: "Finish the task", active: false })
}),
)
})
@@ -473,10 +473,10 @@ describe("AzurePlugin", () => {
yield* addPlugin()
const responses = required(yield* service.get(Provider.ID.azure, models.responses))
expect(responses.transport).toBe("websocket")
expect(responses.settings?.transport).toBe("websocket")
for (const modelID of [models.chat, models.preview, models.deploymentURL, models.gateway, models.nonAzure]) {
const model = required(yield* service.get(Provider.ID.azure, modelID))
expect(model.transport).toBeUndefined()
expect(model.settings?.transport).toBeUndefined()
}
}),
),
@@ -205,7 +205,7 @@ describe("OpenAIPlugin", () => {
expect(model.package).toBe("@opencode/ai/providers/openai")
expect(model.enabled).toBe(true)
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
expect(model.transport).toBe("websocket")
expect(model.settings?.transport).toBe("websocket")
expect(direct.headers).not.toHaveProperty("originator")
expect(direct.baseURL).toBe("https://api.openai.com/v1")
expect(provider.headers).not.toHaveProperty("x-codex-beta-features")
@@ -236,7 +236,7 @@ describe("OpenAIPlugin", () => {
id: "deployment-responses",
provider: Provider.ID.azure,
})
const prepare = (preference?: Model.Info["transport"]) =>
const prepare = (preference?: Provider.Transport) =>
Effect.gen(function* () {
const model = SessionRunnerModel.resolved(route.model({ id: "gpt-5.5" }), {
capabilities: { tools: true, input: ["text"], output: ["text"] },
@@ -84,7 +84,7 @@ describe("XAIPlugin", () => {
yield* addPlugin()
const model = yield* models.get(providerID, Model.ID.make("grok-4.6"))
expect(model?.transport).toBe("websocket")
expect(model?.settings?.transport).toBe("websocket")
}),
)
})
@@ -0,0 +1,114 @@
import { expect } from "bun:test"
import { Effect, Schema } from "effect"
import { Agent } from "@opencode/core/agent"
import { Plugin } from "@opencode/core/plugin"
import { ToolInputRepairPlugin } from "@opencode/core/plugin/tool-input-repair"
import { Session } from "@opencode/core/session"
import { SessionMessage } from "@opencode/core/session/message"
import { Tool } from "@opencode/core/tool"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const identity = {
sessionID: Session.ID.make("ses_repair"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_repair"),
}
it.effect("repairs tool input before validating its original schema", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const registry = yield* Tool.Service
const executed: unknown[] = []
yield* plugins.activate([
{ ...ToolInputRepairPlugin.Plugin, revision: "1" },
{
id: "repairable-tool",
revision: "1",
effect: (ctx) =>
ctx.tool.transform((draft) =>
draft.add({
name: "repairable",
options: { codemode: false },
description: "Repairable",
input: Schema.Struct({ count: Schema.Int, enabled: Schema.Boolean }),
execute: (input) => Effect.sync(() => executed.push(input)).pipe(Effect.as({ content: "ok" })),
}),
),
},
])
const snapshot = yield* registry.snapshot()
yield* snapshot.execute({
...identity,
call: {
type: "tool-call",
id: "call-repair",
name: "repairable",
input: '{"count":"2","enabled":"true","extra":true}',
},
})
expect(executed).toEqual([{ count: 2, enabled: true }])
yield* registry.transform((draft) => {
draft.update("repairable", (tool) => {
tool.input = Schema.Struct({ count: Schema.Boolean, enabled: Schema.Boolean })
})
})
const updated = yield* registry.snapshot()
yield* updated.execute({
...identity,
call: {
type: "tool-call",
id: "call-updated",
name: "repairable",
input: { count: "false", enabled: "true" },
},
})
expect(executed).toEqual([
{ count: 2, enabled: true },
{ count: false, enabled: true },
])
yield* registry.transform((draft) => draft.remove("repairable"))
const removed = yield* registry.snapshot()
expect(
(yield* removed
.execute({
...identity,
call: { type: "tool-call", id: "call-removed", name: "repairable", input: {} },
})
.pipe(Effect.flip)).message,
).toBe('No tool named "repairable" is currently available. Please use a tool from the available tool list.')
}),
)
it.effect("repairs namespaced inner tool input called from Code Mode", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const registry = yield* Tool.Service
const executed: unknown[] = []
yield* plugins.activate([{ ...ToolInputRepairPlugin.Plugin, revision: "1" }])
yield* registry.transform((draft) =>
draft.add({
name: "count",
options: { namespace: "example" },
description: "Record a count",
input: Schema.Struct({ count: Schema.Int }),
execute: (input) => Effect.sync(() => executed.push(input)).pipe(Effect.as({ content: "ok" })),
}),
)
const snapshot = yield* registry.snapshot()
yield* snapshot.execute({
...identity,
call: {
type: "tool-call",
id: "call-codemode-repair",
name: "execute",
input: { code: 'return await tools.example.count({ count: "3" })' },
},
})
expect(executed).toEqual([{ count: 3 }])
}),
)
@@ -0,0 +1,466 @@
import { describe, expect } from "bun:test"
import { Agent } from "@opencode/core/agent"
import { ToolInputRepairPlugin } from "@opencode/core/plugin/tool-input-repair"
import { Session } from "@opencode/core/session"
import { SessionMessage } from "@opencode/core/session/message"
import type { ToolHooks } from "@opencode/plugin/effect/tool"
import { Tool } from "@opencode/schema/tool"
import { Effect, type JsonSchema } from "effect"
import { it } from "../lib/effect"
import { host } from "./host"
function run(input: unknown, inputSchema: JsonSchema.JsonSchema) {
const event: ToolHooks["execute.before"] = {
tool: "test",
input,
sessionID: Session.ID.make("ses_repair"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_repair"),
id: Tool.CallID.make("call_repair"),
}
const events: ToolHooks = {
"execute.before": event,
"execute.after": { ...event, status: "error", error: new Tool.Error({ message: "unused" }) },
}
const base = host()
return ToolInputRepairPlugin.Plugin.effect(
host({
tool: {
...base.tool,
transform: (callback) =>
Effect.sync(() => {
const tool = {
id: "test",
name: "test",
description: "Test repair",
input: inputSchema,
execute: () => Effect.succeed({ content: "unused" }),
}
callback({
list: () => [tool],
get: (id) => (id === tool.id ? tool : undefined),
add: () => {},
namespace: () => {},
update: () => {},
remove: () => {},
})
return { dispose: Effect.void }
}),
hook: (name, callback) => callback(events[name]).pipe(Effect.orDie, Effect.as({ dispose: Effect.void })),
},
}),
).pipe(Effect.as(event))
}
const object = (properties: Record<string, unknown>, required?: string[]) => ({
type: "object" as const,
properties,
...(required ? { required } : {}),
})
describe("tool input repair plugin", () => {
it.effect("preserves valid input identity, nested containers, and unknown properties", () =>
Effect.gen(function* () {
const nested = { enabled: true }
const items = [2, 3]
const input = { count: 2, nested, items, extra: "keep" }
const event = yield* run(
input,
object({
count: { type: "integer" },
nested: object({ enabled: { type: "boolean" } }),
items: { type: "array", items: { type: "number" } },
}),
)
expect(event.input).toBe(input)
expect((event.input as typeof input).nested).toBe(nested)
expect((event.input as typeof input).items).toBe(items)
}),
)
it.effect("parses root objects and repairs nested stringified containers", () =>
Effect.gen(function* () {
const schema = object({
count: { type: "integer" },
item: object({ enabled: { type: "boolean" } }),
list: { type: "array", items: { type: "integer" } },
})
expect(
(yield* run('{"count":"2","item":"{\\"enabled\\":\\"false\\"}","list":"[\\"3\\"]"}', schema)).input,
).toEqual({
count: 2,
item: { enabled: false },
list: [3],
})
expect((yield* run("{broken", schema)).input).toBe("{broken")
expect((yield* run("[]", schema)).input).toBe("[]")
expect((yield* run(null, schema)).input).toBeNull()
}),
)
it.effect("removes extras only from explicitly closed objects without mutating inputs", () =>
Effect.gen(function* () {
const input = {
known: "2",
extra: true,
closed: { keep: "3", extra: true },
open: { keep: "4", extra: true },
items: [{ keep: "5", extra: true }],
}
const event = yield* run(input, {
...object({
known: { type: "integer" },
closed: { ...object({ keep: { type: "integer" } }), additionalProperties: false },
open: object({ keep: { type: "integer" } }),
items: {
type: "array",
items: { ...object({ keep: { type: "integer" } }), additionalProperties: false },
},
}),
additionalProperties: false,
})
expect(event.input).toEqual({
known: 2,
closed: { keep: 3 },
open: { keep: 4, extra: true },
items: [{ keep: 5 }],
})
expect(input.extra).toBeTrue()
expect(input.closed.extra).toBeTrue()
expect(input.items[0]?.extra).toBeTrue()
expect((yield* run({ extra: true }, { ...object({}), additionalProperties: false })).input).toEqual({})
}),
)
it.effect("preserves unknown keys when patterned ownership cannot be determined", () =>
Effect.gen(function* () {
const input = { known: 1, match: "2", extra: true }
const event = yield* run(input, {
...object({ known: { type: "integer" } }),
additionalProperties: false,
patternProperties: { "^match$": { type: "integer" } },
})
expect(event.input).toBe(input)
expect((event.input as typeof input).match).toBe("2")
expect((event.input as typeof input).extra).toBeTrue()
}),
)
it.effect("preserves properties that may belong to composed object schemas", () =>
Effect.gen(function* () {
const input = { name: "example", extra: true }
for (const keyword of ["allOf", "anyOf", "oneOf"]) {
const event = yield* run(input, {
type: "object",
[keyword]: [object({ name: { type: "string" } })],
additionalProperties: false,
})
expect(event.input).toBe(input)
}
}),
)
it.effect("removes only optional nonnullable nulls and non-object empty placeholders", () =>
Effect.gen(function* () {
const input = {
optional: null,
required: null,
nullable: null,
union: null,
constant: null,
permissive: null,
referenced: null,
placeholder: {},
array: {},
requiredPlaceholder: {},
object: {},
unknown: null,
}
const event = yield* run(
input,
object(
{
optional: { type: "string" },
required: { type: "string" },
nullable: { type: "string", nullable: true },
union: { anyOf: [{ type: "integer" }, { type: "null" }] },
constant: { anyOf: [{ type: "integer" }, { const: null }] },
permissive: { anyOf: [{ type: "integer" }, true] },
referenced: { anyOf: [{ type: "integer" }, { $ref: "#/$defs/nullable" }] },
placeholder: { type: "integer" },
array: { type: "array", items: { type: "string" } },
requiredPlaceholder: { type: "boolean" },
object: { type: "object" },
unknown: {},
},
["required", "requiredPlaceholder"],
),
)
expect(event.input).toEqual({
required: null,
nullable: null,
union: null,
constant: null,
permissive: null,
referenced: null,
requiredPlaceholder: {},
object: {},
unknown: null,
})
expect(input.optional).toBeNull()
expect(input.placeholder).toEqual({})
}),
)
it.effect("preserves nulls whose validity is hidden inside compositions", () =>
Effect.gen(function* () {
const input = { wrapped: null, enumerated: null, permissive: null, constant: null }
const event = yield* run(
input,
object({
wrapped: { anyOf: [{ type: ["string", "null"] }] },
enumerated: { anyOf: [{ enum: [null, "keep"] }] },
permissive: { anyOf: [{}] },
constant: { type: "string", const: "keep" },
}),
)
expect(event.input).toBe(input)
}),
)
it.effect("preserves optional-looking nulls when the parent composes requirements", () =>
Effect.gen(function* () {
const input = { value: null }
const event = yield* run(input, {
...object({ value: { type: "string" } }),
allOf: [{ required: ["value"] }],
})
expect(event.input).toBe(input)
}),
)
it.effect("coerces numeric and boolean strings while preserving invalid and existing values", () =>
Effect.gen(function* () {
const input = {
number: "1.5",
integer: "42",
enabled: "true",
disabled: "false",
valid: 3,
empty: " ",
infinite: "Infinity",
fractional: "1.5",
unsafe: "9007199254740992",
uppercase: "TRUE",
}
const event = yield* run(
input,
object({
number: { type: "number" },
integer: { type: "integer" },
enabled: { type: "boolean" },
disabled: { type: "boolean" },
valid: { type: "integer" },
empty: { type: "number" },
infinite: { type: "number" },
fractional: { type: "integer" },
unsafe: { type: "integer" },
uppercase: { type: "boolean" },
}),
)
expect(event.input).toEqual({ ...input, number: 1.5, integer: 42, enabled: true, disabled: false })
}),
)
it.effect("wraps compatible scalars after repairing array items", () =>
Effect.gen(function* () {
const event = yield* run(
{
text: "one",
integer: "42",
boolean: "false",
item: '{"count":"2"}',
incompatible: 2,
fractional: 1.5,
unconstrained: "4",
},
object({
text: { type: "array", items: { type: "string" } },
integer: { type: "array", items: { type: "integer" } },
boolean: { type: "array", items: { type: "boolean" } },
item: { type: "array", items: object({ count: { type: "integer" } }) },
incompatible: { type: "array", items: { type: "string" } },
fractional: { type: "array", items: { type: "integer" } },
unconstrained: { type: "array" },
}),
)
expect(event.input).toEqual({
text: ["one"],
integer: [42],
boolean: [false],
item: [{ count: 2 }],
incompatible: 2,
fractional: 1.5,
unconstrained: "4",
})
}),
)
it.effect("repairs nested question-like inputs without mutating original containers", () =>
Effect.gen(function* () {
const question = { question: "Pick one", multiple: "false", options: { label: "First", description: null } }
const input = { questions: [question] }
const event = yield* run(
input,
object({
questions: {
type: "array",
items: object({
question: { type: "string" },
multiple: { type: "boolean" },
options: {
type: "array",
items: object({ label: { type: "string" }, description: { type: "string" } }, ["label"]),
},
}),
},
}),
)
expect(event.input).toEqual({
questions: [{ question: "Pick one", multiple: false, options: [{ label: "First" }] }],
})
expect(input).toEqual({ questions: [question] })
expect(question.options.description).toBeNull()
}),
)
it.effect("repairs unique nullable alternatives while preserving accepted union values", () =>
Effect.gen(function* () {
const input = {
number: "2",
boolean: "false",
nullable: null,
typed: "3",
typedBoolean: "true",
accepted: "4",
valid: 5,
}
const event = yield* run(
input,
object({
number: { anyOf: [{ type: "number" }, { type: "null" }] },
boolean: { oneOf: [{ type: "boolean" }, { type: "null" }] },
nullable: { anyOf: [{ type: "number" }, { type: "null" }] },
typed: { type: ["integer", "null"] },
typedBoolean: { type: ["boolean", "null"] },
accepted: { anyOf: [{ type: "string" }, { type: "number" }] },
valid: { type: ["number", "null"] },
}),
)
expect(event.input).toEqual({ ...input, number: 2, boolean: false, typed: 3, typedBoolean: true })
}),
)
it.effect("repairs tuple positions and rest items while preserving valid array identity", () =>
Effect.gen(function* () {
const valid = [2, false]
const input = { prefix: ["2", "false", "3"], draft: '["4","true"]', valid, scalar: "5" }
const event = yield* run(
input,
object({
prefix: {
type: "array",
prefixItems: [{ type: "integer" }, { type: "boolean" }],
items: { type: "number" },
},
draft: { type: "array", items: [{ type: "integer" }, { type: "boolean" }] },
valid: { type: "array", prefixItems: [{ type: "integer" }, { type: "boolean" }] },
scalar: { type: "array", prefixItems: [{ type: "integer" }] },
}),
)
expect(event.input).toEqual({ prefix: [2, false, 3], draft: [4, true], valid, scalar: "5" })
expect((event.input as typeof input).valid).toBe(valid)
expect(input.prefix).toEqual(["2", "false", "3"])
}),
)
it.effect("repairs typed dictionaries and straightforward local references", () =>
Effect.gen(function* () {
const input = {
modern: "2",
legacy: "false",
nested: { count: "3" },
dictionary: { first: "4" },
missing: "5",
pointer: "6",
escaped: "7",
}
const event = yield* run(input, {
...object({
modern: { $ref: "#/$defs/integer" },
legacy: { $ref: "#/definitions/boolean" },
nested: { $ref: "#/$defs/nested" },
dictionary: { type: "object", additionalProperties: { $ref: "#/$defs/integer" } },
missing: { $ref: "#/$defs/missing" },
pointer: { $ref: "#/$defs/nested/properties/count" },
escaped: { $ref: "#/$defs/a~1b~0c" },
}),
$defs: {
integer: { type: "integer" },
"a/b~c": { type: "integer" },
nested: object({ count: { $ref: "#/$defs/integer" } }),
},
definitions: { boolean: { type: "boolean" } },
})
expect(event.input).toEqual({
modern: 2,
legacy: false,
nested: { count: 3 },
dictionary: { first: 4 },
missing: "5",
pointer: "6",
escaped: 7,
})
expect(input.nested.count).toBe("3")
expect(input.dictionary.first).toBe("4")
}),
)
it.effect("leaves ambiguous unions, compositions, and unsupported roots unchanged", () =>
Effect.gen(function* () {
const input = { numeric: "2", objects: { value: "3" }, both: "4", composed: "5", unknown: "6" }
const event = yield* run(
input,
object({
numeric: { anyOf: [{ type: "number" }, { type: "integer" }] },
objects: {
oneOf: [
object({ value: { type: "integer" } }, ["value"]),
object({ value: { type: "number" } }, ["value"]),
],
},
both: { anyOf: [{ type: "integer" }], oneOf: [{ type: "integer" }] },
composed: { allOf: [{ type: "integer" }] },
unknown: {},
}),
)
expect(event.input).toBe(input)
expect((yield* run(input, { properties: { numeric: { type: "integer" } } })).input).toBe(input)
expect((yield* run(input, { allOf: [object({ numeric: { type: "integer" } })] })).input).toBe(input)
}),
)
})
+10 -2
View File
@@ -28,8 +28,16 @@ describe("Provider", () => {
}
})
test("passes flat settings to native packages without opencode transport keys", () => {
expect(Provider.nativeSettings({ apiKey: "secret", reasoningEffort: "high", chunkTimeout: 1000 })).toEqual({
test("passes flat settings to native packages without Core settings", () => {
expect(
Provider.nativeSettings({
apiKey: "secret",
reasoningEffort: "high",
chunkTimeout: 1000,
compaction: { type: "native" },
transport: "websocket",
}),
).toEqual({
apiKey: "secret",
reasoningEffort: "high",
})
+3 -10
View File
@@ -207,20 +207,13 @@ it.effect("auto compaction estimates current content against the buffered prompt
const inputLimited = { context: 400_000, input: 272_000, output: 128_000 }
expect(compaction.required(input(251_999, inputLimited))).toBe(false)
expect(compaction.required(input(252_000, inputLimited))).toBe(true)
const native = (
tokens: number,
limit: { context: number; input?: number; output: number } = inputLimited,
threshold?: number,
) => {
const native = (tokens: number, limit: { context: number; input?: number; output: number } = inputLimited) => {
const selected = input(tokens, limit)
return { ...selected, resolved: { ...selected.resolved, compaction: { mode: "provider" as const, threshold } } }
return { ...selected, resolved: { ...selected.resolved, compaction: { type: "native" as const } } }
}
expect(compaction.required(native(251_999))).toBe(false)
expect(compaction.required(native(252_000))).toBe(true)
expect(compaction.required(native(99_999, inputLimited, 100_000))).toBe(false)
expect(compaction.required(native(100_000, inputLimited, 100_000))).toBe(true)
expect(compaction.required(native(252_000, inputLimited, 500_000))).toBe(true)
expect(compaction.required(native(1_000_000, { context: 0, input: undefined, output: 0 }, 100_000))).toBe(false)
expect(compaction.required(native(1_000_000, { context: 0, input: undefined, output: 0 }))).toBe(false)
const contextLimited = { context: 100_000, output: 10_000 }
expect(compaction.required(input(79_999, contextLimited))).toBe(false)
@@ -164,7 +164,7 @@ const setup = Effect.fnUntraced(function* (options: { endpoint?: boolean; plugin
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
limit: { context: 200_000, output: 32_000 },
compaction: { mode: "provider" },
compaction: { type: "native" },
},
)
const sessionID = SessionSchema.ID.create()
+4 -2
View File
@@ -2863,7 +2863,8 @@ describe("SessionRunnerLLM", () => {
scenario("automatically persists native windows, retains earlier users, and waits for fresh usage", function* (s) {
s.currentModel = LanguageModel.make({ id: "native", provider: "openai", route: OpenAIResponses.route })
s.compaction = { mode: "provider", threshold: 10_000 }
modelLimits.set("native", { context: 42_000, output: 32_000 })
s.compaction = { type: "native" }
const agents = yield* Agent.Service
yield* agents.transform((editor) =>
editor.update(Agent.defaultID, (agent) => {
@@ -2914,7 +2915,8 @@ describe("SessionRunnerLLM", () => {
scenario("recovers an overflowing native window locally from original durable history", function* (s) {
s.currentModel = LanguageModel.make({ id: "native", provider: "openai", route: OpenAIResponses.route })
s.compaction = { mode: "provider", threshold: 10_000 }
modelLimits.set("native", { context: 42_000, output: 32_000 })
s.compaction = { type: "native" }
yield* s.llm.push(TestLLM.textWithUsage("Earlier answer", "before-native", 10_000))
yield* s.runPrompt("Original durable request")
yield* s.llm.push(
+3
View File
@@ -106,6 +106,9 @@ test("Core reuses the canonical shared schemas", async () => {
[coreModel.Info, Model.Info],
[coreProvider.ID, Provider.ID],
[coreProvider.Request, Provider.Request],
[coreProvider.Compaction, Provider.Compaction],
[coreProvider.Transport, Provider.Transport],
[coreProvider.Settings, Provider.Settings],
[coreProvider.Info, Provider.Info],
[corePermission.Effect, Permission.Effect],
[corePermission.Rule, Permission.Rule],
@@ -17,8 +17,6 @@ protocol.registerSchemesAsPrivileged([
standard: true,
supportFetchAPI: true,
stream: true,
// Let Chromium keep V8 bytecode for the renderer bundle between launches.
codeCache: true,
},
},
])
+2 -2
View File
@@ -5,8 +5,8 @@ export default Plugin.define({
id: "opencode.latex",
setup(context) {
const render = createLatexCodeBlockRenderer(context.renderer, () => ({
text: context.theme.text.default,
subdued: context.theme.text.subdued,
text: context.theme.text.base,
subdued: context.theme.text.muted,
}))
context.markdown.registerCodeBlockRenderer("latex", render)
context.markdown.registerCodeBlockRenderer("math", render)
+6 -6
View File
@@ -83,15 +83,15 @@ describe("OpenCode diagram palette", () => {
}
const theme = {
text: {
default: rgb([230, 232, 240]),
subdued: rgb([114, 120, 138]),
base: rgb([230, 232, 240]),
muted: rgb([114, 120, 138]),
feedback: {
info: { default: rgb([40, 120, 220]) },
success: { default: rgb([80, 180, 120]) },
warning: { default: rgb([220, 160, 80]) },
info: { base: rgb([40, 120, 220]) },
success: { base: rgb([80, 180, 120]) },
warning: { base: rgb([220, 160, 80]) },
},
},
background: { default: rgb([250, 250, 250]) },
background: { base: rgb([250, 250, 250]) },
categorical: [accent],
}
const palette = resolveOpenCodeDiagramPalette(theme, mode)
+12 -12
View File
@@ -46,27 +46,27 @@ export function createOpenCodeDiagramPalette(input: OpenCodeDiagramPaletteInput)
export function resolveOpenCodeDiagramPalette(
theme: {
readonly text: {
readonly default: RGBA
readonly subdued: RGBA
readonly base: RGBA
readonly muted: RGBA
readonly feedback: {
readonly info: { readonly default: RGBA }
readonly success: { readonly default: RGBA }
readonly warning: { readonly default: RGBA }
readonly info: { readonly base: RGBA }
readonly success: { readonly base: RGBA }
readonly warning: { readonly base: RGBA }
}
}
readonly background: { readonly default: RGBA }
readonly background: { readonly base: RGBA }
readonly categorical: readonly Readonly<Record<200 | 300 | 700 | 800, RGBA>>[]
},
mode: "dark" | "light",
) {
const accent = theme.categorical[3] ?? theme.categorical[0]!
return createOpenCodeDiagramPalette({
text: theme.text.default,
subdued: theme.text.subdued,
info: theme.text.feedback.info.default,
success: theme.text.feedback.success.default,
warning: theme.text.feedback.warning.default,
background: theme.background.default,
text: theme.text.base,
subdued: theme.text.muted,
info: theme.text.feedback.info.base,
success: theme.text.feedback.success.base,
warning: theme.text.feedback.warning.base,
background: theme.background.base,
accent: {
soft: accent[mode === "dark" ? 300 : 700],
clear: accent[mode === "dark" ? 200 : 800],
+76 -38
View File
@@ -13161,7 +13161,7 @@
"properties": {
"action": {
"type": "string",
"enum": ["provider.use"]
"enum": ["provider.use", "permission"]
},
"resource": {
"type": "string"
@@ -13254,12 +13254,6 @@
"Config.ModelEncoded": {
"type": "object",
"properties": {
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"transport": {
"$ref": "#/components/schemas/Provider.Transport"
},
"modelID": {
"type": "string"
},
@@ -13276,7 +13270,7 @@
"type": "string"
},
"settings": {
"type": "object"
"$ref": "#/components/schemas/Config.Provider.Settings"
},
"headers": {
"type": "object",
@@ -13299,7 +13293,7 @@
"type": "string"
},
"settings": {
"type": "object"
"$ref": "#/components/schemas/Config.Provider.Settings"
},
"headers": {
"type": "object",
@@ -13379,15 +13373,47 @@
"required": ["package"],
"additionalProperties": false
},
"Config.ProviderEncoded": {
"Config.Provider.Settings": {
"type": "object",
"properties": {
"timeout": {
"anyOf": [
{
"type": "number"
},
{
"type": "boolean",
"enum": [false]
}
]
},
"chunkTimeout": {
"type": "number"
},
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"transport": {
"$ref": "#/components/schemas/Provider.Transport"
},
}
},
"allOf": [
{
"type": "object",
"additionalProperties": {
"anyOf": [
{},
{
"type": "null"
}
]
}
}
]
},
"Config.ProviderEncoded": {
"type": "object",
"properties": {
"canonical": {
"type": "string"
},
@@ -13404,7 +13430,7 @@
"type": "string"
},
"settings": {
"type": "object"
"$ref": "#/components/schemas/Config.Provider.Settings"
},
"headers": {
"type": "object",
@@ -15481,14 +15507,8 @@
"package": {
"type": "string"
},
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"transport": {
"$ref": "#/components/schemas/Provider.Transport"
},
"settings": {
"type": "object"
"$ref": "#/components/schemas/Provider.Settings"
},
"headers": {
"type": "object",
@@ -15601,7 +15621,7 @@
"type": "string"
},
"settings": {
"type": "object"
"$ref": "#/components/schemas/Provider.Settings"
},
"headers": {
"type": "object",
@@ -16418,27 +16438,23 @@
{
"type": "object",
"properties": {
"mode": {
"type": {
"type": "string",
"enum": ["local"]
"enum": ["summary"]
}
},
"required": ["mode"],
"required": ["type"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"mode": {
"type": {
"type": "string",
"enum": ["provider"]
},
"threshold": {
"type": "integer",
"exclusiveMinimum": 0
"enum": ["native"]
}
},
"required": ["mode"],
"required": ["type"],
"additionalProperties": false
}
]
@@ -16465,14 +16481,8 @@
"package": {
"type": "string"
},
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"transport": {
"$ref": "#/components/schemas/Provider.Transport"
},
"settings": {
"type": "object"
"$ref": "#/components/schemas/Provider.Settings"
},
"headers": {
"type": "object",
@@ -16507,7 +16517,35 @@
"additionalProperties": false
},
"Provider.Settings": {
"type": "object"
"type": "object",
"properties": {
"timeout": {
"anyOf": [
{
"type": "number"
},
{
"type": "boolean",
"enum": [false]
}
]
},
"chunkTimeout": {
"type": "number"
},
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"transport": {
"$ref": "#/components/schemas/Provider.Transport"
}
},
"allOf": [
{
"type": "object",
"additionalProperties": {}
}
]
},
"Provider.Transport": {
"type": "string",
+12 -10
View File
@@ -6,10 +6,21 @@ import { Capabilities, Compatibility, Family, ID, VariantID } from "../model.js"
import { Provider } from "../provider.js"
import { optional } from "../schema.js"
export const Settings = Schema.StructWithRest(
Schema.Struct({
timeout: Schema.Union([Schema.Finite, Schema.Literal(false)]).pipe(optional),
chunkTimeout: Schema.Finite.pipe(optional),
compaction: Provider.Compaction.pipe(optional),
transport: Provider.Transport.pipe(optional),
}),
[Schema.Record(Schema.String, Schema.UndefinedOr(Schema.Json))],
).annotate({ identifier: "Config.Provider.Settings" })
export type Settings = typeof Settings.Type
const JsonRecord = Schema.Record(Schema.String, Schema.Json)
export const Overlays = {
settings: JsonRecord.pipe(optional),
settings: Settings.pipe(optional),
headers: Schema.Record(Schema.String, Schema.String).pipe(optional),
body: JsonRecord.pipe(optional),
}
@@ -41,10 +52,6 @@ class Limit extends Schema.Class<Limit>("Config.Model.Limit")({
}) {}
class Model extends Schema.Class<Model>("Config.Model")({
compaction: Provider.Compaction.pipe(optional),
transport: Provider.Transport.pipe(optional).annotate({
description: "Session transport for this model. Defaults to the provider transport.",
}),
modelID: ID.pipe(optional),
family: Family.pipe(optional),
name: Schema.String.pipe(optional),
@@ -62,11 +69,6 @@ class Model extends Schema.Class<Model>("Config.Model")({
}) {}
export class Info extends Schema.Class<Info>("Config.Provider")({
compaction: Provider.Compaction.pipe(optional),
transport: Provider.Transport.pipe(optional).annotate({
description:
"Session transport for this provider's models. Defaults to the built-in policy; \"websocket\" on a route without a WebSocket channel warns and falls back to HTTP.",
}),
canonical: Provider.ID.pipe(optional),
name: Schema.String.pipe(optional),
env: Schema.String.pipe(Schema.Array, optional),
-3
View File
@@ -110,9 +110,6 @@ export const Info = Schema.Struct({
name: Schema.String,
compatibility: Compatibility.pipe(optional),
package: Provider.Package.pipe(optional),
compaction: Provider.Compaction.pipe(optional),
/** Session transport; omitted inherits the provider transport, then defaults to HTTP. */
transport: Provider.Transport.pipe(optional),
...Provider.Overlays,
capabilities: Capabilities,
variants: Schema.Array(Variant),
+19 -12
View File
@@ -2,7 +2,7 @@ export * as Provider from "./provider.js"
import { Effect, Schema } from "effect"
import { Integration } from "./integration.js"
import { optional, PositiveInt, statics } from "./schema.js"
import { optional, statics } from "./schema.js"
import { ephemeral, inventory } from "./event.js"
export const ID = Schema.String.pipe(
@@ -32,25 +32,35 @@ export type Package = typeof Package.Type
export const Activation = Schema.Literals(["auto", "enabled", "disabled"])
export type Activation = typeof Activation.Type
export type Compaction = typeof Compaction.Type
export const Compaction = Schema.Union([
Schema.Struct({ mode: Schema.Literal("local") }),
Schema.Struct({ mode: Schema.Literal("provider"), threshold: PositiveInt.pipe(optional) }),
]).annotate({ identifier: "Provider.Compaction" })
Schema.Struct({ type: Schema.Literal("summary") }),
Schema.Struct({ type: Schema.Literal("native") }),
])
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Provider.Compaction" })
export type Compaction = typeof Compaction.Type
/** "websocket" on a route without a WebSocket channel warns and falls back to HTTP. */
export const Transport = Schema.Literals(["http", "websocket"]).annotate({ identifier: "Provider.Transport" })
export type Transport = typeof Transport.Type
export const Settings = Schema.StructWithRest(
Schema.Struct({
timeout: Schema.Union([Schema.Finite, Schema.Literal(false)]).pipe(optional),
chunkTimeout: Schema.Finite.pipe(optional),
compaction: Compaction.pipe(optional),
transport: Transport.pipe(optional),
}),
[Schema.Record(Schema.String, Schema.Any)],
).annotate({ identifier: "Provider.Settings" })
export type Settings = typeof Settings.Type
export const Overlays = {
settings: Schema.Record(Schema.String, Schema.Any).pipe(optional),
settings: Settings.pipe(optional),
headers: Schema.Record(Schema.String, Schema.String).pipe(optional),
body: Schema.Record(Schema.String, Schema.Any).pipe(optional),
}
export const Settings = Schema.Record(Schema.String, Schema.Any).annotate({ identifier: "Provider.Settings" })
export type Settings = typeof Settings.Type
export interface Request extends Schema.Schema.Type<typeof Request> {}
export const Request = Schema.Struct({
settings: Settings.pipe(Schema.withConstructorDefault(Effect.succeed({}))),
@@ -66,9 +76,6 @@ export const Info = Schema.Struct({
name: Schema.String,
activation: Activation,
package: Package,
compaction: Compaction.pipe(optional),
/** Session transport for this provider's models; omitted means HTTP. */
transport: Transport.pipe(optional),
...Overlays,
})
.annotate({ identifier: "Provider.Info" })
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
import { DateTime, Schema } from "effect"
import { Agent } from "../src/agent.js"
import { ConfigAgent } from "../src/config/agent.js"
import { ConfigProvider } from "../src/config/provider.js"
import { FileSystem } from "../src/filesystem.js"
import { Form } from "../src/form.js"
import { Mcp } from "../src/mcp.js"
@@ -168,6 +169,7 @@ describe("contract hygiene", () => {
test("reusable public identifiers are stable and unique", () => {
const identifiers = [
Agent.Color,
ConfigProvider.Settings,
FileSystem.Submatch,
Form.Field,
Form.Fields,
@@ -226,7 +228,7 @@ describe("contract hygiene", () => {
}
})
test("current source limits Any to provider options and avoids mutable contract wrappers", async () => {
test("current source limits Any to reviewed boundaries and avoids mutable contract wrappers", async () => {
const files = [...new Bun.Glob("*.ts").scanSync(new URL("../src", import.meta.url).pathname)].filter(
(file) => !file.endsWith("-v1.ts"),
)
@@ -237,11 +239,12 @@ describe("contract hygiene", () => {
expect(
sources
.filter((item) => item.file !== "provider.ts")
.filter((item) => item.file !== "provider.ts" && item.file !== "integration.ts")
.map((item) => item.source)
.join("\n"),
).not.toContain("Schema.Any")
expect(sources.find((item) => item.file === "provider.ts")?.source.match(/Schema\.Any/g)).toHaveLength(4)
expect(sources.find((item) => item.file === "provider.ts")?.source.match(/Schema\.Any/g)).toHaveLength(3)
expect(sources.find((item) => item.file === "integration.ts")?.source.match(/Schema\.Any/g)).toHaveLength(2)
expect(source).not.toContain("Schema.mutable")
})
+14 -19
View File
@@ -56,23 +56,16 @@ describe("Model.Compatibility", () => {
})
describe("Model.Info", () => {
test("provider compaction policy is optional and uses the canonical closed schema", () => {
test("provider compaction policy is a typed setting", () => {
const model = Model.Info.default(Provider.ID.openai, Model.ID.make("gpt-5.4-mini"))
expect(Schema.encodeSync(Model.Info)({ ...model, compaction: undefined })).not.toHaveProperty("compaction")
expect(Schema.decodeUnknownSync(Model.Info)({ ...model, compaction: { mode: "provider" } }).compaction).toEqual({
mode: "provider",
expect(Schema.encodeSync(Model.Info)({ ...model, settings: { compaction: undefined } }).settings).toEqual({})
expect(
Schema.decodeUnknownSync(Model.Info)({ ...model, settings: { compaction: { type: "native" } } }).settings,
).toEqual({
compaction: { type: "native" },
})
expect(Schema.decodeUnknownSync(Provider.Compaction)({ mode: "local" })).toEqual({ mode: "local" })
expect(Schema.encodeSync(Provider.Compaction)({ mode: "provider", threshold: undefined })).toEqual({
mode: "provider",
})
expect(Schema.decodeUnknownSync(Provider.Compaction)({ mode: "provider", threshold: 120_000 })).toEqual({
mode: "provider",
threshold: 120_000,
})
for (const threshold of [0, -1, 1.5])
expect(() => Schema.decodeUnknownSync(Provider.Compaction)({ mode: "provider", threshold })).toThrow()
expect(() => Schema.decodeUnknownSync(Provider.Compaction)({ mode: "automatic" })).toThrow()
expect(Schema.decodeUnknownSync(Provider.Compaction)({ type: "summary" })).toEqual({ type: "summary" })
expect(() => Schema.decodeUnknownSync(Provider.Compaction)({ type: "automatic" })).toThrow()
})
test("uses practical token limits for unknown models", () => {
@@ -83,10 +76,12 @@ describe("Model.Info", () => {
})
describe("Model.Capabilities", () => {
test("decodes the optional transport preference", () => {
test("decodes the optional transport setting", () => {
const model = Model.Info.default(Provider.ID.openai, Model.ID.make("gpt-5.4-mini"))
expect(Schema.encodeSync(Model.Info)({ ...model, transport: undefined })).not.toHaveProperty("transport")
expect(Schema.decodeUnknownSync(Model.Info)({ ...model, transport: "websocket" }).transport).toBe("websocket")
expect(() => Schema.decodeUnknownSync(Model.Info)({ ...model, transport: "sse" })).toThrow()
expect(Schema.encodeSync(Model.Info)({ ...model, settings: { transport: undefined } }).settings).toEqual({})
expect(Schema.decodeUnknownSync(Model.Info)({ ...model, settings: { transport: "websocket" } }).settings).toEqual({
transport: "websocket",
})
expect(() => Schema.decodeUnknownSync(Model.Info)({ ...model, settings: { transport: "sse" } })).toThrow()
})
})
+17 -11
View File
@@ -2,21 +2,27 @@
import { $ } from "bun"
import { rm } from "node:fs/promises"
import path from "node:path"
import { fileURLToPath } from "node:url"
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
await rm("dist", { recursive: true, force: true })
await $`bun tsc -p tsconfig.build.json`
const files = await Array.fromAsync(new Bun.Glob("**/*.ts").scan({ cwd: "src" })).then((items) =>
items.map((item) => `src/${item}`),
)
const transpiler = new Bun.Transpiler({ loader: "ts", target: "bun" })
await Promise.all(
files.map(async (file) =>
Bun.write(
file.replace(/^src\//, "dist/").replace(/\.ts$/, ".js"),
await transpiler.transform(await Bun.file(file).text()),
),
const root = path.resolve("src")
const result = await Bun.build({
entrypoints: await Array.fromAsync(new Bun.Glob("**/*.ts").scan({ cwd: root, absolute: true })).then((files) =>
files.filter((file) => !file.endsWith(".d.ts")),
),
)
root,
outdir: "dist",
target: "node",
format: "esm",
packages: "external",
splitting: true,
naming: {
entry: "[dir]/[name].[ext]",
chunk: "chunks/[name]-[hash].[ext]",
},
})
if (!result.success) throw new AggregateError(result.logs, "Failed to build SDK")
+1
View File
@@ -213,6 +213,7 @@ for (const module of modules) {
throw new Error(`Packed SDK consumer resolved multiple Effect runtimes:\n${runtimes.join("\n")}`)
}
await $`bun imports.mjs`.cwd(consumer)
await $`node imports.mjs`.cwd(consumer)
await $`bun --conditions=workerd imports.mjs`.cwd(consumer)
await $`node_modules/.bin/wrangler deploy --dry-run --config wrangler.jsonc --outdir dist`.cwd(consumer)
+17 -11
View File
@@ -2,21 +2,27 @@
import { $ } from "bun"
import { rm } from "node:fs/promises"
import path from "node:path"
import { fileURLToPath } from "node:url"
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
await rm("dist", { recursive: true, force: true })
await $`bun tsc -p tsconfig.build.json`
const files = await Array.fromAsync(new Bun.Glob("**/*.ts").scan({ cwd: "src" })).then((items) =>
items.map((item) => `src/${item}`),
)
const transpiler = new Bun.Transpiler({ loader: "ts", target: "bun" })
await Promise.all(
files.map(async (file) =>
Bun.write(
file.replace(/^src\//, "dist/").replace(/\.ts$/, ".js"),
await transpiler.transform(await Bun.file(file).text()),
),
const root = path.resolve("src")
const result = await Bun.build({
entrypoints: await Array.fromAsync(new Bun.Glob("**/*.ts").scan({ cwd: root, absolute: true })).then((files) =>
files.filter((file) => !file.endsWith(".d.ts")),
),
)
root,
outdir: "dist",
target: "node",
format: "esm",
packages: "external",
splitting: true,
naming: {
entry: "[dir]/[name].[ext]",
chunk: "chunks/[name]-[hash].[ext]",
},
})
if (!result.success) throw new AggregateError(result.logs, "Failed to build Server")
+3
View File
@@ -0,0 +1,3 @@
import type { HueName } from "./schema.js"
export const DEFAULT_CATEGORICAL = ["blue", "purple", "green", "orange", "red", "cyan"] as const satisfies readonly HueName[]
-458
View File
@@ -1,458 +0,0 @@
import type { HueName, ThemeDocument } from "./schema.js"
export const DEFAULT_CATEGORICAL = [
"blue",
"purple",
"green",
"orange",
"red",
"cyan",
] as const satisfies readonly HueName[]
export const DEFAULT_THEME = {
version: 2,
light: {
hue: {
gray: {
100: "#f3f4f6",
200: "#e5e7eb",
300: "#d1d5db",
400: "#9ca3af",
500: "#6b7280",
600: "#4b5563",
700: "#374151",
800: "#1f2937",
900: "#111827",
},
red: {
100: "#fee2e2",
200: "#fecaca",
300: "#fca5a5",
400: "#f87171",
500: "#ef4444",
600: "#dc2626",
700: "#b91c1c",
800: "#991b1b",
900: "#7f1d1d",
},
orange: {
100: "#ffedd5",
200: "#fed7aa",
300: "#fdba74",
400: "#fb923c",
500: "#f97316",
600: "#ea580c",
700: "#c2410c",
800: "#9a3412",
900: "#7c2d12",
},
yellow: {
100: "#fef9c3",
200: "#fef08a",
300: "#fde047",
400: "#facc15",
500: "#eab308",
600: "#ca8a04",
700: "#a16207",
800: "#854d0e",
900: "#713f12",
},
green: {
100: "#dcfce7",
200: "#bbf7d0",
300: "#86efac",
400: "#4ade80",
500: "#22c55e",
600: "#16a34a",
700: "#15803d",
800: "#166534",
900: "#14532d",
},
cyan: {
100: "#cffafe",
200: "#a5f3fc",
300: "#67e8f9",
400: "#22d3ee",
500: "#06b6d4",
600: "#0891b2",
700: "#0e7490",
800: "#155e75",
900: "#164e63",
},
blue: {
100: "#dbeafe",
200: "#bfdbfe",
300: "#93c5fd",
400: "#60a5fa",
500: "#3b82f6",
600: "#2563eb",
700: "#1d4ed8",
800: "#1e40af",
900: "#1e3a8a",
},
purple: {
100: "#f3e8ff",
200: "#e9d5ff",
300: "#d8b4fe",
400: "#c084fc",
500: "#a855f7",
600: "#9333ea",
700: "#7e22ce",
800: "#6b21a8",
900: "#581c87",
},
accent: "$hue.blue",
interactive: "$hue.blue",
neutral: "$hue.gray",
},
categorical: DEFAULT_CATEGORICAL,
text: {
default: "$hue.neutral.800",
subdued: "$hue.neutral.600",
action: {
primary: { default: "$hue.neutral.200", $disabled: "$hue.neutral.500" },
secondary: { default: "$text.subdued", $hovered: "$text.default" },
destructive: { default: "$hue.red.200", $disabled: "$hue.neutral.500" },
},
formfield: {
default: "$hue.neutral.800",
$focused: "$text.action.primary.default",
$pressed: "$hue.neutral.200",
$disabled: "$hue.neutral.500",
$selected: "$hue.interactive.700",
},
status: {
running: "$hue.interactive.800",
question: "$text.status.unread",
permission: "$text.status.unread",
unread: "$hue.accent.800",
},
feedback: {
error: { default: "$hue.red.700", subdued: "$hue.red.600" },
warning: { default: "$hue.yellow.800", subdued: "$hue.yellow.700" },
success: { default: "$hue.green.700", subdued: "$hue.green.600" },
info: { default: "$hue.cyan.700", subdued: "$hue.cyan.600" },
},
},
background: {
default: "$hue.neutral.200",
raised: {
base: "$hue.neutral.300",
high: "$hue.neutral.400",
max: "$hue.neutral.500",
},
action: {
primary: {
default: "$hue.interactive.600",
$hovered: "$hue.interactive.700",
$focused: "$hue.interactive.700",
$pressed: "$hue.interactive.800",
$selected: "$hue.interactive.700",
$disabled: "$hue.neutral.300",
},
secondary: { default: "transparent" },
destructive: {
default: "$hue.red.600",
$hovered: "$hue.red.700",
$focused: "$hue.red.700",
$pressed: "$hue.red.800",
$selected: "$hue.red.700",
$disabled: "$hue.neutral.300",
},
},
formfield: {
default: "$background.default",
$hovered: "$background.raised.base",
$focused: "$background.action.primary.default",
$pressed: "$hue.interactive.800",
$disabled: "$background.default",
$selected: "$background.formfield.default",
},
feedback: {
error: { default: "$background.default" },
warning: { default: "$background.default" },
success: { default: "$background.default" },
info: { default: "$background.default" },
},
},
border: { default: "$hue.neutral.300" },
scrollbar: { default: "$hue.neutral.400" },
diff: {
text: {
added: "$hue.green.700",
removed: "$hue.red.700",
context: "$hue.neutral.900",
hunkHeader: "$hue.purple.600",
},
background: { added: "$hue.green.100", removed: "$hue.red.100", context: "$hue.neutral.100" },
highlight: { added: "$hue.green.600", removed: "$hue.red.600" },
lineNumber: {
text: "$hue.neutral.600",
background: { added: "$hue.green.200", removed: "$hue.red.200" },
},
},
syntax: {
comment: "$hue.neutral.600",
keyword: "$hue.purple.600",
function: "$hue.accent.600",
variable: "$hue.neutral.900",
string: "$hue.green.700",
number: "$hue.yellow.800",
type: "$hue.yellow.500",
operator: "$hue.cyan.600",
punctuation: "$hue.neutral.900",
},
markdown: {
text: "$hue.neutral.900",
heading: "$hue.purple.600",
link: "$hue.accent.600",
linkText: "$hue.cyan.600",
code: "$hue.green.700",
blockQuote: "$hue.neutral.600",
emphasis: "$hue.yellow.500",
strong: "$hue.neutral.900",
horizontalRule: "$hue.neutral.300",
listItem: "$hue.accent.600",
listEnumeration: "$hue.cyan.600",
image: "$hue.accent.600",
imageText: "$hue.cyan.600",
codeBlock: "$hue.neutral.900",
},
"@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: {
gray: {
100: "#f3f4f6",
200: "#e5e7eb",
300: "#d1d5db",
400: "#9ca3af",
500: "#6b7280",
600: "#4b5563",
700: "#374151",
800: "#1f2937",
900: "#111827",
},
red: {
100: "#fee2e2",
200: "#fecaca",
300: "#fca5a5",
400: "#f87171",
500: "#ef4444",
600: "#dc2626",
700: "#b91c1c",
800: "#991b1b",
900: "#7f1d1d",
},
orange: {
100: "#ffedd5",
200: "#fed7aa",
300: "#fdba74",
400: "#fb923c",
500: "#f97316",
600: "#ea580c",
700: "#c2410c",
800: "#9a3412",
900: "#7c2d12",
},
yellow: {
100: "#fef9c3",
200: "#fef08a",
300: "#fde047",
400: "#facc15",
500: "#eab308",
600: "#ca8a04",
700: "#a16207",
800: "#854d0e",
900: "#713f12",
},
green: {
100: "#dcfce7",
200: "#bbf7d0",
300: "#86efac",
400: "#4ade80",
500: "#22c55e",
600: "#16a34a",
700: "#15803d",
800: "#166534",
900: "#14532d",
},
cyan: {
100: "#cffafe",
200: "#a5f3fc",
300: "#67e8f9",
400: "#22d3ee",
500: "#06b6d4",
600: "#0891b2",
700: "#0e7490",
800: "#155e75",
900: "#164e63",
},
blue: {
100: "#dbeafe",
200: "#bfdbfe",
300: "#93c5fd",
400: "#60a5fa",
500: "#3b82f6",
600: "#2563eb",
700: "#1d4ed8",
800: "#1e40af",
900: "#1e3a8a",
},
purple: {
100: "#f3e8ff",
200: "#e9d5ff",
300: "#d8b4fe",
400: "#c084fc",
500: "#a855f7",
600: "#9333ea",
700: "#7e22ce",
800: "#6b21a8",
900: "#581c87",
},
accent: "$hue.blue",
interactive: "$hue.blue",
neutral: "$hue.gray",
},
categorical: DEFAULT_CATEGORICAL,
text: {
default: "$hue.neutral.200",
subdued: "$hue.neutral.400",
action: {
primary: { default: "$hue.neutral.200", $disabled: "$hue.neutral.500" },
secondary: { default: "$text.subdued", $hovered: "$text.default" },
destructive: { default: "$hue.red.200", $disabled: "$hue.neutral.500" },
},
formfield: {
default: "$hue.neutral.200",
$focused: "$text.action.primary.default",
$pressed: "$hue.neutral.200",
$disabled: "$hue.neutral.500",
$selected: "$hue.interactive.500",
},
status: {
running: "$hue.interactive.200",
question: "$text.status.unread",
permission: "$text.status.unread",
unread: "$hue.accent.200",
},
feedback: {
error: { default: "$hue.red.300", subdued: "$hue.red.400" },
warning: { default: "$hue.yellow.200", subdued: "$hue.yellow.300" },
success: { default: "$hue.green.300", subdued: "$hue.green.400" },
info: { default: "$hue.cyan.300", subdued: "$hue.cyan.400" },
},
},
background: {
default: "$hue.neutral.800",
raised: {
base: "$hue.neutral.700",
high: "$hue.neutral.600",
max: "$hue.neutral.500",
},
action: {
primary: {
default: "$hue.interactive.500",
$hovered: "$hue.interactive.600",
$focused: "$hue.interactive.600",
$pressed: "$hue.interactive.800",
$selected: "$hue.interactive.600",
$disabled: "$hue.neutral.800",
},
secondary: { default: "transparent" },
destructive: {
default: "$hue.red.600",
$hovered: "$hue.red.700",
$focused: "$hue.red.700",
$pressed: "$hue.red.800",
$selected: "$hue.red.700",
$disabled: "$hue.neutral.800",
},
},
formfield: {
default: "$background.default",
$hovered: "$background.raised.base",
$focused: "$background.action.primary.default",
$pressed: "$hue.interactive.800",
$disabled: "$background.default",
$selected: "$background.formfield.default",
},
feedback: {
error: { default: "$background.default" },
warning: { default: "$background.default" },
success: { default: "$background.default" },
info: { default: "$background.default" },
},
},
border: { default: "$hue.neutral.700" },
scrollbar: { default: "$hue.neutral.600" },
diff: {
text: {
added: "$hue.green.300",
removed: "$hue.red.300",
context: "$hue.neutral.100",
hunkHeader: "$hue.purple.400",
},
background: { added: "$hue.green.900", removed: "$hue.red.900", context: "$hue.neutral.900" },
highlight: { added: "$hue.green.400", removed: "$hue.red.400" },
lineNumber: {
text: "$hue.neutral.400",
background: { added: "$hue.green.800", removed: "$hue.red.800" },
},
},
syntax: {
comment: "$hue.neutral.400",
keyword: "$hue.purple.400",
function: "$hue.accent.400",
variable: "$hue.neutral.100",
string: "$hue.green.300",
number: "$hue.yellow.200",
type: "$hue.yellow.500",
operator: "$hue.cyan.400",
punctuation: "$hue.neutral.100",
},
markdown: {
text: "$hue.neutral.100",
heading: "$hue.purple.400",
link: "$hue.accent.400",
linkText: "$hue.cyan.400",
code: "$hue.green.300",
blockQuote: "$hue.neutral.400",
emphasis: "$hue.yellow.500",
strong: "$hue.neutral.100",
horizontalRule: "$hue.neutral.700",
listItem: "$hue.accent.400",
listEnumeration: "$hue.cyan.400",
image: "$hue.accent.400",
imageText: "$hue.cyan.400",
codeBlock: "$hue.neutral.100",
},
"@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
+8 -12
View File
@@ -11,11 +11,7 @@ export function expandTheme<Definition extends ModeDefinition>(definition: Defin
return {
...definition,
...expandTokens(definition),
...Object.fromEntries(
Object.entries(definition)
.filter(([key]) => key.startsWith("@context:"))
.map(([key, value]) => [key, expandTokens(value as ThemeTokensDefinition)]),
),
...(definition["@dialog"] ? { "@dialog": expandTokens(definition["@dialog"]) } : {}),
}
}
@@ -31,7 +27,7 @@ export function mergeTheme(...values: unknown[]): Record<string, unknown> {
return values.reduce<Record<string, unknown>>((result, value) => {
if (!isRecord(value)) return result
return Object.entries(value).reduce<Record<string, unknown>>((next, [key, item]) => {
if (item === undefined || key === "mergeMode") return next
if (item === undefined) return next
return {
...next,
[key]: isRecord(item) ? mergeTheme(next[key], item) : item,
@@ -44,7 +40,7 @@ function expandText(definition: TextDefinition | undefined): TextDefinition | un
if (!definition) return
return {
...definition,
subdued: definition.subdued ?? (definition.default ? "$text.default" : undefined),
muted: definition.muted ?? (definition.base ? "$text.base" : undefined),
action: expandActions(definition.action, "text.action"),
formfield: expandFormfield(definition.formfield, "text.formfield"),
feedback: definition.feedback
@@ -54,7 +50,7 @@ function expandText(definition: TextDefinition | undefined): TextDefinition | un
kind,
{
...feedback,
subdued: feedback.subdued ?? (feedback.default ? `$text.feedback.${kind}.default` : undefined),
muted: feedback.muted ?? (feedback.base ? `$text.feedback.${kind}.base` : undefined),
},
]
}),
@@ -73,11 +69,11 @@ function expandBackground(definition: BackgroundDefinition | undefined): Backgro
}
function expandFormfield(definition: StatefulColorDefinition | undefined, path: string) {
if (!definition?.default) return definition
if (!definition?.base) return definition
return {
...definition,
...Object.fromEntries(
ActionState.literals.map((state) => [`$${state}`, definition[`$${state}`] ?? `$${path}.default`]),
ActionState.literals.map((state) => [`$${state}`, definition[`$${state}`] ?? `$${path}.base`]),
),
}
}
@@ -89,13 +85,13 @@ function expandActions<Definition extends Partial<Record<string, StatefulColorDe
if (!definition) return
return Object.fromEntries(
Object.entries(definition).map(([variant, value]) => {
if (!value?.default) return [variant, value]
if (!value?.base) return [variant, value]
return [
variant,
{
...value,
...Object.fromEntries(
ActionState.literals.map((state) => [`$${state}`, value[`$${state}`] ?? `$${path}.${variant}.default`]),
ActionState.literals.map((state) => [`$${state}`, value[`$${state}`] ?? `$${path}.${variant}.base`]),
),
},
]
-59
View File
@@ -1,59 +0,0 @@
import type { Mode, ThemeTokensDefinition } from "./index.js"
import { DEFAULT_THEME } from "./defaults.js"
import { ActionVariant, FeedbackKind } from "./schema.js"
export function fallback(mode: Mode): ThemeTokensDefinition {
const red = "#ff0000"
return {
text: {
default: red,
action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])),
formfield: { default: red },
status: DEFAULT_THEME[mode].text.status,
feedback: Object.fromEntries(FeedbackKind.literals.map((kind) => [kind, { default: red }])),
},
background: {
default: red,
raised: { base: red, high: red, max: red },
action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])),
formfield: { default: red },
feedback: Object.fromEntries(FeedbackKind.literals.map((kind) => [kind, { default: red }])),
},
border: { default: red },
scrollbar: { default: red },
diff: {
text: { added: red, removed: red, context: red, hunkHeader: red },
background: { added: red, removed: red, context: red },
highlight: { added: red, removed: red },
lineNumber: { text: red, background: { added: red, removed: red } },
},
syntax: {
comment: red,
keyword: red,
function: red,
variable: red,
string: red,
number: red,
type: red,
operator: red,
punctuation: red,
},
markdown: {
text: red,
heading: red,
link: red,
linkText: red,
code: red,
blockQuote: red,
emphasis: red,
strong: red,
horizontalRule: red,
listItem: red,
listEnumeration: red,
image: red,
imageText: red,
codeBlock: red,
},
}
}
+4 -6
View File
@@ -3,6 +3,7 @@ export {
type ActionStateKey,
ActionVariant,
BaseHue,
BaseThemeDefinition,
CategoricalDefinition,
FeedbackKind,
FormfieldState,
@@ -13,27 +14,24 @@ export {
MarkdownDefinition,
MarkdownToken,
ModeDefinition,
SurfaceName,
SyntaxDefinition,
SyntaxToken,
ThemeDefinition,
ThemeDocument,
type BackgroundDefinition,
type DiffDefinition,
type FileThemeDefinition,
type FormfieldColorDefinition,
type HueDefinition,
type HueOverrideDefinition,
type MergeModeDefinition,
type Mode,
type StatefulColorDefinition,
type ContextKey,
type TextDefinition,
type ThemeTokensDefinition,
} from "./schema.js"
export type {
ActionStates,
Categorical,
ContextName,
FormfieldColor,
Hue,
HueSource,
@@ -44,7 +42,7 @@ export type {
ResolvedThemeTokens,
StatefulColor,
} from "./types.js"
export { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js"
export { DEFAULT_CATEGORICAL } from "./categorical.js"
export { expandTheme } from "./expand.js"
export { migrateV1 } from "./v1-migrate.js"
export { resolveTheme, resolveThemeDocument, themeDecodeError } from "./resolve.js"
+57 -44
View File
@@ -1,30 +1,30 @@
import { RGBA } from "@opentui/core"
import { Schema } from "effect"
import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js"
import { expandTheme, expandTokens, mergeTheme } from "./expand.js"
import { fallback } from "./fallback.js"
import { expandTheme, mergeTheme } from "./expand.js"
import {
ActionState,
ActionVariant,
BaseHue,
FeedbackKind,
HueAlias,
HueStep,
SurfaceName,
ThemeDefinition,
ThemeDocument,
} from "./schema.js"
import type {
ActionStateKey,
ContextName,
ActionStates,
HueDefinition,
HueScale,
Mode,
ResolvedActionState,
ResolvedTheme,
ResolvedThemeTokens,
StatefulColor,
StatefulColorDefinition,
ThemeTokensDefinition,
} from "./index.js"
import { selectTheme, selectThemeMode } from "./select.js"
import { selectThemeMode } from "./select.js"
const decodeThemeDefinitionSchema = Schema.decodeUnknownSync(ThemeDefinition, { reportInput: true })
@@ -42,17 +42,10 @@ export function themeDecodeError(error: unknown, name: string) {
return new Error(`Invalid theme: ${name} ${value} is an invalid value`, { cause: error })
}
export function resolveThemeDocument(document: ThemeDocument, mode?: "light" | "dark") {
export function resolveThemeDocument(document: ThemeDocument, mode?: Mode) {
const selected = selectThemeMode(document, mode)
const definition = selected.expanded ? selected.theme : expandTheme(selected.theme)
const defaults = expandTheme(selectTheme(DEFAULT_THEME, selected.mode))
const core = expandTokens(fallback(selected.mode))
const merged = document.standalone ? mergeTheme(core, definition) : mergeTheme(core, defaults, definition)
if (!merged["hue"]) throw new Error("Standalone themes must provide hues")
return resolveExpandedTheme({
...merged,
categorical: merged["categorical"] ?? DEFAULT_CATEGORICAL,
} as ThemeDefinition)
const definition = expandTheme(selected.theme)
return resolveExpandedTheme(definition)
}
export function resolveTheme(definition: ThemeDefinition): ResolvedTheme {
@@ -61,21 +54,16 @@ export function resolveTheme(definition: ThemeDefinition): ResolvedTheme {
function resolveExpandedTheme(definition: ThemeDefinition): ResolvedTheme {
const hue = resolveHue(definition.hue)
const categorical = (definition.categorical ?? DEFAULT_CATEGORICAL).map((name) => hue[name])
const categorical = definition.categorical.map((name) => hue[name])
const hueSteps = compileHueSteps(hue)
const base = tokens(definition)
const resolved = resolveView(base, hue, categorical, hueSteps)
const context = (name: ContextName) => {
const override = definition[`@context:${name}`]
if (!override) return resolved
return resolveView(contextualize(base, override), hue, categorical, hueSteps)
}
const contextual = {
elevated: context("elevated"),
overlay: context("overlay"),
}
return { ...resolved, contextual } as ResolvedTheme
const views = {} as Record<SurfaceName, ResolvedTheme>
const view = (tokens: ThemeTokensDefinition): ResolvedTheme => ({
...resolveView(tokens, hue, categorical, hueSteps),
surface: (name) => views[name],
})
views.dialog = definition["@dialog"] ? view(contextualize(base, definition["@dialog"])) : view(base)
return view(base)
}
function tokens(definition: ThemeDefinition): ThemeTokensDefinition {
@@ -92,38 +80,34 @@ function tokens(definition: ThemeDefinition): ThemeTokensDefinition {
function contextualize(base: ThemeTokensDefinition, override: ThemeTokensDefinition) {
const result = mergeTheme(base, override)
const baseText = base.text?.action
const contextText = override.text?.action
const baseBackground = base.background?.action
const contextBackground = override.background?.action
const text = result["text"] as NonNullable<ThemeTokensDefinition["text"]>
const background = result["background"] as NonNullable<ThemeTokensDefinition["background"]>
return {
...result,
text: { ...text, action: contextualActions(baseText, contextText) },
background: { ...background, action: contextualActions(baseBackground, contextBackground) },
text: { ...text, action: contextualActions(base.text?.action, override.text?.action) },
background: { ...background, action: contextualActions(base.background?.action, override.background?.action) },
} as ThemeTokensDefinition
}
function contextualActions(
base: Partial<Record<ActionVariant, StatefulColorDefinition>> | undefined,
context: Partial<Record<ActionVariant, StatefulColorDefinition>> | undefined,
surface: Partial<Record<ActionVariant, StatefulColorDefinition>> | undefined,
) {
return Object.fromEntries(
ActionVariant.literals.map((variant) => {
const baseVariant = base?.[variant]
const contextVariant = context?.[variant]
const surfaceVariant = surface?.[variant]
return [
variant,
Object.fromEntries(
(["default", ...ActionState.literals] as readonly ResolvedActionState[]).map((state) => {
const key = state === "default" ? undefined : (`$${state}` as ActionStateKey)
(["base", ...ActionState.literals] as readonly ResolvedActionState[]).map((state) => {
const key = state === "base" ? undefined : (`$${state}` as ActionStateKey)
return [
key ?? "default",
(key ? contextVariant?.[key] : undefined) ??
contextVariant?.default ??
key ?? "base",
(key ? surfaceVariant?.[key] : undefined) ??
surfaceVariant?.base ??
(key ? baseVariant?.[key] : undefined) ??
baseVariant?.default,
baseVariant?.base,
]
}),
),
@@ -139,7 +123,36 @@ function resolveView(
hueSteps: Pick<ResolvedThemeTokens, "source" | "increase" | "decrease">,
): ResolvedThemeTokens {
const source: Record<string, unknown> = { hue, ...definition }
return { ...(createResolver(source)(source, "theme") as ResolvedThemeTokens), hue, categorical, ...hueSteps }
const resolved = createResolver(source)(source, "theme") as ResolvedThemeTokens
return {
...resolved,
hue,
categorical,
text: {
...resolved.text,
action: statefulActions(resolved.text.action),
formfield: statefulColor(resolved.text.formfield),
},
background: {
...resolved.background,
action: statefulActions(resolved.background.action),
formfield: statefulColor(resolved.background.formfield),
},
...hueSteps,
}
}
function statefulActions(actions: Readonly<Record<ActionVariant, StatefulColor>>) {
return Object.fromEntries(ActionVariant.literals.map((variant) => [variant, statefulColor(actions[variant])])) as Readonly<
Record<ActionVariant, StatefulColor>
>
}
function statefulColor(color: StatefulColor): StatefulColor {
return {
...color,
state: (states: ActionStates) => color[ActionState.literals.find((state) => states[state]) ?? "base"],
}
}
function compileHueSteps(
+93 -57
View File
@@ -16,6 +16,9 @@ export const ActionState = Schema.Literals(["disabled", "pressed", "focused", "s
export type ActionState = Schema.Schema.Type<typeof ActionState>
export type ActionStateKey = `$${ActionState}`
export const SurfaceName = Schema.Literal("dialog")
export type SurfaceName = Schema.Schema.Type<typeof SurfaceName>
export const FormfieldState = ActionState
export type FormfieldState = ActionState
export type FormfieldStateKey = `$${FormfieldState}`
@@ -40,9 +43,6 @@ export const CategoricalDefinition = Schema.Array(HueName).check(Schema.isMinLen
export type CategoricalDefinition = Schema.Schema.Type<typeof CategoricalDefinition>
const HueColorValue = Schema.Union([HexColor, Schema.TemplateLiteral(["$hue.", HueName, ".", HueStep])])
const ContextKey = Schema.Literals(["@context:elevated", "@context:overlay"])
export type ContextKey = Schema.Schema.Type<typeof ContextKey>
const HueScaleDefinition = Schema.Record(HueStep, HexColor)
const HueValueDefinition = Schema.Union([Schema.TemplateLiteral(["$hue.", HueName]), HueScaleDefinition])
@@ -61,23 +61,8 @@ const HueDefinition = Schema.Struct({
})
export type HueDefinition = Schema.Schema.Type<typeof HueDefinition>
const HueOverrideDefinition = Schema.Struct({
gray: Schema.optional(HueValueDefinition),
red: Schema.optional(HueValueDefinition),
orange: Schema.optional(HueValueDefinition),
yellow: Schema.optional(HueValueDefinition),
green: Schema.optional(HueValueDefinition),
cyan: Schema.optional(HueValueDefinition),
blue: Schema.optional(HueValueDefinition),
purple: Schema.optional(HueValueDefinition),
accent: Schema.optional(HueValueDefinition),
interactive: Schema.optional(HueValueDefinition),
neutral: Schema.optional(HueValueDefinition),
})
export type HueOverrideDefinition = Schema.Schema.Type<typeof HueOverrideDefinition>
const StatefulColorDefinition = Schema.Struct({
default: Schema.optional(ColorValue),
base: Schema.optional(ColorValue),
$hovered: Schema.optional(ColorValue),
$focused: Schema.optional(ColorValue),
$pressed: Schema.optional(ColorValue),
@@ -95,27 +80,19 @@ const ActionColorDefinition = Schema.Struct({
})
const TextFeedbackDefinition = Schema.Struct({
default: Schema.optional(ColorValue),
subdued: Schema.optional(ColorValue),
base: Schema.optional(ColorValue),
muted: Schema.optional(ColorValue),
})
const BackgroundFeedbackDefinition = Schema.Struct({
default: Schema.optional(ColorValue),
base: Schema.optional(ColorValue),
})
const TextDefinition = Schema.Struct({
default: Schema.optional(ColorValue),
subdued: Schema.optional(ColorValue),
base: Schema.optional(ColorValue),
muted: Schema.optional(ColorValue),
action: Schema.optional(ActionColorDefinition),
formfield: Schema.optional(StatefulColorDefinition),
status: Schema.optional(
Schema.Struct({
running: Schema.optional(ColorValue),
question: Schema.optional(ColorValue),
permission: Schema.optional(ColorValue),
unread: Schema.optional(ColorValue),
}),
),
feedback: Schema.optional(
Schema.Struct({
error: Schema.optional(TextFeedbackDefinition),
@@ -128,7 +105,7 @@ const TextDefinition = Schema.Struct({
export type TextDefinition = Schema.Schema.Type<typeof TextDefinition>
const BackgroundDefinition = Schema.Struct({
default: Schema.optional(ColorValue),
base: Schema.optional(ColorValue),
raised: Schema.optional(
Schema.Struct({
base: Schema.optional(ColorValue),
@@ -217,52 +194,111 @@ export type DiffDefinition = Schema.Schema.Type<typeof DiffDefinition>
const ThemeTokensDefinition = Schema.Struct({
text: Schema.optional(TextDefinition),
background: Schema.optional(BackgroundDefinition),
border: Schema.optional(Schema.Struct({ default: Schema.optional(ColorValue) })),
scrollbar: Schema.optional(Schema.Struct({ default: Schema.optional(ColorValue) })),
border: Schema.optional(Schema.Struct({ base: Schema.optional(ColorValue) })),
scrollbar: Schema.optional(Schema.Struct({ base: Schema.optional(ColorValue) })),
diff: Schema.optional(DiffDefinition),
syntax: Schema.optional(SyntaxDefinition),
markdown: Schema.optional(MarkdownDefinition),
})
export type ThemeTokensDefinition = Schema.Schema.Type<typeof ThemeTokensDefinition>
const CompleteStatefulColorDefinition = Schema.Struct({
base: ColorValue,
$hovered: Schema.optional(ColorValue),
$focused: Schema.optional(ColorValue),
$pressed: Schema.optional(ColorValue),
$selected: Schema.optional(ColorValue),
$disabled: Schema.optional(ColorValue),
})
const CompleteActionColorDefinition = Schema.Struct({
primary: CompleteStatefulColorDefinition,
secondary: CompleteStatefulColorDefinition,
destructive: CompleteStatefulColorDefinition,
})
const CompleteTextFeedbackDefinition = Schema.Struct({ base: ColorValue, muted: Schema.optional(ColorValue) })
const CompleteBackgroundFeedbackDefinition = Schema.Struct({ base: ColorValue })
const CompleteThemeTokensDefinition = Schema.Struct({
text: Schema.Struct({
base: ColorValue,
muted: ColorValue,
action: CompleteActionColorDefinition,
formfield: CompleteStatefulColorDefinition,
feedback: Schema.Struct({
error: CompleteTextFeedbackDefinition,
warning: CompleteTextFeedbackDefinition,
success: CompleteTextFeedbackDefinition,
info: CompleteTextFeedbackDefinition,
}),
}),
background: Schema.Struct({
base: ColorValue,
raised: Schema.Struct({ base: ColorValue, high: ColorValue, max: ColorValue }),
action: CompleteActionColorDefinition,
formfield: CompleteStatefulColorDefinition,
feedback: Schema.Struct({
error: CompleteBackgroundFeedbackDefinition,
warning: CompleteBackgroundFeedbackDefinition,
success: CompleteBackgroundFeedbackDefinition,
info: CompleteBackgroundFeedbackDefinition,
}),
}),
border: Schema.Struct({ base: ColorValue }),
scrollbar: Schema.Struct({ base: ColorValue }),
diff: Schema.Struct({
text: Schema.Struct({ added: ColorValue, removed: ColorValue, context: ColorValue, hunkHeader: ColorValue }),
background: Schema.Struct({ added: ColorValue, removed: ColorValue, context: ColorValue }),
highlight: Schema.Struct({ added: ColorValue, removed: ColorValue }),
lineNumber: Schema.Struct({
text: ColorValue,
background: Schema.Struct({ added: ColorValue, removed: ColorValue }),
}),
}),
syntax: Schema.Record(SyntaxToken, HueColorValue),
markdown: Schema.Record(MarkdownToken, HueColorValue),
})
const ThemeDefinitionFields = Schema.Struct({
hue: HueDefinition,
categorical: Schema.optional(CategoricalDefinition),
...ThemeTokensDefinition.fields,
"@context:elevated": Schema.optional(ThemeTokensDefinition),
"@context:overlay": Schema.optional(ThemeTokensDefinition),
categorical: CategoricalDefinition,
...CompleteThemeTokensDefinition.fields,
"@dialog": Schema.optional(ThemeTokensDefinition),
})
export const ThemeDefinition = ThemeDefinitionFields
export type ThemeDefinition = Schema.Schema.Type<typeof ThemeDefinition>
const FileThemeDefinition = Schema.Struct({
hue: Schema.optional(HueOverrideDefinition),
categorical: Schema.optional(CategoricalDefinition),
...ThemeTokensDefinition.fields,
"@context:elevated": Schema.optional(ThemeTokensDefinition),
"@context:overlay": Schema.optional(ThemeTokensDefinition),
export const BaseThemeDefinition = Schema.Struct({
categorical: CategoricalDefinition,
...CompleteThemeTokensDefinition.fields,
"@dialog": Schema.optional(ThemeTokensDefinition),
})
export type FileThemeDefinition = Schema.Schema.Type<typeof FileThemeDefinition>
export type BaseThemeDefinition = Schema.Schema.Type<typeof BaseThemeDefinition>
const MergeModeDefinition = Schema.Struct({
mergeMode: Schema.Literal(true),
hue: Schema.optional(HueOverrideDefinition),
export const ModeDefinition = Schema.Struct({
hue: HueDefinition,
categorical: Schema.optional(CategoricalDefinition),
...ThemeTokensDefinition.fields,
"@context:elevated": Schema.optional(ThemeTokensDefinition),
"@context:overlay": Schema.optional(ThemeTokensDefinition),
"@dialog": Schema.optional(ThemeTokensDefinition),
})
export type MergeModeDefinition = Schema.Schema.Type<typeof MergeModeDefinition>
export const ModeDefinition = Schema.Union([MergeModeDefinition, FileThemeDefinition])
export type ModeDefinition = Schema.Schema.Type<typeof ModeDefinition>
const FileMetadata = {
$schema: Schema.optional(Schema.String),
version: Schema.Literal(2),
standalone: Schema.optional(Schema.Boolean),
}
export const ThemeDocument = Schema.Union([
Schema.Struct({ ...FileMetadata, light: ModeDefinition, dark: Schema.optional(ModeDefinition) }),
Schema.Struct({ ...FileMetadata, light: Schema.optional(ModeDefinition), dark: ModeDefinition }),
Schema.Struct({
...FileMetadata,
base: BaseThemeDefinition,
light: ModeDefinition,
dark: Schema.optional(ModeDefinition),
}),
Schema.Struct({
...FileMetadata,
base: BaseThemeDefinition,
light: Schema.optional(ModeDefinition),
dark: ModeDefinition,
}),
])
export type ThemeDocument = Schema.Schema.Type<typeof ThemeDocument>
+5 -31
View File
@@ -1,51 +1,25 @@
import { expandTheme, mergeTheme } from "./expand.js"
import type {
FileThemeDefinition,
MergeModeDefinition,
Mode,
ModeDefinition,
ThemeDefinition,
ThemeDocument,
} from "./index.js"
import { mergeTheme } from "./expand.js"
import type { Mode, ThemeDefinition, ThemeDocument } from "./index.js"
export function selectTheme(
document: ThemeDocument & { light: ThemeDefinition; dark: ThemeDefinition },
mode?: Mode,
): ThemeDefinition
export function selectTheme(document: ThemeDocument, mode?: Mode): FileThemeDefinition
export function selectTheme(document: ThemeDocument, mode?: Mode) {
export function selectTheme(document: ThemeDocument, mode?: Mode): ThemeDefinition {
return selectThemeMode(document, mode).theme
}
export function selectThemeMode(
document: ThemeDocument,
mode: Mode = "light",
): { theme: FileThemeDefinition; mode: Mode; expanded: boolean } {
): { theme: ThemeDefinition; mode: Mode } {
const modes = themeModes(document)
const selectedMode = modes.includes(mode) ? mode : modes[0]
const selected = document[selectedMode]
if (!selected) throw new Error("Theme must provide at least one mode")
if (merges(document.light) && merges(document.dark)) throw new Error("Light and dark themes cannot both merge modes")
if (!merges(selected)) return { theme: selected, mode: selectedMode, expanded: false }
const otherMode = selectedMode === "light" ? "dark" : "light"
const other = document[otherMode]
if (!other) throw new Error(`The ${selectedMode} theme cannot merge without a ${otherMode} theme`)
const merged = mergeTheme(expandTheme(other), expandTheme(selected))
if (!merged["hue"]) throw new Error(`The ${otherMode} theme must provide hues when ${selectedMode} merges modes`)
return { theme: merged as FileThemeDefinition, mode: selectedMode, expanded: true }
return { theme: mergeTheme(document.base, selected) as ThemeDefinition, mode: selectedMode }
}
export function themeModes(document: ThemeDocument): readonly Mode[] {
if (merges(document.light) && !document.dark) throw new Error("The light theme cannot merge without a dark theme")
if (merges(document.dark) && !document.light) throw new Error("The dark theme cannot merge without a light theme")
return (["light", "dark"] as const).filter((mode) => document[mode] !== undefined)
}
export function supportsThemeMode(document: ThemeDocument, mode: Mode) {
return themeModes(document).includes(mode)
}
function merges(definition: ModeDefinition | undefined): definition is MergeModeDefinition {
return definition !== undefined && "mergeMode" in definition && definition.mergeMode === true
}
+20 -20
View File
@@ -1,21 +1,21 @@
import { SyntaxStyle, type RGBA, type ThemeTokenStyle } from "@opentui/core"
import type { Mode, ResolvedThemeTokens } from "./index.js"
import type { ResolvedThemeTokens } from "./index.js"
export function generateSyntax(theme: ResolvedThemeTokens, mode: Mode) {
const step = mode === "light" ? 800 : 200
export function generateSyntax(theme: ResolvedThemeTokens) {
const step = 200
const syntax = theme.syntax
const markdown = theme.markdown
const feedback = theme.text.feedback
return SyntaxStyle.fromTheme([
rule(["default"], theme.text.default),
rule(["default"], theme.text.base),
rule(["prompt"], theme.hue.accent[step]),
rule(["extmark.file"], feedback.warning.default, { bold: true }),
rule(["extmark.file"], feedback.warning.base, { bold: true }),
rule(["extmark.agent"], theme.categorical[0][step], { bold: true }),
rule(["extmark.skill"], (theme.categorical[1] ?? theme.categorical[0])[step], { bold: true }),
// V1 migration preserves its selected/inverse foreground in this action state.
rule(["extmark.paste"], theme.text.action.primary.focused, {
background: feedback.warning.default,
background: feedback.warning.base,
bold: true,
}),
rule(["comment", "comment.documentation"], syntax.comment, { italic: true }),
@@ -39,7 +39,7 @@ export function generateSyntax(theme: ResolvedThemeTokens, mode: Mode) {
rule(["punctuation", "punctuation.bracket"], syntax.punctuation),
rule(
["variable.builtin", "type.builtin", "function.builtin", "module.builtin", "constant.builtin", "variable.super"],
feedback.error.default,
feedback.error.base,
),
rule(["keyword.directive", "keyword.modifier", "keyword.exception"], syntax.keyword, { italic: true }),
rule(["punctuation.special", "tag.delimiter"], syntax.operator),
@@ -61,27 +61,27 @@ export function generateSyntax(theme: ResolvedThemeTokens, mode: Mode) {
rule(["markup.list"], markdown.listItem),
rule(["markup.quote"], markdown.blockQuote, { italic: true }),
rule(["markup.raw", "markup.raw.block"], markdown.code),
rule(["markup.raw.inline"], markdown.code, { background: theme.background.default }),
rule(["markup.raw.inline"], markdown.code, { background: theme.background.base }),
rule(["markup.link", "markup.link.url", "string.special", "string.special.url"], markdown.link, {
underline: true,
}),
rule(["markup.link.label"], markdown.linkText, { underline: true }),
rule(["label"], markdown.linkText),
rule(["spell", "nospell"], theme.text.default),
rule(["markup.underline"], theme.text.default, { underline: true }),
rule(["comment.error"], feedback.error.default, { italic: true, bold: true }),
rule(["comment.warning"], feedback.warning.default, { italic: true, bold: true }),
rule(["comment.todo", "comment.note"], feedback.info.default, { italic: true, bold: true }),
rule(["attribute", "annotation"], feedback.warning.default),
rule(["tag"], feedback.error.default),
rule(["markup.strikethrough", "markup.list.unchecked", "debug"], theme.text.subdued),
rule(["markup.list.checked"], feedback.success.default),
rule(["spell", "nospell"], theme.text.base),
rule(["markup.underline"], theme.text.base, { underline: true }),
rule(["comment.error"], feedback.error.base, { italic: true, bold: true }),
rule(["comment.warning"], feedback.warning.base, { italic: true, bold: true }),
rule(["comment.todo", "comment.note"], feedback.info.base, { italic: true, bold: true }),
rule(["attribute", "annotation"], feedback.warning.base),
rule(["tag"], feedback.error.base),
rule(["markup.strikethrough", "markup.list.unchecked", "debug"], theme.text.muted),
rule(["markup.list.checked"], feedback.success.base),
rule(["diff.plus"], theme.diff.text.added, { background: theme.diff.background.added }),
rule(["diff.minus"], theme.diff.text.removed, { background: theme.diff.background.removed }),
rule(["diff.delta"], theme.diff.text.context, { background: theme.diff.background.context }),
rule(["error"], feedback.error.default, { bold: true }),
rule(["warning"], feedback.warning.default, { bold: true }),
rule(["info"], feedback.info.default),
rule(["error"], feedback.error.base, { bold: true }),
rule(["warning"], feedback.warning.base, { bold: true }),
rule(["info"], feedback.info.base),
])
}
+15 -18
View File
@@ -7,16 +7,20 @@ import type {
HueAlias,
HueStep,
MarkdownToken,
SurfaceName,
SyntaxToken,
} from "./schema.js"
export type ResolvedActionState = "default" | ActionState
export type ResolvedActionState = "base" | ActionState
export type ResolvedFormfieldState = ResolvedActionState
export type HueScale = Readonly<Record<HueStep, RGBA>>
export type Hue = Readonly<Record<BaseHue | HueAlias, HueScale>>
export type HueSource = Readonly<{ hue: BaseHue | HueAlias; step: HueStep }>
export type Categorical = readonly HueScale[]
export type StatefulColor = Readonly<Record<ResolvedActionState, RGBA>>
export type ActionStates = Readonly<Partial<Record<ActionState, boolean>>>
export type StatefulColor = Readonly<Record<ResolvedActionState, RGBA>> & {
readonly state: (states: ActionStates) => RGBA
}
export type FormfieldColor = StatefulColor
export type ResolvedThemeTokens = {
@@ -26,20 +30,14 @@ export type ResolvedThemeTokens = {
readonly increase: (color: RGBA, amount?: number) => RGBA
readonly decrease: (color: RGBA, amount?: number) => RGBA
readonly text: {
readonly default: RGBA
readonly subdued: RGBA
readonly base: RGBA
readonly muted: RGBA
readonly action: Readonly<Record<ActionVariant, StatefulColor>>
readonly formfield: FormfieldColor
readonly status: {
readonly running: RGBA
readonly question: RGBA
readonly permission: RGBA
readonly unread: RGBA
}
readonly feedback: Readonly<Record<FeedbackKind, { readonly default: RGBA; readonly subdued: RGBA }>>
readonly feedback: Readonly<Record<FeedbackKind, { readonly base: RGBA; readonly muted: RGBA }>>
}
readonly background: {
readonly default: RGBA
readonly base: RGBA
readonly raised: {
readonly base: RGBA
readonly high: RGBA
@@ -47,10 +45,10 @@ export type ResolvedThemeTokens = {
}
readonly action: Readonly<Record<ActionVariant, StatefulColor>>
readonly formfield: FormfieldColor
readonly feedback: Readonly<Record<FeedbackKind, { readonly default: RGBA }>>
readonly feedback: Readonly<Record<FeedbackKind, { readonly base: RGBA }>>
}
readonly border: { readonly default: RGBA }
readonly scrollbar: { readonly default: RGBA }
readonly border: { readonly base: RGBA }
readonly scrollbar: { readonly base: RGBA }
readonly diff: {
readonly text: {
readonly added: RGBA
@@ -69,8 +67,7 @@ export type ResolvedThemeTokens = {
readonly markdown: Readonly<Record<MarkdownToken, RGBA>>
}
export type ContextName = "elevated" | "overlay"
export type ResolvedTheme = ResolvedThemeTokens & {
readonly contextual: Readonly<Record<ContextName, ResolvedThemeTokens>>
/** The same theme re-resolved on a raised surface. Absolute: every view's surfaces are the base theme's. */
readonly surface: (name: SurfaceName) => ResolvedTheme
}
+89 -60
View File
@@ -1,7 +1,7 @@
import { RGBA } from "@opentui/core"
import { oklchToHex, rgbToOklch } from "./color.js"
import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js"
import type { FileThemeDefinition, Mode, ThemeDocument } from "./index.js"
import { DEFAULT_CATEGORICAL } from "./categorical.js"
import type { BaseThemeDefinition, HueDefinition, Mode, ThemeDefinition, ThemeDocument } from "./index.js"
import { HueStep } from "./schema.js"
import type { Theme, ThemeV1Json } from "./v1.js"
@@ -13,6 +13,34 @@ const chromaticHues: readonly ChromaticHue[] = ["red", "orange", "yellow", "gree
const categoricalTokens: readonly V1HueToken[] = ["secondary", "accent", "success", "warning", "primary", "error"]
const minimumChroma = 0.03
const lightThreshold = 0.6
// Canonical swatches copied from the original default-theme classifier keep V1 migration self-contained.
const hueReferences = {
light: {
red: "#fca5a5",
orange: "#fdba74",
yellow: "#fde047",
green: "#86efac",
cyan: "#67e8f9",
blue: "#93c5fd",
purple: "#d8b4fe",
},
dark: {
red: "#b91c1c",
orange: "#c2410c",
yellow: "#a16207",
green: "#15803d",
cyan: "#0e7490",
blue: "#1d4ed8",
purple: "#7e22ce",
},
} satisfies Record<"light" | "dark", Record<ChromaticHue, string>>
const hueAngles = Object.fromEntries(
Object.entries(hueReferences).map(([level, colors]) => [
level,
Object.fromEntries(Object.entries(colors).map(([name, color]) => [name, toOklch(RGBA.fromHex(color)).h])),
]),
) as Record<"light" | "dark", Record<ChromaticHue, number>>
export function migrateV1(theme: ThemeV1Json): ThemeDocument {
const light = resolveV1(theme, "light")
@@ -21,18 +49,25 @@ export function migrateV1(theme: ThemeV1Json): ThemeDocument {
const lightMode = detectMode(light)
const darkMode = detectMode(dark)
if (lightMode === darkMode) {
if (lightMode === "light") return { version: 2, standalone: true, light: migrateMode(light, "light") }
return { version: 2, standalone: true, dark: migrateMode(dark, "dark") }
const definition = migrateMode(lightMode === "light" ? light : dark, lightMode)
if (lightMode === "light") return { base: base(definition), light: { hue: definition.hue } }
return { base: base(definition), dark: { hue: definition.hue } }
}
}
const lightDefinition = migrateMode(light, "light")
const darkDefinition = migrateMode(dark, "dark")
return {
version: 2,
standalone: true,
light: migrateMode(light, "light"),
dark: migrateMode(dark, "dark"),
base: base(lightDefinition),
light: { hue: lightDefinition.hue },
dark: darkDefinition,
}
}
function base(definition: ThemeDefinition): BaseThemeDefinition {
const { hue: _, ...base } = definition
return base
}
function detectMode(theme: Theme): Mode {
return luminance(theme.text) > luminance(theme.background) ? "dark" : "light"
}
@@ -41,27 +76,27 @@ function luminance(color: RGBA) {
return 0.299 * color.r + 0.587 * color.g + 0.114 * color.b
}
function migrateMode(theme: Theme, mode: Mode): FileThemeDefinition {
function migrateMode(theme: Theme, mode: Mode): ThemeDefinition {
const color = (key: ThemeColor) => hex(theme[key])
const selected = hex(selectedForeground(theme, theme.primary))
const destructive = hex(selectedForeground(theme, theme.error))
const hues = inferHues(theme, mode)
const hues = inferHues(theme)
const categorical = categoricalTokens.flatMap((token) => {
const hue = hues.byToken[token]
return hue ? [hue] : []
})
const uniqueCategorical = categorical.filter((hue, index) => categorical.indexOf(hue) === index)
const text = mode === "light" ? "$hue.neutral.800" : "$hue.neutral.200"
const textMuted = mode === "light" ? "$hue.neutral.600" : "$hue.neutral.400"
const primary = mode === "light" ? "$hue.interactive.800" : "$hue.interactive.200"
const background = mode === "light" ? "$hue.neutral.200" : "$hue.neutral.800"
const backgroundPanel = mode === "light" ? "$hue.neutral.300" : "$hue.neutral.700"
const backgroundMenu = mode === "light" ? "$hue.neutral.400" : "$hue.neutral.600"
const text = "$hue.neutral.200"
const textMuted = "$hue.neutral.400"
const primary = "$hue.interactive.200"
const background = "$hue.neutral.800"
const backgroundPanel = "$hue.neutral.700"
const backgroundMenu = "$hue.neutral.600"
const backgroundRaisedMax = "$hue.neutral.500"
return referenceHues({
hue: {
gray: neutralScale(theme, mode),
gray: neutralScale(theme),
...Object.fromEntries(
chromaticHues.map((name) => {
const match = hues.byHue[name]
@@ -71,23 +106,23 @@ function migrateMode(theme: Theme, mode: Mode): FileThemeDefinition {
accent: hues.byToken.accent ? `$hue.${hues.byToken.accent}` : "$hue.gray",
interactive: hues.byToken.primary ? `$hue.${hues.byToken.primary}` : "$hue.gray",
neutral: "$hue.gray",
},
} as HueDefinition,
categorical: uniqueCategorical.length ? uniqueCategorical : DEFAULT_CATEGORICAL,
text: {
default: text,
subdued: textMuted,
base: text,
muted: textMuted,
action: {
primary: {
default: "$text.default",
base: "$text.base",
$disabled: textMuted,
$focused: selected,
$selected: primary,
},
secondary: { default: "$text.subdued", $hovered: "$text.default" },
destructive: { default: destructive, $disabled: textMuted },
secondary: { base: "$text.muted", $hovered: "$text.base" },
destructive: { base: destructive, $disabled: textMuted },
},
formfield: {
default: text,
base: text,
$hovered: primary,
$focused: primary,
$pressed: primary,
@@ -95,36 +130,36 @@ function migrateMode(theme: Theme, mode: Mode): FileThemeDefinition {
$selected: primary,
},
feedback: {
error: { default: color("error") },
warning: { default: color("warning") },
success: { default: color("success") },
info: { default: color("info") },
error: { base: color("error") },
warning: { base: color("warning") },
success: { base: color("success") },
info: { base: color("info") },
},
},
background: {
default: background,
base: background,
raised: {
base: backgroundPanel,
high: backgroundMenu,
max: backgroundRaisedMax,
},
action: {
primary: { default: "transparent", $hovered: backgroundPanel, $focused: primary, $selected: "transparent" },
secondary: { default: "transparent" },
destructive: { default: color("error") },
primary: { base: "transparent", $hovered: backgroundPanel, $focused: primary, $selected: "transparent" },
secondary: { base: "transparent" },
destructive: { base: color("error") },
},
formfield: {
default: "$background.default",
base: "$background.base",
},
feedback: {
error: { default: "$background.default" },
warning: { default: "$background.default" },
success: { default: "$background.default" },
info: { default: "$background.default" },
error: { base: "$background.base" },
warning: { base: "$background.base" },
success: { base: "$background.base" },
info: { base: "$background.base" },
},
},
border: { default: color("border") },
scrollbar: { default: color("borderActive") },
border: { base: color("border") },
scrollbar: { base: color("borderActive") },
diff: {
text: {
added: color("diffAdded"),
@@ -173,17 +208,16 @@ function migrateMode(theme: Theme, mode: Mode): FileThemeDefinition {
imageText: color("markdownImageText"),
codeBlock: color("markdownCodeBlock"),
},
"@context:elevated": {
"@dialog": {
background: {
default: "$background.raised.base",
base: "$background.raised.base",
action: { primary: { $hovered: "$background.raised.high" } },
},
},
"@context:overlay": { background: { default: "$background.raised.high" } },
})
}
function referenceHues(theme: FileThemeDefinition): FileThemeDefinition {
function referenceHues(theme: ThemeDefinition): ThemeDefinition {
const definitions = theme.hue as Record<string, string | Partial<Record<HueStep, string>>> | undefined
if (!definitions) return theme
const scales = new Map<string, Partial<Record<HueStep, string>>>()
@@ -229,10 +263,10 @@ function referenceHues(theme: FileThemeDefinition): FileThemeDefinition {
return Object.fromEntries(
Object.entries(theme).map(([key, value]) => [key, key === "hue" || key === "categorical" ? value : replace(value)]),
) as FileThemeDefinition
) as ThemeDefinition
}
function inferHues(theme: Theme, mode: "light" | "dark") {
function inferHues(theme: Theme) {
const colors: readonly [V1HueToken, RGBA][] = [
["accent", theme.accent],
["success", theme.success],
@@ -247,7 +281,7 @@ function inferHues(theme: Theme, mode: "light" | "dark") {
byToken: Partial<Record<V1HueToken, ChromaticHue>>
}>(
(result, [token, color]) => {
const nearest = inferHue(color, mode)
const nearest = inferHue(color)
if (!nearest) return result
const current = result.byHue[nearest.name]
return {
@@ -266,7 +300,7 @@ function inferHues(theme: Theme, mode: "light" | "dark") {
["primary", theme.primary],
] as const
).reduce((result, [token, color]) => {
const nearest = inferHue(color, mode)
const nearest = inferHue(color)
if (!nearest) return result
return {
byHue: { ...result.byHue, [nearest.name]: { color, distance: nearest.distance } },
@@ -275,22 +309,18 @@ function inferHues(theme: Theme, mode: "light" | "dark") {
}, inferred)
}
function inferHue(color: RGBA, mode: Mode) {
function inferHue(color: RGBA) {
const value = toOklch(color)
if (ambiguous(color, value.c)) return
const anchor = inferenceAnchor(value.l)
const reference = value.l >= lightThreshold ? hueAngles.light : hueAngles.dark
return chromaticHues
.map((name) => ({
name,
distance: hueDistance(value.h, toOklch(RGBA.fromHex(DEFAULT_THEME[mode].hue[name][anchor])).h),
distance: hueDistance(value.h, reference[name]),
}))
.sort((first, second) => first.distance - second.distance)[0]
}
function inferenceAnchor(lightness: number): HueStep {
return lightness >= lightThreshold ? 300 : 700
}
function hueDistance(first: number, second: number) {
const difference = Math.abs(first - second)
return Math.min(difference, 360 - difference)
@@ -348,13 +378,13 @@ function selectedForeground(theme: Theme, background: RGBA) {
function hueScale(color: RGBA, mode: "light" | "dark") {
const value = toOklch(color)
const anchor = mode === "light" ? 800 : 200
const anchor = 200
const endpoint = mode === "light" ? Math.max(0.97, value.l) : Math.min(0.18, value.l)
const alpha = color.toInts()[3]
return Object.fromEntries(
HueStep.literals.map((step) => {
if (step === anchor) return [step, hex(color)]
const progress = mode === "light" ? (anchor - step) / (anchor - 100) : (step - anchor) / (900 - anchor)
const progress = (step - anchor) / (900 - anchor)
const generated = oklchToHex({
l: value.l + (endpoint - value.l) * progress,
c: value.c * (1 - progress * 0.5),
@@ -365,8 +395,8 @@ function hueScale(color: RGBA, mode: "light" | "dark") {
) as Record<HueStep, string>
}
function neutralScale(theme: Theme, mode: "light" | "dark") {
const anchors = neutralAnchors(theme, mode)
function neutralScale(theme: Theme) {
const anchors = neutralAnchors(theme)
return Object.fromEntries(
HueStep.literals.map((step) => {
const exact = anchors.find((anchor) => anchor.step === step)
@@ -384,7 +414,7 @@ function neutralScale(theme: Theme, mode: "light" | "dark") {
) as Record<HueStep, string>
}
function neutralAnchors(theme: Theme, mode: "light" | "dark") {
function neutralAnchors(theme: Theme) {
const light: { step: HueStep; color: RGBA }[] = [
{ step: 200, color: theme.background },
{ step: 300, color: theme.backgroundPanel },
@@ -392,7 +422,6 @@ function neutralAnchors(theme: Theme, mode: "light" | "dark") {
{ step: 600, color: theme.textMuted },
{ step: 800, color: theme.text },
]
if (mode === "light") return light
return light.toReversed().map((source) => ({ ...source, step: (1000 - source.step) as HueStep }))
}
-61
View File
@@ -1,61 +0,0 @@
import { expect, test } from "bun:test"
import { RGBA } from "@opentui/core"
import { Schema } from "effect"
import { DEFAULT_THEME, ThemeDocument, migrateV1, resolveThemeDocument } from "../src/tui/index.js"
import type { ThemeV1Json } from "../src/tui/v1.js"
test.each(["light", "dark"] as const)("built-in %s themes resolve status colors", async (mode) => {
const source: ThemeV1Json = await Bun.file(
new URL("../../tui/src/theme/assets/opencode.json", import.meta.url),
).json()
for (const document of [DEFAULT_THEME, migrateV1(source)]) {
const theme = resolveThemeDocument(document, mode)
expect(theme.text.status.running.equals(theme.hue.interactive[mode === "light" ? 800 : 200])).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.text.status.unread.equals(theme.hue.accent[mode === "light" ? 800 : 200])).toBeTrue()
expect(theme.contextual.elevated.text.status).toEqual(theme.text.status)
}
})
test.each(["light", "dark"] as const)("custom %s themes inherit the unread attention color", (mode) => {
for (const standalone of [false, true]) {
const theme = resolveThemeDocument(
Schema.decodeUnknownSync(ThemeDocument)({
version: 2,
standalone,
[mode]: {
hue: { ...DEFAULT_THEME[mode].hue, accent: "$hue.purple" },
text: { status: { unread: "#abcdef" } },
},
}),
mode,
)
expect(theme.text.status.unread.equals(RGBA.fromHex("#abcdef"))).toBeTrue()
expect(theme.text.status.question.equals(theme.text.status.unread)).toBeTrue()
expect(theme.text.status.permission.equals(theme.text.status.unread)).toBeTrue()
expect(theme.contextual.elevated.text.status).toEqual(theme.text.status)
}
})
test.each(["light", "dark"] as const)("custom %s themes inherit and override status colors", (mode) => {
for (const standalone of [false, true]) {
const theme = resolveThemeDocument(
Schema.decodeUnknownSync(ThemeDocument)({
version: 2,
standalone,
[mode]: {
hue: { ...DEFAULT_THEME[mode].hue, interactive: "$hue.purple", accent: "$hue.orange" },
text: {
status: { question: "#123456", permission: "#654321" },
},
},
}),
mode,
)
expect(theme.text.status.running.equals(theme.hue.purple[mode === "light" ? 800 : 200])).toBeTrue()
expect(theme.text.status.unread.equals(theme.hue.orange[mode === "light" ? 800 : 200])).toBeTrue()
expect(theme.text.status.question.equals(RGBA.fromHex("#123456"))).toBeTrue()
expect(theme.text.status.permission.equals(RGBA.fromHex("#654321"))).toBeTrue()
}
})
+15
View File
@@ -0,0 +1,15 @@
import { expect, test } from "bun:test"
import { migrateV1, resolveThemeDocument } from "../src/tui/index.js"
import type { ThemeV1Json } from "../src/tui/v1.js"
const source: ThemeV1Json = await Bun.file(
new URL("../../tui/src/theme/assets/opencode.json", import.meta.url),
).json()
const document = migrateV1(source)
test.each(["light", "dark"] as const)("resolves %s themes without status tokens", (mode) => {
const theme = resolveThemeDocument(document, mode)
expect("status" in theme.text).toBeFalse()
expect(theme.hue.accent[800]).toBeDefined()
expect(theme.hue.interactive[800]).toBeDefined()
})
+1 -1
View File
@@ -1311,7 +1311,7 @@ function App(props: { pair?: DialogPairCredentials }) {
width={dimensions().width}
height={dimensions().height}
flexDirection="column"
backgroundColor={theme.background.default}
backgroundColor={theme.background.base}
onMouseDown={(evt) => {
if (copyOnSelectEnabled()) return
if (evt.button !== MouseButton.RIGHT) return
+34 -34
View File
@@ -39,7 +39,7 @@ export function DevToolsBar() {
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const { current: theme, mode, supports, setMode } = themes
const elevatedTheme = useTheme("elevated")
const elevatedTheme = useTheme()
const [panel, setPanel] = createSignal<Panel>()
const [dumping, setDumping] = createSignal(false)
const [dumpPath, setDumpPath] = createSignal<string>()
@@ -229,7 +229,7 @@ export function DevToolsBar() {
}
return (
<box height={1} flexShrink={0} flexDirection="row" backgroundColor={theme.raise(theme.background.default)}>
<box height={1} flexShrink={0} flexDirection="row" backgroundColor={theme.decrease(theme.background.base)}>
<Show when={panel()}>
<box
position="absolute"
@@ -248,10 +248,10 @@ export function DevToolsBar() {
panel() === "server"
? theme.text.action.primary.focused
: serverIndicator().state === "connected"
? theme.text.feedback.success.default
? theme.text.feedback.success.base
: serverIndicator().state === "disconnected"
? theme.text.feedback.error.default
: theme.text.default
? theme.text.feedback.error.base
: theme.text.base
}
>
{serverIndicator().icon}
@@ -261,8 +261,8 @@ export function DevToolsBar() {
panel() === "server"
? theme.text.action.primary.focused
: serverIndicator().state === "disconnected"
? theme.text.feedback.error.default
: theme.text.subdued
? theme.text.feedback.error.base
: theme.text.muted
}
>
{" "}
@@ -286,7 +286,7 @@ export function DevToolsBar() {
)}
</Show>
<Show when={server.error}>
<text fg={elevatedTheme.text.feedback.error.default}>Server details unavailable</text>
<text fg={elevatedTheme.text.feedback.error.base}>Server details unavailable</text>
</Show>
</PanelBox>
</Show>
@@ -297,8 +297,8 @@ export function DevToolsBar() {
panel() === "ui"
? theme.text.action.primary.focused
: runtime() === "high"
? theme.text.feedback.error.default
: theme.text.subdued
? theme.text.feedback.error.base
: theme.text.muted
}
>
{statusIcon(runtime())}
@@ -308,8 +308,8 @@ export function DevToolsBar() {
panel() === "ui"
? theme.text.action.primary.focused
: runtime() === "high"
? theme.text.feedback.error.default
: theme.text.subdued
? theme.text.feedback.error.base
: theme.text.muted
}
>
{" "}
@@ -339,7 +339,7 @@ export function DevToolsBar() {
</Show>
</BarItem>
<BarItem active={panel() === "theme"} onClick={() => toggle("theme")}>
<text fg={panel() === "theme" ? theme.text.action.primary.focused : theme.text.subdued}>Theme</text>
<text fg={panel() === "theme" ? theme.text.action.primary.focused : theme.text.muted}>Theme</text>
<Show when={panel() === "theme"}>
<PanelBox>
<PanelTitle>Theme</PanelTitle>
@@ -355,7 +355,7 @@ export function DevToolsBar() {
</Show>
</BarItem>
<BarItem active={panel() === "tools"} onClick={() => toggle("tools")}>
<text fg={panel() === "tools" ? theme.text.action.primary.focused : theme.text.subdued}>Tools</text>
<text fg={panel() === "tools" ? theme.text.action.primary.focused : theme.text.muted}>Tools</text>
<Show when={panel() === "tools"}>
<PanelBox>
<PanelTitle>Tools</PanelTitle>
@@ -364,20 +364,20 @@ export function DevToolsBar() {
</Action>
<Show when={dumpPath()}>
{(file) => (
<text fg={elevatedTheme.text.subdued} wrapMode="word">
<text fg={elevatedTheme.text.muted} wrapMode="word">
{file()}
</text>
)}
</Show>
<Show when={dumpError()}>
{(error) => (
<text fg={elevatedTheme.text.feedback.error.default} wrapMode="word">
<text fg={elevatedTheme.text.feedback.error.base} wrapMode="word">
{error()}
</text>
)}
</Show>
<box marginTop={1}>
<text fg={elevatedTheme.text.default} attributes={TextAttributes.BOLD}>
<text fg={elevatedTheme.text.base} attributes={TextAttributes.BOLD}>
Render
</text>
<Action
@@ -416,7 +416,7 @@ export function DevToolsBar() {
<For each={groups()}>
{(group) => (
<box marginTop={1}>
<text fg={elevatedTheme.text.default} attributes={TextAttributes.BOLD}>
<text fg={elevatedTheme.text.base} attributes={TextAttributes.BOLD}>
{group.title}
</text>
<For each={group.entries}>{(entry) => <Row label={entry.key} value={String(entry.value)} />}</For>
@@ -433,10 +433,10 @@ export function DevToolsBar() {
dialog.replace(() => <DialogExperiments />)
}}
>
<text fg={theme.text.subdued}>Experiments</text>
<text fg={theme.text.muted}>Experiments</text>
</BarItem>
<box flexGrow={1} minWidth={0}>
<TimeToFirstDraw visible={timing()} width="100%" fg={theme.text.subdued} label="Time to first draw" />
<TimeToFirstDraw visible={timing()} width="100%" fg={theme.text.muted} label="Time to first draw" />
</box>
</box>
)
@@ -474,7 +474,7 @@ function BarItem(props: ParentProps<{ active: boolean; onClick: () => void }>) {
}
function PanelBox(props: ParentProps) {
const theme = useTheme("elevated")
const theme = useTheme()
const renderer = useRenderer()
return (
<box
@@ -487,7 +487,7 @@ function PanelBox(props: ParentProps) {
paddingRight={2}
paddingTop={1}
paddingBottom={1}
backgroundColor={theme.background.default}
backgroundColor={theme.background.raised.base}
flexDirection="column"
onMouseUp={(event) => {
if (renderer.getSelection()?.getSelectedText()) return
@@ -500,32 +500,32 @@ function PanelBox(props: ParentProps) {
}
function PanelTitle(props: ParentProps) {
const theme = useTheme("elevated")
const theme = useTheme()
return (
<text fg={theme.text.default} attributes={TextAttributes.BOLD} marginBottom={1}>
<text fg={theme.text.base} attributes={TextAttributes.BOLD} marginBottom={1}>
{props.children}
</text>
)
}
function Row(props: { label: string; value: string }) {
const theme = useTheme("elevated")
const theme = useTheme()
return (
<box flexDirection="row">
<text fg={theme.text.subdued}>{props.label}</text>
<text fg={theme.text.muted}>{props.label}</text>
<box flexGrow={1} />
<text fg={theme.text.default}>{props.value}</text>
<text fg={theme.text.base}>{props.value}</text>
</box>
)
}
function Action(props: ParentProps<{ onClick: () => void; disabled?: boolean; hoverBackground?: boolean }>) {
const theme = useTheme("elevated")
const theme = useTheme()
const [hovered, setHovered] = createSignal(false)
return (
<box
backgroundColor={
props.hoverBackground && hovered() && !props.disabled ? theme.background.action.primary.hovered : undefined
props.hoverBackground && hovered() && !props.disabled ? theme.background.raised.high : undefined
}
onMouseOver={() => setHovered(true)}
onMouseOut={() => setHovered(false)}
@@ -534,7 +534,7 @@ function Action(props: ParentProps<{ onClick: () => void; disabled?: boolean; ho
if (!props.disabled) props.onClick()
}}
>
<text fg={props.disabled ? theme.text.subdued : theme.text.action.primary.default}>{props.children}</text>
<text fg={props.disabled ? theme.text.muted : theme.text.action.primary.base}>{props.children}</text>
</box>
)
}
@@ -545,7 +545,7 @@ function cpuPercent(microseconds: number, milliseconds: number) {
}
function ProcessStat(props: { label: string; values: readonly number[]; unit: string; decimals?: number }) {
const theme = useTheme("elevated")
const theme = useTheme()
const value = () => {
const value = props.values.at(-1)
if (value === undefined) return "--"
@@ -554,13 +554,13 @@ function ProcessStat(props: { label: string; values: readonly number[]; unit: st
return (
<box flexDirection="row">
<box width={7}>
<text fg={theme.text.subdued}>{props.label}</text>
<text fg={theme.text.muted}>{props.label}</text>
</box>
<box flexGrow={1}>
<text fg={props.values.length ? theme.text.default : theme.text.subdued}>{brailleGraph(props.values)}</text>
<text fg={props.values.length ? theme.text.base : theme.text.muted}>{brailleGraph(props.values)}</text>
</box>
<box width={8} alignItems="flex-end">
<text fg={props.values.length ? theme.text.default : theme.text.subdued}>{value()}</text>
<text fg={props.values.length ? theme.text.base : theme.text.muted}>{value()}</text>
</box>
</box>
)
+7 -7
View File
@@ -55,10 +55,10 @@ export function DialogDebug() {
return (
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
<text fg={theme.text.base} attributes={TextAttributes.BOLD}>
Debug
</text>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.text.muted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
@@ -68,10 +68,10 @@ export function DialogDebug() {
<For each={entries()}>
{(entry) => (
<box flexDirection="row" gap={1}>
<text flexShrink={0} fg={theme.text.subdued}>
<text flexShrink={0} fg={theme.text.muted}>
{entry.label.padEnd(10)}
</text>
<text fg={theme.text.default} wrapMode="word">
<text fg={theme.text.base} wrapMode="word">
{entry.value}
</text>
</box>
@@ -79,12 +79,12 @@ export function DialogDebug() {
</For>
</box>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text.subdued}>Share this when reporting an issue.</text>
<text fg={theme.text.muted}>Share this when reporting an issue.</text>
<text onMouseUp={copy}>
<span style={{ fg: copied() ? theme.text.feedback.success.default : theme.text.default }}>
<span style={{ fg: copied() ? theme.text.feedback.success.base : theme.text.base }}>
<b>{copied() ? "✓ copied" : "copy"}</b>{" "}
</span>
<span style={{ fg: theme.text.subdued }}>enter</span>
<span style={{ fg: theme.text.muted }}>enter</span>
</text>
</box>
</box>
@@ -1,6 +1,6 @@
import { CliRenderEvents, TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { createSignal, Show } from "solid-js"
import { useConfig } from "../config"
import { useClipboard } from "../context/clipboard"
import { Keymap } from "../context/keymap"
@@ -27,35 +27,11 @@ export function DialogErrorDetails(props: {
const location = useLocation()
const route = useRoute()
const toast = useToast()
const theme = useTheme("elevated")
const renderer = useRenderer()
const theme = useTheme().surface("dialog")
const dimensions = useTerminalDimensions()
const config = useConfig().data
const [copied, setCopied] = createSignal(false)
const [scrollable, setScrollable] = createSignal(false)
const [height, setHeight] = createSignal(1)
const maxHeight = createMemo(() => Math.max(3, Math.floor(dimensions().height / 2) - 5))
let scroll: ScrollBoxRenderable | undefined
let measure: (() => void) | undefined
createEffect(() => {
dimensions()
props.error
if (measure) renderer.off(CliRenderEvents.FRAME, measure)
measure = () => {
measure = undefined
if (!scroll) return
const next = Math.max(1, Math.min(maxHeight(), scroll.scrollHeight))
setHeight(next)
setScrollable(scroll.scrollHeight > next)
}
renderer.once(CliRenderEvents.FRAME, measure)
renderer.requestRender()
})
onCleanup(() => {
if (measure) renderer.off(CliRenderEvents.FRAME, measure)
})
const copy = () => {
void clipboard
@@ -86,11 +62,10 @@ export function DialogErrorDetails(props: {
}))
useKeyboard((event) => {
if (!scrollable()) return
if (event.name === "up") return scroll?.scrollBy(-1)
if (event.name === "down") return scroll?.scrollBy(1)
if (event.name === "pageup") return scroll?.scrollBy(-maxHeight())
if (event.name === "pagedown") return scroll?.scrollBy(maxHeight())
if (event.name === "pageup") return scroll?.scrollBy(-20)
if (event.name === "pagedown") return scroll?.scrollBy(20)
if (event.name === "home") return scroll?.scrollTo(0)
if (event.name === "end" && scroll) return scroll.scrollTo(scroll.scrollHeight)
})
@@ -101,7 +76,7 @@ export function DialogErrorDetails(props: {
<box flexDirection="row" gap={2}>
<text
attributes={TextAttributes.BOLD}
fg={theme.text.default}
fg={theme.text.base}
flexGrow={1}
minWidth={0}
wrapMode="none"
@@ -109,7 +84,7 @@ export function DialogErrorDetails(props: {
>
{props.title}
</text>
<text fg={theme.text.subdued} flexShrink={0} onMouseUp={props.onBack}>
<text fg={theme.text.muted} flexShrink={0} onMouseUp={props.onBack}>
esc
</text>
</box>
@@ -118,7 +93,7 @@ export function DialogErrorDetails(props: {
<FilePath
value={source()}
maxWidth={Math.min(dialogWidth(dialog.size), dimensions().width - 2) - 4}
fg={theme.text.subdued}
fg={theme.text.muted}
/>
)}
</Show>
@@ -126,34 +101,33 @@ export function DialogErrorDetails(props: {
<box>
<scrollbox
ref={(element: ScrollBoxRenderable) => (scroll = element)}
height={height()}
maxHeight={20}
contentOptions={{ minHeight: 0 }}
scrollbarOptions={{ visible: false }}
scrollAcceleration={getScrollAcceleration(config)}
>
<text fg={theme.text.default} wrapMode="word">
<text fg={theme.text.base} wrapMode="word">
{props.error}
</text>
</scrollbox>
<Show when={props.diagnosticRef}>
<text fg={theme.text.subdued}>Reference: {props.diagnosticRef}</text>
<text fg={theme.text.muted}>Reference: {props.diagnosticRef}</text>
</Show>
</box>
<box flexDirection="row" gap={3} flexWrap="wrap">
<text onMouseUp={investigate}>
<span style={{ fg: theme.text.default }}>
<span style={{ fg: theme.text.base }}>
<b>i</b>
</span>
<span style={{ fg: theme.text.subdued }}> investigate</span>
<span style={{ fg: theme.text.muted }}> investigate</span>
</text>
<text onMouseUp={copy}>
<span style={{ fg: copied() ? theme.text.feedback.success.default : theme.text.default }}>
<span style={{ fg: copied() ? theme.text.feedback.success.base : theme.text.base }}>
<b>{copied() ? "✓ copied" : "c"}</b>
</span>
<span style={{ fg: theme.text.subdued }}>{copied() ? "" : " copy details"}</span>
<span style={{ fg: theme.text.muted }}>{copied() ? "" : " copy details"}</span>
</text>
<Show when={scrollable()}>
<text fg={theme.text.subdued}>/ scroll</text>
</Show>
<text fg={theme.text.muted}>/ scroll</text>
</box>
</box>
)
@@ -57,7 +57,7 @@ export function DialogExperiments() {
onSelect={(option) => void change(option.value)}
emptyView={
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>No experiments available</text>
<text fg={theme.text.muted}>No experiments available</text>
</box>
}
footerHints={experiments.length > 0 ? [{ title: "←/→", label: "change" }] : []}
@@ -13,7 +13,7 @@ type ImagePreviewItem = Readonly<{
export function DialogImagePreview(props: { images: readonly ImagePreviewItem[]; initial: number }) {
const dialog = useDialog()
const dimensions = useTerminalDimensions()
const theme = useTheme("elevated")
const theme = useTheme().surface("dialog")
const [index, setIndex] = createSignal(Math.max(0, Math.min(props.images.length - 1, props.initial)))
const [failed, setFailed] = createSignal(false)
const current = createMemo(() => props.images[index()])
@@ -39,10 +39,10 @@ export function DialogImagePreview(props: { images: readonly ImagePreviewItem[];
return (
<box id="prompt-image-viewer" paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
<text attributes={TextAttributes.BOLD} fg={theme.text.base}>
Image {index() + 1} of {props.images.length}
</text>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.text.muted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
@@ -56,13 +56,13 @@ export function DialogImagePreview(props: { images: readonly ImagePreviewItem[];
onError={() => setFailed(true)}
/>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text.subdued} onMouseUp={() => move(-1)}>
<text fg={theme.text.muted} onMouseUp={() => move(-1)}>
{props.images.length > 1 ? "← previous" : ""}
</text>
<text fg={failed() ? theme.text.feedback.error.default : theme.text.subdued} wrapMode="none" truncate>
<text fg={failed() ? theme.text.feedback.error.base : theme.text.muted} wrapMode="none" truncate>
{failed() ? "No preview" : (current().mention?.text ?? `Image ${index() + 1}`)}
</text>
<text fg={theme.text.subdued} onMouseUp={() => move(1)}>
<text fg={theme.text.muted} onMouseUp={() => move(1)}>
{props.images.length > 1 ? "next →" : ""}
</text>
</box>
@@ -78,7 +78,7 @@ export function DialogIntegration(
const data = useData()
const currentLocation = useLocation()
const dialog = useDialog()
const theme = useTheme("elevated")
const theme = useTheme().surface("dialog")
const location = currentLocation.ref ?? data.location.default()
const integrations = createMemo(() =>
integrationOptions(data.location.integration.list(location) ?? []).filter(
@@ -114,7 +114,7 @@ export function DialogIntegration(
disabled: methods.length === 0 && credentials.length === 0,
gutter:
integration.connections.length > 0
? () => <text fg={theme.text.feedback.success.default}></text>
? () => <text fg={theme.text.feedback.success.base}></text>
: undefined,
onSelect: () => {
if (credentials.length) return manageConnections(integration, methods, location, dialog, props.onConnected)
@@ -130,12 +130,12 @@ export function DialogIntegration(
options={options()}
emptyView={
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>No integrations available</text>
<text fg={theme.text.muted}>No integrations available</text>
</box>
}
noMatchView={
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>No integrations found</text>
<text fg={theme.text.muted}>No integrations found</text>
</box>
}
/>
@@ -153,7 +153,7 @@ function manageConnections(
const data = useData()
const client = useClient()
const toast = useToast()
const theme = useTheme("elevated")
const theme = useTheme().surface("dialog")
const shortcuts = Keymap.useShortcuts()
const [deleting, setDeleting] = createSignal<string>()
const [selected, setSelected] = createSignal(methods.length ? "add" : credentialConnections(integration)[0]?.id)
@@ -427,30 +427,30 @@ function CommandPending(props: {
function CommandView(props: { title: string; output: string; message: string }) {
const dialog = useDialog()
const theme = useTheme("elevated")
const overlayTheme = useTheme("overlay")
const theme = useTheme().surface("dialog")
const overlayTheme = useTheme()
onMount(() => dialog.setSize("large"))
return (
<box gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
<text attributes={TextAttributes.BOLD} fg={theme.text.base}>
{props.title}
</text>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.text.muted} onMouseUp={() => dialog.clear()}>
esc close
</text>
</box>
<box
backgroundColor={overlayTheme.background.default}
backgroundColor={overlayTheme.background.raised.high}
paddingLeft={2}
paddingRight={2}
paddingTop={1}
paddingBottom={1}
>
<text fg={overlayTheme.text.default}>{props.output.trim()}</text>
<text fg={overlayTheme.text.base}>{props.output.trim()}</text>
</box>
<box paddingLeft={2} paddingRight={2}>
<text fg={theme.text.subdued}>{props.message}</text>
<text fg={theme.text.muted}>{props.message}</text>
</box>
</box>
)
@@ -467,7 +467,7 @@ function KeyMethod(props: {
const dialog = useDialog()
const client = useClient()
const toast = useToast()
const theme = useTheme("elevated")
const theme = useTheme().surface("dialog")
const [error, setError] = createSignal<string>()
return (
@@ -487,7 +487,7 @@ function KeyMethod(props: {
.catch((cause) => setError(message(cause)))
}}
description={() => (
<Show when={error()}>{(value) => <text fg={theme.text.feedback.error.default}>{value()}</text>}</Show>
<Show when={error()}>{(value) => <text fg={theme.text.feedback.error.base}>{value()}</text>}</Show>
)}
/>
)
@@ -672,7 +672,7 @@ function OAuthCode(props: {
const dialog = useDialog()
const client = useClient()
const toast = useToast()
const theme = useTheme("elevated")
const theme = useTheme().surface("dialog")
const [error, setError] = createSignal<string>()
let settled = false
@@ -706,9 +706,9 @@ function OAuthCode(props: {
}}
description={() => (
<box gap={1}>
<text fg={theme.text.subdued}>{props.attempt.instructions}</text>
<text fg={theme.text.muted}>{props.attempt.instructions}</text>
<Link href={props.attempt.url} fg={theme.markdown.link} />
<Show when={error()}>{(value) => <text fg={theme.text.feedback.error.default}>{value()}</text>}</Show>
<Show when={error()}>{(value) => <text fg={theme.text.feedback.error.base}>{value()}</text>}</Show>
</box>
)}
/>
@@ -724,14 +724,14 @@ function OAuthView(props: {
open?: boolean
}) {
const dialog = useDialog()
const theme = useTheme("elevated")
const theme = useTheme().surface("dialog")
return (
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
<text attributes={TextAttributes.BOLD} fg={theme.text.base}>
{props.title}
</text>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.text.muted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
@@ -740,21 +740,21 @@ function OAuthView(props: {
<box gap={1}>
<Link href={url()} fg={theme.markdown.link} />
<Show when={props.instructions}>
{(instructions) => <text fg={theme.text.subdued}>{instructions()}</text>}
{(instructions) => <text fg={theme.text.muted}>{instructions()}</text>}
</Show>
</box>
)}
</Show>
<text fg={theme.text.subdued}>{props.message}</text>
<text fg={theme.text.muted}>{props.message}</text>
<box flexDirection="row" gap={2}>
<Show when={props.open}>
<text fg={theme.text.default}>
o <span style={{ fg: theme.text.subdued }}>open</span>
<text fg={theme.text.base}>
o <span style={{ fg: theme.text.muted }}>open</span>
</text>
</Show>
<Show when={props.copy}>
<text fg={theme.text.default}>
c <span style={{ fg: theme.text.subdued }}>copy</span>
<text fg={theme.text.base}>
c <span style={{ fg: theme.text.muted }}>copy</span>
</text>
</Show>
</box>
@@ -852,7 +852,7 @@ function textAnswer(
return new Promise<FormValue | undefined | typeof CANCELLED>((resolve) => {
dialog.replace(
() => {
const theme = useTheme("elevated")
const theme = useTheme().surface("dialog")
const [error, setError] = createSignal<string>()
return (
<DialogPrompt
@@ -872,9 +872,9 @@ function textAnswer(
description={() => (
<box gap={1}>
<Show when={field.description}>
{(description) => <text fg={theme.text.subdued}>{description()}</text>}
{(description) => <text fg={theme.text.muted}>{description()}</text>}
</Show>
<Show when={error()}>{(value) => <text fg={theme.text.feedback.error.default}>{value()}</text>}</Show>
<Show when={error()}>{(value) => <text fg={theme.text.feedback.error.base}>{value()}</text>}</Show>
</box>
)}
/>
+7 -7
View File
@@ -40,7 +40,7 @@ export function DialogMcp(props: { initialServer?: string; details?: boolean } =
const client = useClient()
const location = useLocation()
const toast = useToast()
const theme = useTheme("elevated")
const theme = useTheme().surface("dialog")
const current = () => location.ref ?? data.location.default()
const servers = createMemo(() =>
pipe(
@@ -56,10 +56,10 @@ export function DialogMcp(props: { initialServer?: string; details?: boolean } =
const [loading, setLoading] = createSignal<ReadonlySet<string>>(new Set())
const statusColor = (status: McpServer["status"]) => {
if (status.status === "connected") return theme.text.feedback.success.default
if (status.status === "failed") return theme.text.feedback.error.default
if (status.status === "needs_auth") return theme.text.feedback.warning.default
return theme.text.subdued
if (status.status === "connected") return theme.text.feedback.success.base
if (status.status === "failed") return theme.text.feedback.error.base
if (status.status === "needs_auth") return theme.text.feedback.warning.base
return theme.text.muted
}
createEffect(() => {
@@ -76,7 +76,7 @@ export function DialogMcp(props: { initialServer?: string; details?: boolean } =
value: server.name,
title: server.name,
footer: <Status status={server.status} loading={pending} />,
footerColor: pending ? theme.text.subdued : statusColor(server.status),
footerColor: pending ? theme.text.muted : statusColor(server.status),
}
})
})
@@ -155,7 +155,7 @@ export function DialogMcp(props: { initialServer?: string; details?: boolean } =
]}
footer={
<Show when={focusedError()}>
<text fg={theme.text.subdued}>enter to view error</text>
<text fg={theme.text.muted}>enter to view error</text>
</Show>
}
/>
+9 -11
View File
@@ -11,7 +11,7 @@ import { locationKey, useData } from "../context/data"
import { useClient } from "../context/client"
import { useLocation } from "../context/location"
import { useSessionTabs } from "../context/session-tabs"
import { useTheme, useThemes } from "../context/theme"
import { useTheme } from "../context/theme"
import { Keymap } from "../context/keymap"
import { Locale } from "../util/locale"
import { abbreviateHome } from "../runtime"
@@ -43,9 +43,7 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
const location = useLocation()
const sessionTabs = useSessionTabs()
const toast = useToast()
const themes = useThemes()
const theme = useTheme("elevated")
const mode = themes.mode
const theme = useTheme().surface("dialog")
const paths = useTuiPaths()
const dimensions = useTerminalDimensions()
const shortcuts = Keymap.useShortcuts()
@@ -201,7 +199,7 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
gutter: running
? (color: RGBA) => <Spinner color={color} />
: tabs.has(session.id)
? () => <text fg={theme.hue.accent[mode() === "light" ? 800 : 200]}></text>
? () => <text fg={theme.hue.accent[200]}></text>
: undefined,
}
})
@@ -338,7 +336,7 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
emptyView={
<Show when={!recent.loading && !projects.loading}>
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>No recent sessions or projects</text>
<text fg={theme.text.muted}>No recent sessions or projects</text>
</box>
</Show>
}
@@ -352,13 +350,13 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
>
<box>
<Show when={projectID() && worktrees.loading}>
<Spinner color={theme.text.subdued}>Loading worktrees</Spinner>
<Spinner color={theme.text.muted}>Loading worktrees</Spinner>
</Show>
<Show when={!projectID() && (recent.loading || projects.loading)}>
<Spinner color={theme.text.subdued}>Refreshing sessions and projects</Spinner>
<Spinner color={theme.text.muted}>Refreshing sessions and projects</Spinner>
</Show>
<Show when={!projectID() && (recent() === false || projects() === false)}>
<text fg={theme.text.feedback.error.default}>
<text fg={theme.text.feedback.error.base}>
Could not refresh{" "}
{recent() === false ? (projects() === false ? "sessions and projects" : "sessions") : "projects"}.
</text>
@@ -404,7 +402,7 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
footerHints={[...(projectID() ? [{ title: "new worktree", label: "ctrl+n" }] : [])]}
noMatchView={
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>
<text fg={theme.text.muted}>
{projectID()
? worktrees.loading
? "Loading worktrees…"
@@ -436,7 +434,7 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
size="large"
title={`${projectName(data.project.get(projectID()!)) ?? "Project"} / New worktree`}
placeholder="Worktree name (optional)"
description={() => <text fg={theme.text.subdued}>Leave blank for a random name.</text>}
description={() => <text fg={theme.text.muted}>Leave blank for a random name.</text>}
busy={creating()}
busyText="Creating worktree…"
onCancel={cancelCreation}
+17 -17
View File
@@ -17,7 +17,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
const client = useClient()
const dialog = useDialog()
const dimensions = useTerminalDimensions()
const theme = useTheme("elevated")
const theme = useTheme().surface("dialog")
const [loadError, setLoadError] = createSignal<unknown>()
const [showPassword, setShowPassword] = createSignal(false)
const [passwordHover, setPasswordHover] = createSignal(false)
@@ -61,33 +61,33 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
<box flexDirection={horizontal() ? "row" : "column"} alignItems={horizontal() ? "flex-start" : "center"} gap={2}>
<box width={horizontal() ? 29 : "100%"} flexShrink={0} gap={1}>
<box>
<text fg={theme.text.subdued}>This device</text>
<text fg={theme.text.muted}>This device</text>
<Show when={localhost()}>
{(url) => (
<Link href={href(url())} fg={theme.text.default}>
<Link href={href(url())} fg={theme.text.base}>
{url()}
</Link>
)}
</Show>
</box>
<box>
<text fg={theme.text.subdued}>URLs</text>
<text fg={theme.text.muted}>URLs</text>
<For each={value.urls}>
{(url) => (
<Link href={href(url)} fg={theme.text.default}>
<Link href={href(url)} fg={theme.text.base}>
{url}
</Link>
)}
</For>
</box>
<box>
<text fg={theme.text.subdued}>Username</text>
<text fg={theme.text.default}>{value.username}</text>
<text fg={theme.text.muted}>Username</text>
<text fg={theme.text.base}>{value.username}</text>
</box>
<box>
<text fg={theme.text.subdued}>Password</text>
<text fg={theme.text.muted}>Password</text>
<text
fg={passwordHover() ? theme.text.default : theme.text.subdued}
fg={passwordHover() ? theme.text.base : theme.text.muted}
wrapMode="word"
onMouseOver={() => setPasswordHover(true)}
onMouseOut={() => setPasswordHover(false)}
@@ -97,7 +97,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
</text>
</box>
<Show when={value.urls.some((url) => ["localhost", "127.0.0.1", "[::1]"].includes(new URL(url).hostname))}>
<text fg={theme.text.subdued} wrapMode="word">
<text fg={theme.text.muted} wrapMode="word">
Run `opencode service set hostname 0.0.0.0` to access the service remotely.
</text>
</Show>
@@ -108,7 +108,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
flexShrink={0}
alignItems={horizontal() ? "flex-end" : "center"}
>
<text fg={theme.text.default}>{renderUnicodeCompact(JSON.stringify(value), { border: 1 })}</text>
<text fg={theme.text.base}>{renderUnicodeCompact(JSON.stringify(value), { border: 1 })}</text>
</box>
</box>
)
@@ -117,17 +117,17 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
return (
<box paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
<text fg={theme.text.base} attributes={TextAttributes.BOLD}>
Pair
</text>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.text.muted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<Show
when={loadError()}
fallback={
<Show when={info()} fallback={<text fg={theme.text.subdued}>Loading server information</text>}>
<Show when={info()} fallback={<text fg={theme.text.muted}>Loading server information</text>}>
<Show
when={dimensions().height >= 36}
fallback={
@@ -146,11 +146,11 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
>
{(error) => (
<box>
<text fg={theme.text.feedback.error.default} attributes={TextAttributes.BOLD}>
<text fg={theme.text.feedback.error.base} attributes={TextAttributes.BOLD}>
Could not load server information
</text>
<text fg={theme.text.subdued}>{errorMessage(error())}</text>
<text fg={theme.text.subdued}>Close and reopen Pair to try again.</text>
<text fg={theme.text.muted}>{errorMessage(error())}</text>
<text fg={theme.text.muted}>Close and reopen Pair to try again.</text>
</box>
)}
</Show>
@@ -10,7 +10,7 @@ import { useRoute } from "../context/route"
import { useData } from "../context/data"
import { Keymap } from "../context/keymap"
import { Locale } from "../util/locale"
import { useTheme, useThemes } from "../context/theme"
import { useTheme } from "../context/theme"
import { useClient } from "../context/client"
import { useLocal } from "../context/local"
import { createDebouncedSignal } from "../util/signal"
@@ -29,9 +29,7 @@ export function DialogSessionList() {
const dialog = useDialog()
const route = useRoute()
const data = useData()
const themes = useThemes()
const theme = useTheme("elevated")
const mode = themes.mode
const theme = useTheme().surface("dialog")
const client = useClient()
const local = useLocal()
const sessionTabs = useSessionTabs()
@@ -178,7 +176,7 @@ export function DialogSessionList() {
? (color: RGBA) => <Spinner color={color} />
: slot === undefined
? undefined
: () => <text fg={theme.hue.accent[mode() === "light" ? 800 : 200]}>{slot}</text>,
: () => <text fg={theme.hue.accent[200]}>{slot}</text>,
}
}
@@ -199,11 +197,11 @@ export function DialogSessionList() {
title="Sessions"
titleView={
<box flexDirection="row">
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
<text fg={theme.text.base} attributes={TextAttributes.BOLD}>
Sessions
</text>
<Show when={!allProjects() && currentProjectName()}>
<text fg={theme.text.subdued}> for {currentProjectName()}</text>
<text fg={theme.text.muted}> for {currentProjectName()}</text>
</Show>
</box>
}
@@ -228,14 +226,14 @@ export function DialogSessionList() {
]}
emptyView={
<box paddingLeft={4} paddingRight={4}>
<text fg={searchState().error ? theme.text.feedback.error.default : theme.text.subdued}>
<text fg={searchState().error ? theme.text.feedback.error.base : theme.text.muted}>
{searchState().message}
</text>
</box>
}
noMatchView={
<box paddingLeft={4} paddingRight={4}>
<text fg={searchState().error ? theme.text.feedback.error.default : theme.text.subdued}>
<text fg={searchState().error ? theme.text.feedback.error.base : theme.text.muted}>
{searchState().message}
</text>
</box>
@@ -13,7 +13,7 @@ const PAGE_BYTES = 64 * 1024
export function DialogShellOutput(props: { shell: ShellInfo; location: LocationRef }) {
const client = useClient()
const dialog = useDialog()
const theme = useTheme("elevated")
const theme = useTheme().surface("dialog")
const dimensions = useTerminalDimensions()
const [info, setInfo] = createSignal(props.shell)
const [output, setOutput] = createSignal<string>()
@@ -102,19 +102,19 @@ export function DialogShellOutput(props: { shell: ShellInfo; location: LocationR
return (
<box paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
<box flexDirection="row" gap={2}>
<text fg={theme.text.default} attributes={TextAttributes.BOLD} flexGrow={1}>
<text fg={theme.text.base} attributes={TextAttributes.BOLD} flexGrow={1}>
Shell output
</text>
<text fg={theme.text.subdued}>{status()}</text>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.text.muted}>{status()}</text>
<text fg={theme.text.muted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<text fg={theme.text.subdued} maxHeight={3} wrapMode="word">
<text fg={theme.text.muted} maxHeight={3} wrapMode="word">
{props.shell.command}
</text>
<Show when={omitted()}>
<text fg={theme.text.subdued}>Earlier output omitted · showing recent output</text>
<text fg={theme.text.muted}>Earlier output omitted · showing recent output</text>
</Show>
<scrollbox
id="shell-output-scroll"
@@ -124,7 +124,7 @@ export function DialogShellOutput(props: { shell: ShellInfo; location: LocationR
stickyStart="bottom"
scrollbarOptions={{ visible: false }}
>
<text fg={theme.text.default} wrapMode="word">
<text fg={theme.text.base} wrapMode="word">
{text() ||
(output() === undefined
? "Loading output…"
@@ -132,12 +132,12 @@ export function DialogShellOutput(props: { shell: ShellInfo; location: LocationR
</text>
</scrollbox>
<Show when={error()}>
<text fg={theme.text.feedback.error.default}>{error()}</text>
<text fg={theme.text.feedback.error.base}>{error()}</text>
</Show>
<box flexDirection="row" gap={2} flexWrap="wrap">
<text fg={theme.text.subdued}>/ scroll</text>
<text fg={theme.text.subdued}>end follow</text>
<text fg={theme.text.subdued}>esc back</text>
<text fg={theme.text.muted}>/ scroll</text>
<text fg={theme.text.muted}>end follow</text>
<text fg={theme.text.muted}>esc back</text>
</box>
</box>
)
+6 -6
View File
@@ -63,29 +63,29 @@ export function DialogSkill(props: DialogSkillProps) {
<Switch
fallback={
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>No skills available</text>
<text fg={theme.text.muted}>No skills available</text>
</box>
}
>
<Match when={showError()}>
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.feedback.error.default} attributes={TextAttributes.BOLD}>
<text fg={theme.text.feedback.error.base} attributes={TextAttributes.BOLD}>
Could not load skills
</text>
<text fg={theme.text.subdued}>{errorMessage(loadError())}</text>
<text fg={theme.text.subdued}>Close and reopen Skills to try again.</text>
<text fg={theme.text.muted}>{errorMessage(loadError())}</text>
<text fg={theme.text.muted}>Close and reopen Skills to try again.</text>
</box>
</Match>
<Match when={skills.loading}>
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>Loading skills</text>
<text fg={theme.text.muted}>Loading skills</text>
</box>
</Match>
</Switch>
}
noMatchView={
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>No skills found</text>
<text fg={theme.text.muted}>No skills found</text>
</box>
}
/>
+1 -1
View File
@@ -29,7 +29,7 @@ function getStashPreview(input: string, maxLength: number = 50): string {
export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
const dialog = useDialog()
const stash = usePromptStash()
const theme = useTheme("elevated")
const theme = useTheme().surface("dialog")
const shortcuts = Keymap.useShortcuts()
const [toDelete, setToDelete] = createSignal<number>()
+11 -11
View File
@@ -6,29 +6,29 @@ import { For, Match, Switch, Show, createMemo } from "solid-js"
export function DialogStatus() {
const data = useData()
const theme = useTheme("elevated")
const theme = useTheme().surface("dialog")
const dialog = useDialog()
const mcp = createMemo(() => data.location.mcp.server.list() ?? [])
const color = (status: string) => {
if (status === "connected") return theme.text.feedback.success.default
if (status === "failed") return theme.text.feedback.error.default
if (status === "needs_auth") return theme.text.feedback.warning.default
return theme.text.subdued
if (status === "connected") return theme.text.feedback.success.base
if (status === "failed") return theme.text.feedback.error.base
if (status === "needs_auth") return theme.text.feedback.warning.base
return theme.text.muted
}
return (
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
<text fg={theme.text.base} attributes={TextAttributes.BOLD}>
Status
</text>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.text.muted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<Show when={mcp().length > 0} fallback={<text fg={theme.text.default}>No MCP servers</text>}>
<Show when={mcp().length > 0} fallback={<text fg={theme.text.base}>No MCP servers</text>}>
<box>
<text fg={theme.text.default}>
<text fg={theme.text.base}>
{mcp().length} MCP server{mcp().length === 1 ? "" : "s"}
</text>
<For each={mcp()}>
@@ -37,9 +37,9 @@ export function DialogStatus() {
<text flexShrink={0} style={{ fg: color(item.status.status) }}>
</text>
<text fg={theme.text.default} wrapMode="word">
<text fg={theme.text.base} wrapMode="word">
<b>{item.name}</b>{" "}
<span style={{ fg: theme.text.subdued }}>
<span style={{ fg: theme.text.muted }}>
<Switch fallback={item.status.status}>
<Match when={item.status.status === "connected"}>Connected</Match>
<Match when={item.status.status === "failed" && item.status}>{(val) => val().error}</Match>
+11 -11
View File
@@ -15,7 +15,7 @@ export function DialogUpdate(props: {
restart: () => void
}) {
const dialog = useDialog()
const theme = useTheme("elevated")
const theme = useTheme().surface("dialog")
const [error, setError] = createSignal<string>()
const [active, setActive] = createSignal(0)
const controller = new AbortController()
@@ -88,14 +88,14 @@ export function DialogUpdate(props: {
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
<text attributes={TextAttributes.BOLD} fg={theme.text.base}>
{state().type === "installing"
? "Updating OpenCode"
: state().type === "available" || state().type === "failed"
? "Update available"
: "Update"}
</text>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.text.muted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
@@ -104,33 +104,33 @@ export function DialogUpdate(props: {
{(current) => (
<Switch>
<Match when={current.type === "checking"}>
<Spinner shimmer={theme.text.default}>Checking for updates</Spinner>
<Spinner shimmer={theme.text.base}>Checking for updates</Spinner>
</Match>
<Match when={current.type === "available"}>
<text fg={theme.text.subdued}>
<text fg={theme.text.muted}>
An update is available. After installing, you'll be prompted to restart OpenCode.
</text>
</Match>
<Match when={current.type === "installing"}>
<Spinner shimmer={theme.text.default}>
<Spinner shimmer={theme.text.base}>
{current.type === "installing" ? `Installing OpenCode ${current.version}` : ""}
</Spinner>
</Match>
<Match when={current.type === "installed"}>
<text fg={theme.text.subdued} wrapMode="word">
<text fg={theme.text.muted} wrapMode="word">
Update successful! A restart is required. Any active sessions will be resumed automatically.
</text>
</Match>
<Match when={current.type === "current"}>
<text fg={theme.text.subdued}>OpenCode is already up to date.</text>
<text fg={theme.text.muted}>OpenCode is already up to date.</text>
</Match>
<Match when={current.type === "unavailable"}>
<text fg={theme.text.subdued} wrapMode="word">
<text fg={theme.text.muted} wrapMode="word">
{current.type === "unavailable" ? current.message : ""}
</text>
</Match>
<Match when={current.type === "failed" || current.type === "check-failed"}>
<text fg={theme.text.feedback.error.default}>
<text fg={theme.text.feedback.error.base}>
{current.type === "failed" || current.type === "check-failed" ? current.message : ""}
</text>
</Match>
@@ -148,7 +148,7 @@ export function DialogUpdate(props: {
backgroundColor={active() === index() ? theme.background.action.primary.focused : undefined}
onMouseUp={() => void button.run()}
>
<text fg={active() === index() ? theme.text.action.primary.focused : theme.text.subdued}>
<text fg={active() === index() ? theme.text.action.primary.focused : theme.text.muted}>
{button.label}
</text>
</box>
@@ -31,8 +31,8 @@ export function DialogWorkspaceFileChanges(props: {
message?: string
}) {
const dialog = useDialog()
const theme = useTheme("elevated")
const overlayTheme = useTheme("overlay")
const theme = useTheme().surface("dialog")
const overlayTheme = useTheme()
const config = useConfig().data
const dimensions = useTerminalDimensions()
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
@@ -72,21 +72,21 @@ export function DialogWorkspaceFileChanges(props: {
return (
<box gap={1}>
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
<text attributes={TextAttributes.BOLD} fg={theme.text.base}>
{props.title ?? "File Changes Found"}
</text>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.text.muted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<box paddingLeft={2} paddingRight={2}>
<text fg={theme.text.subdued} wrapMode="word">
<text fg={theme.text.muted} wrapMode="word">
{props.message ?? "Do you want to move these changes with the session?"}
</text>
</box>
<scrollbox
height={height()}
backgroundColor={overlayTheme.background.default}
backgroundColor={overlayTheme.background.raised.high}
scrollbarOptions={{ visible: false }}
scrollAcceleration={scrollAcceleration()}
>
@@ -95,9 +95,9 @@ export function DialogWorkspaceFileChanges(props: {
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
<box flexDirection="row" minWidth={0} flexShrink={1}>
<box width={2} flexShrink={0}>
<text fg={overlayTheme.text.subdued}>{statusLabel(item.status)}</text>
<text fg={overlayTheme.text.muted}>{statusLabel(item.status)}</text>
</box>
<FilePath value={item.file} maxWidth={fileNameWidth()} fg={overlayTheme.text.subdued} />
<FilePath value={item.file} maxWidth={fileNameWidth()} fg={overlayTheme.text.muted} />
</box>
<box flexDirection="row" gap={1} minWidth={7} flexShrink={0} justifyContent="flex-end">
<text>
@@ -125,7 +125,7 @@ export function DialogWorkspaceFileChanges(props: {
dialog.clear()
}}
>
<text fg={item === store.active ? theme.text.action.primary.focused : theme.text.subdued}>{item}</text>
<text fg={item === store.active ? theme.text.action.primary.focused : theme.text.muted}>{item}</text>
</box>
)}
</For>
@@ -40,7 +40,7 @@ export function DialogWorkspaces(props: DialogWorkspacesProps) {
const dialog = useDialog()
const client = useClient()
const dimensions = useTerminalDimensions()
const theme = useTheme("elevated")
const theme = useTheme().surface("dialog")
const sessionData = useData()
const route = useRoute()
const toast = useToast()
@@ -175,18 +175,18 @@ export function DialogWorkspaces(props: DialogWorkspacesProps) {
return {
title,
titleView: isRemoving ? (
<span style={{ fg: theme.text.feedback.error.default }}>Deleting {item.location}</span>
<span style={{ fg: theme.text.feedback.error.base }}>Deleting {item.location}</span>
) : deleting ? (
<span style={{ fg: theme.text.action.destructive.default }}>
<span style={{ fg: theme.text.action.destructive.base }}>
Press {shortcuts.get("dialog.move_session.delete")} again to confirm
</span>
) : suffix ? (
<>
{visible.slice(0, split)}
<span style={{ fg: theme.text.subdued }}>{visible.slice(split)}</span>
<span style={{ fg: theme.text.muted }}>{visible.slice(split)}</span>
</>
) : undefined,
bg: deleting ? theme.background.action.destructive.default : undefined,
bg: deleting ? theme.background.action.destructive.base : undefined,
value: {
type: "directory",
directory: item.location,
@@ -327,7 +327,7 @@ export function DialogWorkspaces(props: DialogWorkspacesProps) {
title="Worktrees"
titleView={
<box flexDirection="row" gap={1}>
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
<text fg={theme.text.base} attributes={TextAttributes.BOLD}>
Worktrees
</text>
<Show when={working() || directories.loading || loadedProject.loading}>
@@ -341,25 +341,25 @@ export function DialogWorkspaces(props: DialogWorkspacesProps) {
emptyView={
showError() ? (
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.feedback.error.default} attributes={TextAttributes.BOLD}>
<text fg={theme.text.feedback.error.base} attributes={TextAttributes.BOLD}>
Could not load worktrees
</text>
<text fg={theme.text.subdued}>{errorMessage(loadError())}</text>
<text fg={theme.text.subdued}>Close and reopen Worktrees to try again.</text>
<text fg={theme.text.muted}>{errorMessage(loadError())}</text>
<text fg={theme.text.muted}>Close and reopen Worktrees to try again.</text>
</box>
) : directories.loading || loadedProject.loading ? (
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>Loading worktrees</text>
<text fg={theme.text.muted}>Loading worktrees</text>
</box>
) : (
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>No worktrees available</text>
<text fg={theme.text.muted}>No worktrees available</text>
</box>
)
}
noMatchView={
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>No worktrees found</text>
<text fg={theme.text.muted}>No worktrees found</text>
</box>
}
locked={showError() || directories.loading || loadedProject.loading || Boolean(removing())}
@@ -8,7 +8,7 @@ import { useConfig } from "../config"
export function DialogWorktreeName(props: { onConfirm: (name: string) => void }) {
const dialog = useDialog()
const theme = useTheme("elevated")
const theme = useTheme().surface("dialog")
const shortcuts = Keymap.useShortcuts()
const config = useConfig().data
const [inputTarget, setInputTarget] = createSignal<InputRenderable>()
@@ -49,10 +49,10 @@ export function DialogWorktreeName(props: { onConfirm: (name: string) => void })
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
<text attributes={TextAttributes.BOLD} fg={theme.text.base}>
Name worktree
</text>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.text.muted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
@@ -64,17 +64,17 @@ export function DialogWorktreeName(props: { onConfirm: (name: string) => void })
}}
onSubmit={confirm}
placeholder="Worktree name"
placeholderColor={theme.text.subdued}
textColor={theme.text.formfield.default}
focusedTextColor={theme.text.formfield.default}
cursorColor={theme.text.formfield.default}
placeholderColor={theme.text.muted}
textColor={theme.text.formfield.base}
focusedTextColor={theme.text.formfield.base}
cursorColor={theme.text.formfield.base}
/>
<box paddingBottom={1} flexDirection="row" gap={2}>
<text fg={theme.text.default}>
enter <span style={{ fg: theme.text.subdued }}>submit</span>
<text fg={theme.text.base}>
enter <span style={{ fg: theme.text.muted }}>submit</span>
</text>
<text fg={theme.text.default}>
{shortcuts.get("dialog.worktree.generate")} <span style={{ fg: theme.text.subdued }}>generate one</span>
<text fg={theme.text.base}>
{shortcuts.get("dialog.worktree.generate")} <span style={{ fg: theme.text.muted }}>generate one</span>
</text>
</box>
</box>
+6 -6
View File
@@ -10,7 +10,7 @@ export function Logo() {
const dimensions = useTerminalDimensions()
const renderLine = (line: string, fg: RGBA, bold: boolean): JSX.Element[] => {
const shadow = tint(theme.background.default, fg, 0.25)
const shadow = tint(theme.background.base, fg, 0.25)
const attrs = bold ? TextAttributes.BOLD : undefined
return Array.from(line).map((char) => {
if (char === "_") {
@@ -53,23 +53,23 @@ export function Logo() {
<box>
{dimensions().height < 12 ? null : dimensions().width < 22 ? (
<For each={go.right.slice(1)}>
{(line) => <box flexDirection="row">{renderLine(line, theme.text.default, true)}</box>}
{(line) => <box flexDirection="row">{renderLine(line, theme.text.base, true)}</box>}
</For>
) : dimensions().width < 44 ? (
<>
<For each={logo.left.slice(1)}>
{(line) => <box flexDirection="row">{renderLine(line, theme.text.subdued, false)}</box>}
{(line) => <box flexDirection="row">{renderLine(line, theme.text.muted, false)}</box>}
</For>
<For each={logo.right}>
{(line) => <box flexDirection="row">{renderLine(line, theme.text.default, true)}</box>}
{(line) => <box flexDirection="row">{renderLine(line, theme.text.base, true)}</box>}
</For>
</>
) : (
<For each={logo.left}>
{(line, index) => (
<box flexDirection="row" gap={1}>
<box flexDirection="row">{renderLine(line, theme.text.subdued, false)}</box>
<box flexDirection="row">{renderLine(logo.right[index()], theme.text.default, true)}</box>
<box flexDirection="row">{renderLine(line, theme.text.muted, false)}</box>
<box flexDirection="row">{renderLine(logo.right[index()], theme.text.base, true)}</box>
</box>
)}
</For>
@@ -10,7 +10,7 @@ type Progress = { label: string; numerator?: number; denominator?: number }
export function MigrationOverlay() {
const client = useClient()
const toast = useToast()
const theme = useTheme("overlay")
const theme = useTheme()
const [progress, setProgress] = createSignal<Progress>()
const abort = new AbortController()
@@ -52,16 +52,16 @@ export function MigrationOverlay() {
top={1}
right={2}
flexDirection="row"
backgroundColor={theme.background.default}
backgroundColor={theme.background.raised.high}
border={["left"]}
borderColor={theme.text.feedback.info.default}
borderColor={theme.text.feedback.info.base}
customBorderChars={SplitBorder.customBorderChars}
paddingLeft={2}
paddingRight={2}
paddingTop={1}
paddingBottom={1}
>
<Spinner color={theme.text.feedback.info.default}>
<Spinner color={theme.text.feedback.info.base}>
{value().label}
{count(value())}
</Spinner>
+6 -5
View File
@@ -2,7 +2,7 @@ import type { BoxRenderable } from "@opentui/core"
import { onCleanup, onMount } from "solid-js"
import { usePanel, type PanelTarget } from "../context/panel"
import { InteractivityProvider } from "../context/interactivity"
import { ThemeContextProvider, useTheme } from "../context/theme"
import { useTheme } from "../context/theme"
import { Slot } from "../plugin/render"
export function PanelHost(props: {
@@ -19,6 +19,9 @@ export function PanelHost(props: {
const Content = () => {
const theme = useTheme()
// Side panels sit on a raised surface; fullscreen takes over the base background.
const background = () =>
panels.presentation() === "panel" ? theme.background.raised.base : theme.background.base
return (
<box
id="session-panel"
@@ -27,7 +30,7 @@ export function PanelHost(props: {
minWidth={0}
minHeight={0}
focusable
backgroundColor={theme.background.default}
backgroundColor={background()}
onMouseDown={props.onFocus}
>
<Slot
@@ -55,9 +58,7 @@ export function PanelHost(props: {
return (
<InteractivityProvider enabled={props.focused}>
<ThemeContextProvider context={panels.presentation() === "panel" ? "elevated" : undefined}>
<Content />
</ThemeContextProvider>
<Content />
</InteractivityProvider>
)
}
@@ -5,7 +5,7 @@ export function PluginRouteMissing(props: { id: string; name: string; onHome: ()
return (
<box width="100%" height="100%" alignItems="center" justifyContent="center" flexDirection="column" gap={1}>
<text fg={theme.text.feedback.warning.default}>
<text fg={theme.text.feedback.warning.base}>
Unknown plugin route: {props.id}/{props.name}
</text>
<box
@@ -77,7 +77,7 @@ export function Autocomplete(props: {
const data = useData()
const keymap = Keymap.use()
const keymapCommands = Keymap.useCommands()
const theme = useTheme("overlay")
const theme = useTheme()
const dimensions = useTerminalDimensions()
const frecency = useFrecency()
const config = useConfig().data
@@ -879,7 +879,7 @@ export function Autocomplete(props: {
width={position().width}
zIndex={100}
{...SplitBorder}
borderColor={theme.border.default}
borderColor={theme.border.base}
>
<scrollbox
ref={(r: ScrollBoxRenderable) => {
@@ -887,7 +887,7 @@ export function Autocomplete(props: {
scroll = r
scroll.verticalScrollBar.on("change", syncSelectionWindow)
}}
backgroundColor={theme.background.default}
backgroundColor={theme.background.raised.high}
height={height()}
scrollbarOptions={{ visible: false }}
scrollAcceleration={scrollAcceleration()}
@@ -896,7 +896,7 @@ export function Autocomplete(props: {
each={options()}
fallback={
<box paddingLeft={1} paddingRight={1}>
<text fg={emptyError() ? theme.text.feedback.error.default : theme.text.subdued}>{emptyMessage()}</text>
<text fg={emptyError() ? theme.text.feedback.error.base : theme.text.muted}>{emptyMessage()}</text>
</box>
}
>
@@ -928,7 +928,7 @@ export function Autocomplete(props: {
? theme.text.action.destructive.focused
: index === store.selected
? theme.text.action.primary.focused
: theme.text.default
: theme.text.base
}
flexShrink={0}
>
@@ -936,7 +936,7 @@ export function Autocomplete(props: {
</text>
<Show when={!confirmingAction() && option().description}>
<text
fg={index === store.selected ? theme.text.action.primary.focused : theme.text.subdued}
fg={index === store.selected ? theme.text.action.primary.focused : theme.text.muted}
wrapMode="none"
>
{" " + option().description?.replace(/\s+/g, " ").trim()}
+21 -21
View File
@@ -350,7 +350,7 @@ export function Prompt(props: PromptProps) {
createEffect(() => {
if (!input || input.isDestroyed) return
input.cursorColor = disabled() ? theme.background.raised.base : theme.text.default
input.cursorColor = disabled() ? theme.background.raised.base : theme.text.base
if (config.cursor) input.cursorStyle = config.cursor
})
@@ -1554,9 +1554,9 @@ export function Prompt(props: PromptProps) {
},
)
const highlight = createMemo(() => {
if (muted()) return theme.border.default
if (muted()) return theme.border.base
if (store.mode === "shell") return theme.text.action.primary.selected
return promptDisplay().agentColor ?? theme.border.default
return promptDisplay().agentColor ?? theme.border.base
})
const agentLabel = createMemo(() => (store.mode === "shell" ? "Shell" : promptDisplay().agentLabel))
const animateMetadata = !revealedPromptMetadata.has(local)
@@ -1573,7 +1573,7 @@ export function Prompt(props: PromptProps) {
createEffect(() => {
if (agentLabel()) revealedPromptMetadata.add(local)
})
const borderHighlight = createMemo(() => tint(theme.border.default, highlight(), agentMetaAlpha()))
const borderHighlight = createMemo(() => tint(theme.border.base, highlight(), agentMetaAlpha()))
const footerInput = () => ({
sessionID: props.sessionID,
mode: store.mode,
@@ -1622,7 +1622,7 @@ export function Prompt(props: PromptProps) {
})
const spinnerDef = createMemo(() => {
const color = promptDisplay().agentColor ?? theme.border.default
const color = promptDisplay().agentColor ?? theme.border.base
return {
frames: createFrames({
color,
@@ -1642,7 +1642,7 @@ export function Prompt(props: PromptProps) {
})
const maxHeight = createMemo(() => Math.max(6, Math.floor(dimensions().height / 3)))
const promptBg = createMemo(() => theme.raise(theme.background.raised.base))
const promptBg = createMemo(() => theme.decrease(theme.background.raised.base))
return (
<>
@@ -1694,7 +1694,7 @@ export function Prompt(props: PromptProps) {
when={!failed()}
fallback={
<box width="100%" height="100%" alignItems="center" justifyContent="center">
<text fg={theme.text.subdued}>No preview</text>
<text fg={theme.text.muted}>No preview</text>
</box>
}
>
@@ -1726,7 +1726,7 @@ export function Prompt(props: PromptProps) {
openImagePreview(visibleImageAttachments().length)
}}
>
<text fg={theme.text.subdued} wrapMode="none" truncate>
<text fg={theme.text.muted} wrapMode="none" truncate>
+{imageAttachments().length - visibleImageAttachments().length} more
</text>
</box>
@@ -1736,9 +1736,9 @@ export function Prompt(props: PromptProps) {
<textarea
width="100%"
placeholder={placeholderText()}
placeholderColor={theme.text.subdued}
textColor={muted() ? theme.text.subdued : theme.text.default}
focusedTextColor={muted() ? theme.text.subdued : theme.text.default}
placeholderColor={theme.text.muted}
textColor={muted() ? theme.text.muted : theme.text.base}
focusedTextColor={muted() ? theme.text.muted : theme.text.base}
minHeight={1}
maxHeight={maxHeight()}
cursorStyle={config.cursor}
@@ -1799,7 +1799,7 @@ export function Prompt(props: PromptProps) {
setTimeout(() => {
// setTimeout is a workaround and needs to be addressed properly
if (!input || input.isDestroyed) return
input.cursorColor = disabled() ? theme.background.raised.base : theme.text.default
input.cursorColor = disabled() ? theme.background.raised.base : theme.text.base
if (config.cursor) input.cursorStyle = config.cursor
}, 0)
}}
@@ -1818,7 +1818,7 @@ export function Prompt(props: PromptProps) {
r.stopPropagation()
}}
focusedBackgroundColor="transparent"
cursorColor={disabled() ? theme.background.raised.base : theme.text.default}
cursorColor={disabled() ? theme.background.raised.base : theme.text.base}
syntaxStyle={syntax()}
/>
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
@@ -1885,17 +1885,17 @@ export function Prompt(props: PromptProps) {
<Match when={status() === "running"}>
<box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start">
<box marginLeft={1}>
<Show when={config.animations ?? true} fallback={<text fg={theme.text.subdued}>[]</text>}>
<Show when={config.animations ?? true} fallback={<text fg={theme.text.muted}>[]</text>}>
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
</Show>
</box>
<PromptInterruptStatus
armed={store.interrupt > 0}
animations={animationsEnabled()}
text={theme.text.default}
subdued={theme.text.subdued}
warning={theme.text.feedback.warning.default}
flash={theme.decrease(theme.text.feedback.warning.default, 2)}
text={theme.text.base}
subdued={theme.text.muted}
warning={theme.text.feedback.warning.base}
flash={theme.decrease(theme.text.feedback.warning.base, 2)}
/>
</box>
</Match>
@@ -1904,7 +1904,7 @@ export function Prompt(props: PromptProps) {
<box paddingLeft={3} height={1} minHeight={0} flexShrink={1}>
<Spinner color={theme.hue.accent[500]}>
{progress()}
<span style={{ fg: theme.text.subdued }}>{".".repeat(move.creatingDots())}</span>
<span style={{ fg: theme.text.muted }}>{".".repeat(move.creatingDots())}</span>
</Spinner>
</box>
)}
@@ -1921,7 +1921,7 @@ export function Prompt(props: PromptProps) {
{(location) => (
<text
id="prompt.footer.location"
fg={locationActions.hovered() ? theme.text.default : theme.text.subdued}
fg={locationActions.hovered() ? theme.text.base : theme.text.muted}
wrapMode="none"
truncate
flexGrow={1}
@@ -1945,7 +1945,7 @@ export function Prompt(props: PromptProps) {
wrapMode="none"
truncate
flexShrink={1}
fg={editorContextLabelState() === "pending" ? theme.hue.accent[500] : theme.text.subdued}
fg={editorContextLabelState() === "pending" ? theme.hue.accent[500] : theme.text.muted}
>
{file()}
</text>
@@ -43,25 +43,25 @@ export function PromptMetadataRow(props: {
{(agent) => <text fg={fade(props.highlight, props.agentAlpha)}>{agent()}</text>}
</Show>
<Show when={props.mode === "normal" && layout().auto}>
<text fg={fade(theme.text.subdued, props.agentAlpha)}>auto</text>
<text fg={fade(theme.text.muted, props.agentAlpha)}>auto</text>
</Show>
<Show when={props.mode === "normal" && layout().model}>
<box flexDirection="row" gap={1} flexGrow={1} flexShrink={1} minWidth={0}>
<Show when={layout().agent}>
<text fg={fade(theme.text.subdued, props.modelAlpha)}>·</text>
<text fg={fade(theme.text.muted, props.modelAlpha)}>·</text>
</Show>
<text
flexShrink={1}
minWidth={0}
wrapMode="none"
truncate
fg={fade(props.muted ? theme.text.subdued : theme.text.default, props.modelAlpha)}
fg={fade(props.muted ? theme.text.muted : theme.text.base, props.modelAlpha)}
>
{layout().model}
</text>
<Show when={layout().provider}>
{(provider) => (
<text flexShrink={0} fg={fade(theme.text.subdued, props.modelAlpha)}>
<text flexShrink={0} fg={fade(theme.text.muted, props.modelAlpha)}>
{provider()}
</text>
)}
@@ -69,9 +69,9 @@ export function PromptMetadataRow(props: {
<Show when={layout().variant}>
{(variant) => (
<>
<text fg={fade(theme.text.subdued, props.variantAlpha)}>·</text>
<text fg={fade(theme.text.muted, props.variantAlpha)}>·</text>
<text
fg={fade(theme.text.feedback.warning.default, props.variantAlpha)}
fg={fade(theme.text.feedback.warning.base, props.variantAlpha)}
attributes={TextAttributes.BOLD}
>
{variant()}
+4 -4
View File
@@ -3,7 +3,7 @@ import { useTheme } from "../context/theme"
import { Spinner } from "./spinner"
export function Reconnecting(props: { managed?: boolean }) {
const theme = useTheme("elevated")
const theme = useTheme()
return (
<box
@@ -21,15 +21,15 @@ export function Reconnecting(props: { managed?: boolean }) {
width={48}
maxWidth="90%"
flexDirection="column"
backgroundColor={theme.background.default}
backgroundColor={theme.background.raised.base}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
gap={1}
>
<Spinner color={theme.text.default}>{props.managed ? "Restarting service…" : "Connection lost…"}</Spinner>
<text fg={theme.text.subdued}>
<Spinner color={theme.text.base}>{props.managed ? "Restarting service…" : "Connection lost…"}</Spinner>
<text fg={theme.text.muted}>
{props.managed
? "Your session will resume automatically."
: "Reconnecting to the server automatically."}
@@ -10,29 +10,29 @@ export function SessionTabsRailControls(props: {
tabs: SessionTabsController
belowHighlighted: boolean
}) {
const theme = useTheme("elevated")
const theme = useTheme()
const keymap = Keymap.use()
const [hovered, setHovered] = createSignal(false)
const hoverColor = createMemo(() =>
tint(theme.background.default, theme.background.action.primary.hovered, theme.background.action.primary.hovered.a),
tint(theme.background.raised.base, theme.background.raised.high, theme.background.raised.high.a),
)
let pressed = false
const search = () => (props.tabs.search ? props.tabs.search() : keymap.dispatch("session.list"))
return (
<box height={1} position="relative" flexShrink={0} backgroundColor={theme.background.default}>
<box height={1} position="relative" flexShrink={0} backgroundColor={theme.background.raised.base}>
<SessionTabHalfRow
top={-1}
edge="top"
width={props.width}
color={hovered() ? hoverColor() : theme.background.default}
background={theme.background.default}
color={hovered() ? hoverColor() : theme.background.raised.base}
background={theme.background.raised.base}
/>
<box
height={1}
position="relative"
flexDirection="row"
justifyContent="center"
backgroundColor={hovered() ? theme.background.action.primary.hovered : undefined}
backgroundColor={hovered() ? theme.background.raised.high : undefined}
onMouseOver={() => setHovered(true)}
onMouseOut={() => setHovered(false)}
onMouseDown={(event) => {
@@ -47,7 +47,7 @@ export function SessionTabsRailControls(props: {
}}
onMouseDragEnd={() => (pressed = false)}
>
<text width={1} height={1} fg={theme.text.action.secondary.default} selectable={false} wrapMode="none">
<text width={1} height={1} fg={theme.text.action.secondary.base} selectable={false} wrapMode="none">
</text>
</box>
@@ -59,7 +59,7 @@ export function SessionTabsRailControls(props: {
height={1}
zIndex={2}
fg={hoverColor()}
bg={hovered() && props.belowHighlighted ? hoverColor() : theme.background.default}
bg={hovered() && props.belowHighlighted ? hoverColor() : theme.background.raised.base}
selectable={false}
>
{(hovered() ? "▀" : props.belowHighlighted ? "▄" : " ").repeat(props.width)}
+88 -83
View File
@@ -120,8 +120,8 @@ const glowTextColor = (base: RGBA, glow: RGBA, index: number, width: number, lev
tint(base, glow, 0.12 * unreadGlowIntensity(index, width) * level)
function tabFeedbackColor(status: SessionTabsStatus, theme: ReturnType<typeof useTheme>) {
if (status.attention) return theme.text.status[status.attention]
if (status.unread === "error") return theme.text.feedback.error.default
if (status.attention) return theme.hue.interactive[800]
if (status.unread === "error") return theme.text.feedback.error.base
return undefined
}
@@ -366,7 +366,9 @@ export function createTabMarquee(animations: () => boolean) {
function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsController; onClose: () => void }) {
const dimensions = useTerminalDimensions()
const theme = useTheme("elevated")
const theme = useTheme()
const background = () => theme.background.raised.base
const actionHovered = () => theme.background.raised.high
const dialog = useDialog()
onCleanup(Keymap.use().mode.push("menu"))
Keymap.createLayer(() => ({
@@ -430,7 +432,7 @@ function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsCo
height={actions().length}
width={CONTEXT_MENU_WIDTH}
flexDirection="column"
backgroundColor={theme.background.default}
backgroundColor={background()}
onMouseDown={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) props.onClose()
event.preventDefault()
@@ -443,7 +445,7 @@ function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsCo
width="100%"
paddingLeft={1}
paddingRight={1}
backgroundColor={selected() === index() ? theme.background.action.primary.hovered : undefined}
backgroundColor={selected() === index() ? actionHovered() : undefined}
onMouseOver={() => setSelected(index())}
onMouseOut={() => setSelected(undefined)}
onMouseUp={(event) => {
@@ -453,7 +455,7 @@ function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsCo
run(index())
}}
>
<text fg={theme.text.default} selectable={false}>
<text fg={theme.text.base} selectable={false}>
{action.title}
</text>
</box>
@@ -515,7 +517,10 @@ function VerticalSessionTabs(props: {
const data = props.controller ? undefined : useData()
const dimensions = useTerminalDimensions()
const renderer = useRenderer()
const theme = useTheme("elevated")
const theme = useTheme()
const background = () => theme.background.raised.base
const actionSelected = () => theme.background.action.primary.selected
const actionHovered = () => theme.background.raised.high
const base = useTheme()
const config = useConfig().data
const animations = () => props.animations ?? config.animations ?? true
@@ -523,11 +528,11 @@ function VerticalSessionTabs(props: {
const compact = createMemo(() => width() < SESSION_TABS_COMPACT_BREAKPOINT)
const tooltipWidth = () => Math.min(54, dimensions().width - width())
const stride = () => (compact() ? 2 : 3)
const unreadColor = () => theme.text.status.unread
const activeNumber = () => theme.text.status.running
const idleNumber = () => tint(theme.text.formfield.default, theme.background.default, 0.55)
const separatorUpperPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.04))
const separatorLowerPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.05))
const unreadColor = () => theme.hue.interactive[800]
const activeNumber = () => theme.hue.accent[800]
const idleNumber = () => tint(theme.text.formfield.base, background(), 0.55)
const separatorUpperPulseColor = createMemo(() => tint(background(), theme.text.base, 0.04))
const separatorLowerPulseColor = createMemo(() => tint(background(), theme.text.base, 0.05))
const [addHovered, setAddHovered] = createSignal(false)
const marquee = createTabMarquee(animations)
const hovered = marquee.hovered
@@ -564,7 +569,7 @@ function VerticalSessionTabs(props: {
})
const items = ordered
const highlightColor = createMemo(() =>
tint(theme.background.default, theme.background.action.primary.hovered, theme.background.action.primary.hovered.a),
tint(background(), actionHovered(), actionHovered().a),
)
const highlighted = (sessionID: string | undefined) =>
sessionID !== undefined && (activeID() === sessionID || hovered() === sessionID || dragging() === sessionID)
@@ -671,7 +676,7 @@ function VerticalSessionTabs(props: {
flexDirection="column"
position="relative"
paddingTop={1}
backgroundColor={theme.background.default}
backgroundColor={background()}
onMouseOut={marquee.leaveHovered}
onMouseUp={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
@@ -694,7 +699,7 @@ function VerticalSessionTabs(props: {
}}
flexGrow={1}
minHeight={0}
backgroundColor={theme.background.default}
backgroundColor={background()}
scrollbarOptions={{ visible: false }}
>
<box flexShrink={0} flexDirection="column" gap={1} paddingY={compact() ? 1 : 0}>
@@ -732,29 +737,29 @@ function VerticalSessionTabs(props: {
const detailFades = createMemo(
() => marqueeOverflows(tabDetail(), titleWidth()) && titleWidth() > FADE_WIDTH,
)
const background = createMemo(() => {
if (selected() && !compact()) return theme.background.action.primary.selected
const tabBackground = createMemo(() => {
if (selected() && !compact()) return actionSelected()
if ((compact() && selected()) || hovered() === tab.sessionID || dragging() === tab.sessionID)
return theme.background.action.primary.hovered
return theme.background.default
return actionHovered()
return background()
})
const pulseBackground = createMemo(() => tint(theme.background.default, background(), background().a))
const pulseBackground = createMemo(() => tint(background(), tabBackground(), tabBackground().a))
const runs = () => status().runs
const numberIgnition = createNumberIgnition(runs, () => status().promptPulse, animations)
const numberColor = () => {
const base =
hovered() === tab.sessionID && !selected()
? foreground()
: tint(idleNumber(), tint(theme.text.default, pulseBackground(), 0.25), Number(selected()))
: tint(idleNumber(), tint(theme.text.base, pulseBackground(), 0.25), Number(selected()))
const color = tabFeedbackColor(status(), theme) ?? tint(base, glowHue(), numberGlow.value().level)
const runningColor = runs() ? activeNumber() : color
return sweepLevel() === 0
? tint(runningColor, theme.text.default, numberIgnition.value().level)
: tint(runningColor, theme.text.default, Math.max(numberIgnition.value().level, 0.35 * sweepLevel()))
? tint(runningColor, theme.text.base, numberIgnition.value().level)
: tint(runningColor, theme.text.base, Math.max(numberIgnition.value().level, 0.35 * sweepLevel()))
}
const foreground = () => {
if (hovered() === tab.sessionID) return theme.text.default
return selected() ? theme.text.default : theme.text.subdued
if (hovered() === tab.sessionID) return theme.text.base
return selected() ? theme.text.base : theme.text.muted
}
const complete = () => status().complete
// Latched so a resolving glow fades out in the hue it lit with instead of snapping to the unread color.
@@ -765,14 +770,14 @@ function VerticalSessionTabs(props: {
if (status().unread !== undefined) return (lastGlowHue = unreadColor())
return lastGlowHue ?? unreadColor()
}
const pulseColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.25))
const flashColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.7))
const pulseColor = createMemo(() => tint(pulseBackground(), theme.text.base, 0.25))
const flashColor = createMemo(() => tint(pulseBackground(), theme.text.base, 0.7))
const glowLevel = createGlowLevel(() => selected() && Boolean(status().attention), animations)
const glowColor = createMemo(() => tint(pulseBackground(), glowHue(), 0.45 * glowLevel()))
const detailPulseColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.13))
const detailFlashColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.42))
const detailPulseColor = createMemo(() => tint(pulseBackground(), theme.text.base, 0.13))
const detailFlashColor = createMemo(() => tint(pulseBackground(), theme.text.base, 0.42))
const detailGlowColor = createMemo(() => tint(pulseBackground(), glowHue(), 0.25 * glowLevel()))
const detailColor = createMemo(() => tint(theme.text.subdued, pulseBackground(), 0.35))
const detailColor = createMemo(() => tint(theme.text.muted, pulseBackground(), 0.35))
const detailTextColor = (index: number) =>
detailFades()
? fadeTitleColor(detailColor(), pulseBackground(), index, visibleDetailParts().length, 0)
@@ -815,10 +820,10 @@ function VerticalSessionTabs(props: {
return lastPreviousGlowHue ?? unreadColor()
}
const separatorUpperColor = createMemo(() =>
tint(theme.background.default, previousGlowHue(), 0.1 * previousGlowLevel()),
tint(background(), previousGlowHue(), 0.1 * previousGlowLevel()),
)
const separatorLowerColor = createMemo(() =>
tint(theme.background.default, glowHue(), 0.12 * glowLevel()),
tint(background(), glowHue(), 0.12 * glowLevel()),
)
const titleColor = (index: number, separator: boolean) => {
const level = titleGlow.value().level
@@ -843,7 +848,7 @@ function VerticalSessionTabs(props: {
width="100%"
position="relative"
flexDirection="column"
backgroundColor={background()}
backgroundColor={tabBackground()}
onMouseOver={(event) => {
setHoverY(event.y)
marquee.enter(tab.sessionID, title(), compact() ? Infinity : hoveredTitleWidth())
@@ -885,7 +890,7 @@ function VerticalSessionTabs(props: {
width={width()}
color={pulseBackground()}
background={
highlighted(items()[index() - 1]?.sessionID) ? highlightColor() : theme.background.default
highlighted(items()[index() - 1]?.sessionID) ? highlightColor() : background()
}
/>
<SessionTabHalfRow
@@ -900,7 +905,7 @@ function VerticalSessionTabs(props: {
: highlighted(items()[index() + 1]?.sessionID)
)
? highlightColor()
: theme.background.default
: background()
}
/>
</Show>
@@ -914,14 +919,14 @@ function VerticalSessionTabs(props: {
idleLabel={Locale.graphemes(title().trimStart())[0] ?? "U"}
color={
selected()
? theme.text.default
? theme.text.base
: props.numbers
? numberColor()
: (tabFeedbackColor(status(), theme) ?? (runs() ? activeNumber() : foreground()))
}
unreadColor={tabFeedbackColor(status(), theme) ?? unreadColor()}
backgroundColor={pulseBackground()}
flashColor={theme.text.default}
flashColor={theme.text.base}
animations={animations()}
numbers={props.numbers}
spinner={props.spinner}
@@ -946,8 +951,8 @@ function VerticalSessionTabs(props: {
color={separatorLowerPulseColor()}
width={indicatorWidth}
outerColor={separatorUpperPulseColor()}
flashColor={tint(theme.background.default, theme.text.default, 0.22)}
outerFlashColor={tint(theme.background.default, theme.text.default, 0.18)}
flashColor={tint(background(), theme.text.base, 0.22)}
outerFlashColor={tint(background(), theme.text.base, 0.18)}
flashTail={8}
glowColor={separatorLowerColor()}
outerGlowColor={separatorUpperColor()}
@@ -955,7 +960,7 @@ function VerticalSessionTabs(props: {
outerGlowTail={5}
completionColor={separatorLowerColor()}
outerCompletionColor={separatorUpperColor()}
backgroundColor={theme.background.default}
backgroundColor={background()}
/>
<Show when={index() === items().length - 1}>
<TabPulse
@@ -970,18 +975,18 @@ function VerticalSessionTabs(props: {
outerComplete={false}
glow={glows()}
outerGlow={false}
color={tint(theme.background.default, theme.text.default, 0.04)}
color={tint(background(), theme.text.base, 0.04)}
width={indicatorWidth}
outerColor={tint(theme.background.default, theme.text.default, 0.006)}
flashColor={tint(theme.background.default, theme.text.default, 0.18)}
outerColor={tint(background(), theme.text.base, 0.006)}
flashColor={tint(background(), theme.text.base, 0.18)}
flashTail={8}
glowColor={tint(theme.background.default, glowHue(), 0.1 * glowLevel())}
outerGlowColor={theme.background.default}
glowColor={tint(background(), glowHue(), 0.1 * glowLevel())}
outerGlowColor={background()}
glowTail={8}
outerGlowTail={5}
completionColor={tint(theme.background.default, glowHue(), 0.1 * glowLevel())}
outerCompletionColor={theme.background.default}
backgroundColor={theme.background.default}
completionColor={tint(background(), glowHue(), 0.1 * glowLevel())}
outerCompletionColor={background()}
backgroundColor={background()}
/>
</Show>
<box height={1} width="100%" flexDirection="row" position="relative">
@@ -1008,7 +1013,7 @@ function VerticalSessionTabs(props: {
color={numberColor()}
unreadColor={tabFeedbackColor(status(), theme) ?? unreadColor()}
backgroundColor={pulseBackground()}
flashColor={theme.text.default}
flashColor={theme.text.base}
animations={animations()}
numbers={props.numbers}
spinner={props.spinner}
@@ -1048,7 +1053,7 @@ function VerticalSessionTabs(props: {
right={1}
zIndex={2}
width={1}
fg={closeHovered() ? theme.text.default : theme.text.subdued}
fg={closeHovered() ? theme.text.base : theme.text.muted}
selectable={false}
onMouseOver={() => setCloseHovered(true)}
onMouseOut={() => setCloseHovered(false)}
@@ -1100,7 +1105,7 @@ function VerticalSessionTabs(props: {
)
}}
</For>
{/* One slot with two states: a subdued affordance that promotes in place into the
{/* One slot with two states: a muted affordance that promotes in place into the
active new-session tab, instead of spawning a separate pseudo tab above itself. */}
<Show when={tabs.add || newTab()}>
<box
@@ -1113,10 +1118,10 @@ function VerticalSessionTabs(props: {
alignItems="center"
backgroundColor={
newTab() && !compact()
? theme.background.action.primary.selected
? actionSelected()
: addHovered() || (compact() && newTab())
? theme.background.action.primary.hovered
: theme.background.default
? actionHovered()
: background()
}
onMouseOver={() => setAddHovered(true)}
onMouseOut={() => setAddHovered(false)}
@@ -1145,19 +1150,19 @@ function VerticalSessionTabs(props: {
edge="top"
width={width()}
color={highlightColor()}
background={highlighted(items().at(-1)?.sessionID) ? highlightColor() : theme.background.default}
background={highlighted(items().at(-1)?.sessionID) ? highlightColor() : background()}
/>
<SessionTabHalfRow
top={1}
edge="bottom"
width={width()}
color={highlightColor()}
background={theme.background.default}
background={background()}
/>
</Show>
<text
width={compact() ? 1 : 2}
fg={newTab() || addHovered() ? theme.text.default : idleNumber()}
fg={newTab() || addHovered() ? theme.text.base : idleNumber()}
selectable={false}
attributes={newTab() ? TextAttributes.BOLD : undefined}
>
@@ -1165,7 +1170,7 @@ function VerticalSessionTabs(props: {
</text>
<Show when={!compact()}>
<text
fg={newTab() || addHovered() ? theme.text.default : theme.text.subdued}
fg={newTab() || addHovered() ? theme.text.base : theme.text.muted}
wrapMode="none"
selectable={false}
attributes={newTab() ? TextAttributes.BOLD : undefined}
@@ -1179,7 +1184,7 @@ function VerticalSessionTabs(props: {
right={1}
zIndex={2}
width={1}
fg={theme.text.subdued}
fg={theme.text.muted}
selectable={false}
onMouseUp={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
@@ -1211,11 +1216,11 @@ function VerticalSessionTabs(props: {
top={0}
edge="top"
width={tooltipWidth()}
color={theme.background.default}
background={base.background.default}
color={background()}
background={base.background.base}
/>
<box height={2} paddingX={1} backgroundColor={theme.background.default}>
<text fg={theme.text.default} wrapMode="none" selectable={false}>
<box height={2} paddingX={1} backgroundColor={background()}>
<text fg={theme.text.base} wrapMode="none" selectable={false}>
{Locale.truncateWidth(
data?.session.get(sessionID())?.title ??
items().find((tab) => tab.sessionID === sessionID())?.title ??
@@ -1223,7 +1228,7 @@ function VerticalSessionTabs(props: {
tooltipWidth() - 2,
)}
</text>
<text fg={theme.text.subdued} wrapMode="none" selectable={false}>
<text fg={theme.text.muted} wrapMode="none" selectable={false}>
{Locale.takeWidth(detail(sessionID()), tooltipWidth() - 2)}
</text>
</box>
@@ -1231,8 +1236,8 @@ function VerticalSessionTabs(props: {
top={3}
edge="bottom"
width={tooltipWidth()}
color={theme.background.default}
background={base.background.default}
color={background()}
background={base.background.base}
/>
</box>
)}
@@ -1286,9 +1291,9 @@ function HorizontalSessionTabs(props: {
onCleanup(clearCloseHold)
// A captured drag ends with a synthetic up on its drop target; do not turn that into a click.
let suppressClick = false
const unreadColor = () => theme.text.status.unread
const activeNumber = () => theme.text.status.running
const idleNumber = () => tint(theme.text.formfield.default, theme.background.default, 0.55)
const unreadColor = () => theme.hue.interactive[800]
const activeNumber = () => theme.hue.accent[800]
const idleNumber = () => tint(theme.text.formfield.base, theme.background.base, 0.55)
const newTab = () => tabs.newTab?.() ?? false
const activeID = createMemo(() => (newTab() ? NEW_SESSION_TAB.sessionID : tabs.current()))
const ordered = createMemo(() => {
@@ -1514,7 +1519,7 @@ function HorizontalSessionTabs(props: {
onMouseDragEnd={release}
>
<Show when={layout().before > 0}>
<text width={sessionTabOverflowWidth(layout().before)} fg={theme.text.subdued} selectable={false}>
<text width={sessionTabOverflowWidth(layout().before)} fg={theme.text.muted} selectable={false}>
{layout().before}
</text>
</Show>
@@ -1528,14 +1533,14 @@ function HorizontalSessionTabs(props: {
const dragged = () => dragging() === tab.sessionID
const background = createMemo(() => {
const lifted = (hovered() === tab.sessionID || dragged()) && !selected()
const base = lifted ? theme.background.action.primary.hovered : theme.background.default
const base = lifted ? theme.background.action.primary.hovered : theme.background.base
// A dragged tab lifts to full selected elevation while it is held.
return tint(base, theme.raise(theme.background.raised.base), dragged() ? 1 : selection())
return tint(base, theme.decrease(theme.background.raised.base), dragged() ? 1 : selection())
})
const pulseColor = () => tint(background(), theme.text.default, 0.45)
const pulseColor = () => tint(background(), theme.text.base, 0.45)
// The edge flash washes toward a brighter stop on the same background-to-text ramp,
// so it reads as a lift of the pulse color rather than a different hue.
const flashColor = () => tint(background(), theme.text.default, 0.65)
const flashColor = () => tint(background(), theme.text.base, 0.65)
const feedbackColor = () => tabFeedbackColor(status(), theme)
const glowLevel = createGlowLevel(() => selected() && Boolean(status().attention), animations)
const glowColor = createMemo(() => tint(background(), feedbackColor() ?? unreadColor(), glowLevel()))
@@ -1568,8 +1573,8 @@ function HorizontalSessionTabs(props: {
const runs = () => status().busy && !status().attention
const numberIgnition = createNumberIgnition(runs, () => status().promptPulse, animations)
const foreground = () => {
if (hovered() === tab.sessionID) return theme.text.default
return tint(theme.text.subdued, theme.text.default, selection())
if (hovered() === tab.sessionID) return theme.text.base
return tint(theme.text.muted, theme.text.base, selection())
}
// Title characters sitting over the glow tinge toward its color, following the same
// spatial falloff as the glow itself; characters beyond the tail stay neutral.
@@ -1595,13 +1600,13 @@ function HorizontalSessionTabs(props: {
const base =
hovered() === tab.sessionID && !selected()
? foreground()
: tint(idleNumber(), tint(theme.text.default, background(), 0.25), selection())
: tint(idleNumber(), tint(theme.text.base, background(), 0.25), selection())
const color = runs() ? activeNumber() : (feedback ?? tint(base, unreadColor(), activity()))
// The number brightens faintly as the running sweep passes beneath it.
return tint(color, theme.text.default, Math.max(numberIgnition.value().level, 0.15 * sweepLevel()))
return tint(color, theme.text.base, Math.max(numberIgnition.value().level, 0.15 * sweepLevel()))
}
const bold = () => (selected() || dragged() ? TextAttributes.BOLD : undefined)
const closeColor = () => tint(theme.text.subdued, theme.text.default, 0.6)
const closeColor = () => tint(theme.text.muted, theme.text.base, 0.6)
return (
<box
width={width()}
@@ -1661,7 +1666,7 @@ function HorizontalSessionTabs(props: {
color={numberColor()}
unreadColor={feedbackColor() ?? unreadColor()}
backgroundColor={background()}
flashColor={theme.text.default}
flashColor={theme.text.base}
animations={animations()}
numbers={props.numbers}
spinner={props.spinner}
@@ -1692,7 +1697,7 @@ function HorizontalSessionTabs(props: {
right={1}
zIndex={2}
width={1}
fg={closeHovered() ? theme.text.default : closeColor()}
fg={closeHovered() ? theme.text.base : closeColor()}
selectable={false}
onMouseOver={() => setCloseHovered(true)}
onMouseOut={() => setCloseHovered(false)}
@@ -1720,14 +1725,14 @@ function HorizontalSessionTabs(props: {
}}
</For>
<Show when={layout().after > 0}>
<text width={sessionTabOverflowWidth(layout().after)} fg={theme.text.subdued} selectable={false}>
<text width={sessionTabOverflowWidth(layout().after)} fg={theme.text.muted} selectable={false}>
{" " + layout().after}
</text>
</Show>
<Show when={showPlus()}>
<text
width={ADD_TAB_WIDTH}
fg={addHovered() ? theme.text.default : theme.text.subdued}
fg={addHovered() ? theme.text.base : theme.text.muted}
bg={addHovered() ? theme.background.action.primary.hovered : undefined}
selectable={false}
onMouseOver={() => setAddHovered(true)}
+1 -1
View File
@@ -14,7 +14,7 @@ registerOpencodeSpinner()
export function Spinner(props: { children?: JSX.Element; color?: RGBA; shimmer?: RGBA }) {
const theme = useTheme()
const config = useConfig().data
const color = () => props.color ?? theme.text.subdued
const color = () => props.color ?? theme.text.muted
const [frame, setFrame] = createSignal(0)
createEffect(() => {
if (!(config.animations ?? true) || !props.shimmer) return

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