mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-18 23:06:25 +00:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fc6c9f07e | ||
|
|
ffe4d1522c | ||
|
|
b1860465cd | ||
|
|
cab11795e5 | ||
|
|
810e79a6d4 | ||
|
|
d81604714f | ||
|
|
d1e828c217 | ||
|
|
437cdc03a9 | ||
|
|
219ecada68 | ||
|
|
9e1fa80fb7 | ||
|
|
278db3023f | ||
|
|
c2067d59af | ||
|
|
f5d40d6f30 | ||
|
|
c8e654410d |
@@ -42,19 +42,19 @@ runs:
|
||||
bun-version-file: ${{ !steps.bun-url.outputs.url && !inputs.bun-version && 'package.json' || '' }}
|
||||
bun-download-url: ${{ steps.bun-url.outputs.url }}
|
||||
|
||||
- name: Get cache directory
|
||||
- name: Get cache configuration
|
||||
id: cache
|
||||
shell: bash
|
||||
run: echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT"
|
||||
run: |
|
||||
echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT"
|
||||
echo "key=${RUNNER_OS}-bun-${{ hashFiles('**/bun.lock') }}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore Bun dependencies
|
||||
id: bun-cache
|
||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: ${{ steps.cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
key: ${{ steps.cache.outputs.key }}
|
||||
|
||||
- name: Install setuptools for distutils compatibility
|
||||
run: python3 -m pip install setuptools || pip install setuptools || true
|
||||
@@ -66,9 +66,9 @@ runs:
|
||||
# e.g. ./patches/ for standard-openapi
|
||||
# https://github.com/oven-sh/bun/issues/28147
|
||||
if [ "$RUNNER_OS" = "Windows" ]; then
|
||||
bun install --linker hoisted ${{ inputs.install-flags }}
|
||||
bun install --frozen-lockfile --linker hoisted ${{ inputs.install-flags }}
|
||||
else
|
||||
bun install ${{ inputs.install-flags }}
|
||||
bun install --frozen-lockfile ${{ inputs.install-flags }}
|
||||
fi
|
||||
shell: bash
|
||||
|
||||
@@ -77,4 +77,4 @@ runs:
|
||||
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: ${{ steps.cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
||||
key: ${{ steps.cache.outputs.key }}
|
||||
|
||||
@@ -103,7 +103,7 @@ jobs:
|
||||
- name: Cache Turbo
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: node_modules/.cache/turbo
|
||||
path: .turbo/cache
|
||||
key: turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-${{ github.sha }}
|
||||
restore-keys: |
|
||||
turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-
|
||||
@@ -141,7 +141,6 @@ jobs:
|
||||
TURBO_SCM_HEAD: ${{ github.sha }}
|
||||
|
||||
- name: Verify compiled service lifecycle
|
||||
if: always()
|
||||
timeout-minutes: 10
|
||||
working-directory: packages/cli
|
||||
env:
|
||||
@@ -151,13 +150,11 @@ jobs:
|
||||
bun run script/service-smoke.ts
|
||||
|
||||
- name: Setup Node build runtime
|
||||
if: always()
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "26.4.0"
|
||||
|
||||
- name: Verify Node build
|
||||
if: always()
|
||||
timeout-minutes: 15
|
||||
working-directory: packages/cli
|
||||
env:
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Buffer } from "node:buffer"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { Effect, Option, Schema, SchemaGetter } from "effect"
|
||||
import { Tool } from "@opencode/schema/tool"
|
||||
import { Route } from "../route/client.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
@@ -21,11 +21,12 @@ import {
|
||||
type JsonSchema,
|
||||
type MediaPart,
|
||||
type ProviderMetadata,
|
||||
type ProviderOptions,
|
||||
type ToolCallPart,
|
||||
type ToolDefinition,
|
||||
type ToolResultPart,
|
||||
} from "../schema/index.js"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { JsonObject, knownString, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { classifyProviderFailure } from "../provider-error.js"
|
||||
import { effortUpdate, resolveEffortUpdates } from "../effort-updates.js"
|
||||
import * as Cache from "./utils/cache.js"
|
||||
@@ -52,57 +53,10 @@ const SSE_EVENTS = new Set([
|
||||
])
|
||||
export const framing = Framing.sseEvents(SSE_EVENTS)
|
||||
|
||||
export type ThinkingBlockBinding = {
|
||||
readonly prefix_mismatch_behavior?: "error" | "drop_block" | (string & {})
|
||||
}
|
||||
|
||||
export type ThinkingInput =
|
||||
| {
|
||||
readonly type: "adaptive"
|
||||
readonly display?: "summarized" | "omitted"
|
||||
readonly block_binding?: ThinkingBlockBinding
|
||||
}
|
||||
| {
|
||||
readonly type: "disabled"
|
||||
}
|
||||
| ({
|
||||
readonly type: "enabled"
|
||||
readonly display?: "summarized" | "omitted"
|
||||
readonly block_binding?: ThinkingBlockBinding
|
||||
} & (
|
||||
| { readonly budgetTokens: number; readonly budget_tokens?: number }
|
||||
| { readonly budgetTokens?: number; readonly budget_tokens: number }
|
||||
))
|
||||
|
||||
export interface OptionsInput {
|
||||
/** Advanced in-band compaction. The caller owns checkpoint persistence and recovery. */
|
||||
readonly contextManagement?: ContextManagement
|
||||
readonly [key: string]: unknown
|
||||
readonly thinking?: ThinkingInput
|
||||
readonly effort?: string
|
||||
readonly service_tier?: "auto" | "standard_only"
|
||||
readonly serviceTier?: "auto" | "standard_only"
|
||||
// SDK Metadata:2649 {user_id?: string | null}
|
||||
readonly metadata?: { readonly user_id?: string | null }
|
||||
// SDK MessageCreateParamsContainer:2596 ContainerParams|string
|
||||
readonly container?:
|
||||
| string
|
||||
| { readonly id?: string | null; readonly skills?: ReadonlyArray<Record<string, unknown>> | null }
|
||||
readonly inference_geo?: string | null
|
||||
readonly inferenceGeo?: string | null
|
||||
readonly cache_control?: { readonly type: "ephemeral"; readonly ttl?: "5m" | "1h" }
|
||||
readonly cacheControl?: { readonly type: "ephemeral"; readonly ttl?: "5m" | "1h" }
|
||||
// SDK OutputConfig:2684 {effort, format: JSONOutputFormat}
|
||||
readonly output_config?: {
|
||||
readonly effort?: string | null
|
||||
readonly format?: { readonly type: "json_schema"; readonly schema: Record<string, unknown> } | null
|
||||
}
|
||||
readonly outputConfig?: {
|
||||
readonly effort?: string | null
|
||||
readonly format?: { readonly type: "json_schema"; readonly schema: Record<string, unknown> } | null
|
||||
}
|
||||
}
|
||||
|
||||
export type ThinkingBlockBinding = typeof AnthropicThinkingBlockBinding.Type
|
||||
export type ThinkingInput = typeof Thinking.Encoded
|
||||
/** Caller-facing provider options; unknown keys are accepted and ignored. `Options.Type` is the wire-ready form. */
|
||||
export type OptionsInput = ProviderOptions & typeof Options.Encoded
|
||||
export type ProviderOptionsInput = OptionsInput
|
||||
|
||||
export const ContextManagement = Schema.Struct({
|
||||
@@ -129,6 +83,7 @@ const AnthropicCacheControl = Schema.Struct({
|
||||
type: Schema.tag("ephemeral"),
|
||||
ttl: Schema.optional(Schema.Literals(["5m", "1h"])),
|
||||
})
|
||||
const AnthropicServiceTier = Schema.Literals(["auto", "standard_only"])
|
||||
|
||||
const AnthropicTextBlock = Schema.Struct({
|
||||
type: Schema.tag("text"),
|
||||
@@ -317,25 +272,21 @@ const AnthropicToolChoice = Schema.Union([
|
||||
])
|
||||
|
||||
const AnthropicThinkingBlockBinding = Schema.Struct({
|
||||
prefix_mismatch_behavior: Schema.optional(Schema.String),
|
||||
prefix_mismatch_behavior: Schema.optional(knownString<"error" | "drop_block">()),
|
||||
})
|
||||
|
||||
const AnthropicThinking = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.tag("enabled"),
|
||||
budget_tokens: Schema.Number,
|
||||
display: Schema.optional(Schema.Literals(["summarized", "omitted"])),
|
||||
block_binding: Schema.optional(AnthropicThinkingBlockBinding),
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.tag("adaptive"),
|
||||
display: Schema.optional(Schema.Literals(["summarized", "omitted"])),
|
||||
block_binding: Schema.optional(AnthropicThinkingBlockBinding),
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.tag("disabled"),
|
||||
}),
|
||||
])
|
||||
const AnthropicThinkingFields = {
|
||||
display: Schema.optional(Schema.Literals(["summarized", "omitted"])),
|
||||
block_binding: Schema.optional(AnthropicThinkingBlockBinding),
|
||||
}
|
||||
const AnthropicThinkingEnabled = Schema.Struct({
|
||||
type: Schema.tag("enabled"),
|
||||
budget_tokens: Schema.Number,
|
||||
...AnthropicThinkingFields,
|
||||
})
|
||||
const AnthropicThinkingAdaptive = Schema.Struct({ type: Schema.tag("adaptive"), ...AnthropicThinkingFields })
|
||||
const AnthropicThinkingDisabled = Schema.Struct({ type: Schema.tag("disabled") })
|
||||
const AnthropicThinking = Schema.Union([AnthropicThinkingEnabled, AnthropicThinkingAdaptive, AnthropicThinkingDisabled])
|
||||
type AnthropicThinking = typeof AnthropicThinking.Type
|
||||
|
||||
// SDK OutputConfig:2684 {effort?: "low"|"medium"|"high"|"xhigh"|"max"|null, format?: JSONOutputFormat:2399}
|
||||
@@ -360,6 +311,53 @@ const AnthropicContainer = Schema.Union([
|
||||
}),
|
||||
])
|
||||
|
||||
// =============================================================================
|
||||
// Provider Options
|
||||
// =============================================================================
|
||||
// Callers spell the budget as `budgetTokens` or the wire `budget_tokens`; the
|
||||
// keys are disjoint per variant so the input type requires exactly one and the
|
||||
// transform can narrow on it. Decoding straight to the wire block keeps the
|
||||
// alias out of the rest of the file.
|
||||
const ThinkingEnabledInput = Schema.Union([
|
||||
Schema.Struct({ type: Schema.tag("enabled"), budgetTokens: Schema.Number, ...AnthropicThinkingFields }),
|
||||
Schema.Struct({ type: Schema.tag("enabled"), budget_tokens: Schema.Number, ...AnthropicThinkingFields }),
|
||||
]).pipe(
|
||||
Schema.decodeTo(AnthropicThinkingEnabled, {
|
||||
decode: SchemaGetter.transform((input) => ({
|
||||
type: "enabled" as const,
|
||||
budget_tokens: "budgetTokens" in input ? input.budgetTokens : input.budget_tokens,
|
||||
display: input.display,
|
||||
block_binding: input.block_binding,
|
||||
})),
|
||||
encode: SchemaGetter.passthrough({ strict: false }),
|
||||
}),
|
||||
)
|
||||
const Thinking = Schema.Union([ThinkingEnabledInput, AnthropicThinkingAdaptive, AnthropicThinkingDisabled])
|
||||
|
||||
const OutputConfigInput = Schema.Struct({
|
||||
effort: optionalNull(Schema.String),
|
||||
format: optionalNull(AnthropicJsonOutputFormat),
|
||||
})
|
||||
|
||||
// Both key spellings are accepted; `fromRequest` prefers the snake_case one.
|
||||
const Options = Schema.Struct({
|
||||
/** Advanced in-band compaction. The caller owns checkpoint persistence and recovery. */
|
||||
contextManagement: Schema.optional(ContextManagement),
|
||||
thinking: Schema.optional(Thinking),
|
||||
effort: Schema.optional(Schema.String),
|
||||
service_tier: Schema.optional(AnthropicServiceTier),
|
||||
serviceTier: Schema.optional(AnthropicServiceTier),
|
||||
metadata: Schema.optional(AnthropicMetadata),
|
||||
container: Schema.optional(AnthropicContainer),
|
||||
inference_geo: optionalNull(Schema.String),
|
||||
inferenceGeo: optionalNull(Schema.String),
|
||||
cache_control: Schema.optional(AnthropicCacheControl),
|
||||
cacheControl: Schema.optional(AnthropicCacheControl),
|
||||
output_config: Schema.optional(OutputConfigInput),
|
||||
outputConfig: Schema.optional(OutputConfigInput),
|
||||
})
|
||||
const decodeOptions = ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))
|
||||
|
||||
const AnthropicBodyFields = {
|
||||
context_management: Schema.optional(
|
||||
Schema.Struct({
|
||||
@@ -391,7 +389,7 @@ const AnthropicBodyFields = {
|
||||
container: Schema.optional(Schema.NullOr(AnthropicContainer)),
|
||||
inference_geo: Schema.optional(Schema.NullOr(Schema.String)),
|
||||
metadata: Schema.optional(AnthropicMetadata),
|
||||
service_tier: Schema.optional(Schema.Literals(["auto", "standard_only"])),
|
||||
service_tier: Schema.optional(AnthropicServiceTier),
|
||||
}
|
||||
export const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields)
|
||||
export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesBody>
|
||||
@@ -1001,64 +999,6 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
return messages
|
||||
})
|
||||
|
||||
const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* (request: LLMRequest) {
|
||||
const input = request.providerOptions as Record<string, unknown> | undefined
|
||||
const rawServiceTier =
|
||||
(input as Record<string, unknown> | undefined)?.service_tier ??
|
||||
(input as Record<string, unknown> | undefined)?.serviceTier
|
||||
const service_tier =
|
||||
rawServiceTier === "auto" || rawServiceTier === "standard_only"
|
||||
? (rawServiceTier as "auto" | "standard_only")
|
||||
: undefined
|
||||
const rawMetadata = (input as Record<string, unknown> | undefined)?.metadata
|
||||
const metadata =
|
||||
ProviderShared.isRecord(rawMetadata) && (typeof rawMetadata.user_id === "string" || rawMetadata.user_id === null)
|
||||
? { user_id: rawMetadata.user_id as string | null }
|
||||
: undefined
|
||||
const container =
|
||||
typeof (input as Record<string, unknown> | undefined)?.container === "string" ||
|
||||
ProviderShared.isRecord((input as Record<string, unknown> | undefined)?.container)
|
||||
? ((input as Record<string, unknown>).container as
|
||||
| string
|
||||
| { id?: string | null; skills?: ReadonlyArray<Record<string, unknown>> | null })
|
||||
: undefined
|
||||
const rawInferenceGeo =
|
||||
(input as Record<string, unknown> | undefined)?.inference_geo ??
|
||||
(input as Record<string, unknown> | undefined)?.inferenceGeo
|
||||
const inference_geo = typeof rawInferenceGeo === "string" ? rawInferenceGeo : undefined
|
||||
const rawCacheControl =
|
||||
(input as Record<string, unknown> | undefined)?.cache_control ??
|
||||
(input as Record<string, unknown> | undefined)?.cacheControl
|
||||
const cache_control =
|
||||
ProviderShared.isRecord(rawCacheControl) && rawCacheControl.type === "ephemeral"
|
||||
? (rawCacheControl as { type: "ephemeral"; ttl?: "5m" | "1h" })
|
||||
: undefined
|
||||
const rawOutputConfig =
|
||||
(input as Record<string, unknown> | undefined)?.output_config ??
|
||||
(input as Record<string, unknown> | undefined)?.outputConfig
|
||||
const outputConfigEffort =
|
||||
typeof (input as Record<string, unknown> | undefined)?.effort === "string"
|
||||
? ((input as Record<string, unknown>).effort as string)
|
||||
: ProviderShared.isRecord(rawOutputConfig) && typeof rawOutputConfig.effort === "string"
|
||||
? (rawOutputConfig.effort as string)
|
||||
: undefined
|
||||
const outputConfigFormat =
|
||||
ProviderShared.isRecord(rawOutputConfig) && ProviderShared.isRecord(rawOutputConfig.format)
|
||||
? (rawOutputConfig.format as { type: "json_schema"; schema: Record<string, unknown> })
|
||||
: undefined
|
||||
const thinking = yield* resolveThinking(input?.thinking)
|
||||
return {
|
||||
thinking: applyThinkingBindingDefault(request.model, thinking),
|
||||
effort: outputConfigEffort,
|
||||
format: outputConfigFormat,
|
||||
service_tier,
|
||||
metadata,
|
||||
container,
|
||||
inference_geo,
|
||||
cache_control,
|
||||
}
|
||||
})
|
||||
|
||||
// Accept gateway namespaces and Vertex suffixes without treating a snapshot date as a minor version.
|
||||
const claudeVersion = (id: string) => {
|
||||
const match = /(?:^|[./])claude-(?<family>[a-z]+)-(?<major>\d+)(?:[.-](?<minor>\d{1,2}))?(?:$|[-:@])/.exec(
|
||||
@@ -1097,35 +1037,12 @@ const applyThinkingBindingDefault = (model: LLMRequest["model"], thinking: Anthr
|
||||
}
|
||||
}
|
||||
|
||||
const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function* (input: unknown) {
|
||||
if (!ProviderShared.isRecord(input)) return undefined
|
||||
if (input.type === "disabled") return { type: "disabled" as const }
|
||||
if (input.type !== "adaptive" && input.type !== "enabled") return undefined
|
||||
const block_binding = yield* ProviderShared.validateWith(
|
||||
Schema.decodeUnknownEffect(Schema.UndefinedOr(AnthropicThinkingBlockBinding)),
|
||||
)(input.block_binding)
|
||||
const display =
|
||||
input.display === "summarized" || input.display === "omitted"
|
||||
? (input.display as "summarized" | "omitted")
|
||||
: undefined
|
||||
if (input.type === "adaptive") return { type: "adaptive" as const, display, block_binding }
|
||||
const budget =
|
||||
typeof input.budgetTokens === "number"
|
||||
? input.budgetTokens
|
||||
: typeof input.budget_tokens === "number"
|
||||
? input.budget_tokens
|
||||
: undefined
|
||||
if (budget === undefined)
|
||||
return yield* ProviderShared.invalidRequest("Anthropic thinking provider option requires budgetTokens")
|
||||
return { type: "enabled" as const, budget_tokens: budget, display, block_binding }
|
||||
})
|
||||
|
||||
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
|
||||
const management = yield* ProviderShared.validateWith(
|
||||
Schema.decodeUnknownEffect(Schema.UndefinedOr(ContextManagement)),
|
||||
)(request.providerOptions?.contextManagement)
|
||||
const options = yield* resolveOptions(request)
|
||||
const updates = resolveEffortUpdates(request, options.effort)
|
||||
const options = yield* decodeOptions(request.providerOptions ?? {})
|
||||
const management = options.contextManagement
|
||||
const outputConfig = options.output_config ?? options.outputConfig
|
||||
const format = outputConfig?.format ?? undefined
|
||||
const updates = resolveEffortUpdates(request, options.effort ?? outputConfig?.effort ?? undefined)
|
||||
const generation = request.generation
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
// Allocate the 4-breakpoint budget in invalidation order: tools → system →
|
||||
@@ -1161,12 +1078,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
)
|
||||
}
|
||||
const output_config =
|
||||
updates.effort === undefined && options.format === undefined
|
||||
? undefined
|
||||
: {
|
||||
...(updates.effort === undefined ? {} : { effort: updates.effort }),
|
||||
...(options.format === undefined ? {} : { format: options.format }),
|
||||
}
|
||||
updates.effort === undefined && format === undefined ? undefined : { effort: updates.effort, format }
|
||||
const body = {
|
||||
model: request.model.id,
|
||||
system,
|
||||
@@ -1179,14 +1091,14 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
top_p: generation?.topP,
|
||||
top_k: generation?.topK,
|
||||
stop_sequences: generation?.stop,
|
||||
thinking: options.thinking,
|
||||
thinking: applyThinkingBindingDefault(request.model, options.thinking),
|
||||
output_config,
|
||||
// top-level passthrough per SDK MessageCreateParamsBase:4638,4643,4649,4654,4670
|
||||
cache_control: options.cache_control,
|
||||
cache_control: options.cache_control ?? options.cacheControl,
|
||||
container: options.container,
|
||||
inference_geo: options.inference_geo,
|
||||
inference_geo: options.inference_geo ?? options.inferenceGeo ?? undefined,
|
||||
metadata: options.metadata,
|
||||
service_tier: options.service_tier,
|
||||
service_tier: options.service_tier ?? options.serviceTier,
|
||||
}
|
||||
if (!management) return body
|
||||
return {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Buffer } from "node:buffer"
|
||||
import { Tool } from "@opencode/schema/tool"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { Effect, Option, Schema, Stream } from "effect"
|
||||
import * as Sse from "effect/unstable/encoding/Sse"
|
||||
import { Headers, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import {
|
||||
@@ -29,6 +29,16 @@ const isJson = Schema.is(Schema.Json)
|
||||
export const JsonObject = Schema.Record(Schema.String, Schema.Unknown)
|
||||
export const optionalArray = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.Array(schema))
|
||||
export const optionalNull = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.NullOr(schema))
|
||||
/** Optional field whose malformed value decodes to `undefined` instead of failing the enclosing struct. */
|
||||
export const lenient = <const S extends Schema.Top>(schema: S) =>
|
||||
Schema.optionalKey(
|
||||
Schema.UndefinedOr(schema).pipe(Schema.catchDecoding(() => Effect.succeed(Option.some(undefined)))),
|
||||
)
|
||||
/** Provider-defined string enum: known values for autocomplete, any string accepted at runtime. */
|
||||
export const knownString = <Known extends string>() =>
|
||||
Schema.declare<Known | (string & {})>((value): value is Known | (string & {}) => typeof value === "string", {
|
||||
expected: "string",
|
||||
})
|
||||
|
||||
export const OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH = 64
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Option, Schema } from "effect"
|
||||
import { Schema } from "effect"
|
||||
import { ReasoningEffort, ReasoningEfforts, type LLMRequest } from "../../schema/index.js"
|
||||
import { lenient } from "../shared.js"
|
||||
|
||||
export { ReasoningEffort, ReasoningEfforts }
|
||||
|
||||
@@ -49,21 +50,22 @@ export const StreamOptions = Schema.Struct({
|
||||
includeObfuscation: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
|
||||
// Malformed options are dropped one at a time so a bad `topLogprobs` cannot discard `store` or `reasoningEffort`.
|
||||
export const Options = Schema.Struct({
|
||||
store: Schema.optional(Schema.Boolean),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
safetyIdentifier: Schema.optional(Schema.String),
|
||||
streamOptions: Schema.optional(StreamOptions),
|
||||
topLogprobs: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 20 }))),
|
||||
reasoningEffort: Schema.optional(ReasoningEffort),
|
||||
reasoningSummary: Schema.optional(Schema.Literals(["auto", "concise", "detailed"])),
|
||||
include: Schema.optional(Schema.Array(ResponseIncludableSchema)),
|
||||
textVerbosity: Schema.optional(TextVerbositySchema),
|
||||
serviceTier: Schema.optional(ServiceTierSchema),
|
||||
truncation: Schema.optional(TruncationSchema),
|
||||
allowedTools: Schema.optional(AllowedTools),
|
||||
maxToolCalls: Schema.optional(Schema.Int),
|
||||
parallelToolCalls: Schema.optional(Schema.Boolean),
|
||||
store: lenient(Schema.Boolean),
|
||||
metadata: lenient(Schema.Record(Schema.String, Schema.String)),
|
||||
safetyIdentifier: lenient(Schema.String),
|
||||
streamOptions: lenient(StreamOptions),
|
||||
topLogprobs: lenient(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 20 }))),
|
||||
reasoningEffort: lenient(ReasoningEffort),
|
||||
reasoningSummary: lenient(Schema.Literals(["auto", "concise", "detailed"])),
|
||||
include: lenient(Schema.Array(ResponseIncludableSchema)),
|
||||
textVerbosity: lenient(TextVerbositySchema),
|
||||
serviceTier: lenient(ServiceTierSchema),
|
||||
truncation: lenient(TruncationSchema),
|
||||
allowedTools: lenient(AllowedTools),
|
||||
maxToolCalls: lenient(Schema.Int),
|
||||
parallelToolCalls: lenient(Schema.Boolean),
|
||||
})
|
||||
export type Options = typeof Options.Type
|
||||
|
||||
@@ -71,11 +73,10 @@ export type Resolved = Omit<Options, "allowedTools"> & {
|
||||
readonly allowedTools?: AllowedTools & { readonly mode: NonNullable<AllowedTools["mode"]> }
|
||||
}
|
||||
|
||||
const decodeOptions = Schema.decodeUnknownOption(Options)
|
||||
const decodeOptions = Schema.decodeUnknownSync(Options)
|
||||
|
||||
export const resolve = (request: LLMRequest): Resolved => {
|
||||
const input = Option.getOrUndefined(decodeOptions(request.providerOptions))
|
||||
if (!input) return {}
|
||||
const input = decodeOptions(request.providerOptions ?? {})
|
||||
return {
|
||||
...input,
|
||||
include: input.include?.length ? input.include : undefined,
|
||||
|
||||
@@ -166,7 +166,71 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error.message).toContain("Anthropic thinking provider option requires budgetTokens")
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
expect(error.message).toContain("budgetTokens")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers passthrough provider options and accepts either key spelling", () =>
|
||||
Effect.gen(function* () {
|
||||
const snake = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
providerOptions: {
|
||||
service_tier: "auto",
|
||||
metadata: { user_id: "user_1" },
|
||||
container: { id: "container_1" },
|
||||
inference_geo: "us",
|
||||
cache_control: { type: "ephemeral", ttl: "1h" },
|
||||
output_config: { format: { type: "json_schema", schema: { type: "object" } } },
|
||||
},
|
||||
}),
|
||||
)
|
||||
const camel = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
providerOptions: {
|
||||
serviceTier: "standard_only",
|
||||
container: "container_2",
|
||||
inferenceGeo: "eu",
|
||||
cacheControl: { type: "ephemeral" },
|
||||
outputConfig: { effort: "low" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(snake.body).toMatchObject({
|
||||
service_tier: "auto",
|
||||
metadata: { user_id: "user_1" },
|
||||
container: { id: "container_1" },
|
||||
inference_geo: "us",
|
||||
cache_control: { type: "ephemeral", ttl: "1h" },
|
||||
output_config: { format: { type: "json_schema", schema: { type: "object" } } },
|
||||
})
|
||||
expect(camel.body).toMatchObject({
|
||||
service_tier: "standard_only",
|
||||
container: "container_2",
|
||||
inference_geo: "eu",
|
||||
cache_control: { type: "ephemeral" },
|
||||
output_config: { effort: "low" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores unknown provider options and rejects malformed known ones", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(LLMRequest.update(request, { providerOptions: { unknownOption: true } }))
|
||||
const malformed = [
|
||||
{ service_tier: "fast" },
|
||||
{ metadata: { user_id: 42 } },
|
||||
{ cache_control: { type: "ephemeral", ttl: "2h" } },
|
||||
{ output_config: { format: { type: "text" } } },
|
||||
{ thinking: { type: "automatic" } },
|
||||
]
|
||||
const errors = yield* Effect.forEach(malformed, (providerOptions) =>
|
||||
compileRequest(LLMRequest.update(request, { providerOptions })).pipe(Effect.flip),
|
||||
)
|
||||
|
||||
expect(prepared.body).not.toHaveProperty("unknownOption")
|
||||
expect(errors.map((error) => error.reason._tag)).toEqual(malformed.map(() => "InvalidRequest"))
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -248,6 +248,21 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps valid Chat options when a sibling option is malformed", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).chat("gpt-4o-mini"),
|
||||
prompt: "think",
|
||||
providerOptions: { store: true, reasoningEffort: "max", topLogprobs: 25 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.store).toBe(true)
|
||||
expect(prepared.body.reasoning_effort).toBe("max")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps the request prompt cache key when the compatibility flag is set", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
|
||||
@@ -1945,6 +1945,30 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("drops a malformed provider option without discarding its siblings", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "hi",
|
||||
providerOptions: {
|
||||
topLogprobs: 25,
|
||||
metadata: { tenant: 7 },
|
||||
reasoningEffort: "high",
|
||||
serviceTier: "priority",
|
||||
maxToolCalls: 4,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.top_logprobs).toBeUndefined()
|
||||
expect(prepared.body.metadata).toBeUndefined()
|
||||
expect(prepared.body.reasoning).toEqual({ effort: "high" })
|
||||
expect(prepared.body.service_tier).toBe("priority")
|
||||
expect(prepared.body.max_tool_calls).toBe(4)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("accepts the full ResponseIncludable union", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
|
||||
@@ -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(() => ({
|
||||
|
||||
@@ -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 }
|
||||
}>
|
||||
|
||||
@@ -128,6 +128,8 @@ function prepareOptions(model: Info, pkg: string) {
|
||||
const customFetch = options.fetch
|
||||
const chunkTimeout = options.chunkTimeout
|
||||
delete options.chunkTimeout
|
||||
delete options.compaction
|
||||
delete options.transport
|
||||
options.fetch = async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
||||
const opts = { ...(init ?? {}) }
|
||||
const signals = [
|
||||
@@ -388,7 +390,10 @@ function requestSettings(settings: Readonly<Record<string, unknown>> | undefined
|
||||
if (settings === undefined) return undefined
|
||||
const result = Object.fromEntries(
|
||||
Object.entries(settings).filter(
|
||||
([key]) => !["apiKey", "authToken", "baseURL", "chunkTimeout", "fetch", "timeout"].includes(key),
|
||||
([key]) =>
|
||||
!["apiKey", "authToken", "baseURL", "chunkTimeout", "compaction", "fetch", "timeout", "transport"].includes(
|
||||
key,
|
||||
),
|
||||
),
|
||||
)
|
||||
return Object.keys(result).length === 0 ? undefined : result
|
||||
|
||||
@@ -68,8 +68,6 @@ export const Plugin = define({
|
||||
if (item.canonical !== undefined) provider.canonical = item.canonical
|
||||
if (item.name !== undefined) provider.name = item.name
|
||||
if (item.package !== undefined) provider.package = item.package
|
||||
if (item.compaction !== undefined) provider.compaction = { ...item.compaction }
|
||||
if (item.transport !== undefined) provider.transport = item.transport
|
||||
if (item.settings !== undefined) provider.settings = Provider.mergeOverlay(provider.settings, item.settings)
|
||||
if (item.headers !== undefined) provider.headers = Provider.mergeHeaders(provider.headers, item.headers)
|
||||
if (item.body !== undefined) provider.body = Provider.mergeOverlay(provider.body, item.body)
|
||||
@@ -116,8 +114,6 @@ export const Plugin = define({
|
||||
if (config.compatibility !== undefined)
|
||||
model.compatibility = { ...model.compatibility, ...config.compatibility }
|
||||
if (config.package !== undefined) model.package = config.package
|
||||
if (config.compaction !== undefined) model.compaction = { ...config.compaction }
|
||||
if (config.transport !== undefined) model.transport = config.transport
|
||||
if (config.settings !== undefined) model.settings = Provider.mergeOverlay(model.settings, config.settings)
|
||||
if (config.headers !== undefined) model.headers = Provider.mergeHeaders(model.headers, config.headers)
|
||||
if (config.body !== undefined) model.body = Provider.mergeOverlay(model.body, config.body)
|
||||
|
||||
+61
-14
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -95,6 +95,7 @@ import { ProviderPlugins } from "./provider.js"
|
||||
import { WebSearchPlugins } from "./websearch/index.js"
|
||||
import { SkillPlugin } from "./skill.js"
|
||||
import { VcsHgPlugin } from "./vcs/hg.js"
|
||||
import { ToolInputRepairPlugin } from "./tool-input-repair.js"
|
||||
import { OptimizePlugin } from "./optimize.js"
|
||||
import { VcsGitPlugin } from "./vcs/git.js"
|
||||
import { WarmingPlugin } from "./warming.js"
|
||||
@@ -205,6 +206,7 @@ export const requirements = LayerNode.group([
|
||||
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
|
||||
|
||||
const pre = [
|
||||
ToolInputRepairPlugin.Plugin,
|
||||
ConfigWorktreePlugin.Plugin,
|
||||
BrowserPlugin,
|
||||
ConfigMcpPlugin.Plugin,
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ function required<T>(value: T | undefined): T {
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
describe("ConfigProviderPlugin.Plugin", () => {
|
||||
it.effect("inherits provider compaction policy with model overrides and rejects unsupported routes", () =>
|
||||
it.effect("inherits the provider compaction setting with model overrides and rejects unsupported routes", () =>
|
||||
Effect.gen(function* () {
|
||||
const models = yield* Model.Service
|
||||
yield* addPlugin([
|
||||
@@ -44,12 +44,10 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
providers: {
|
||||
custom: {
|
||||
package: "@opencode/ai/providers/openai/responses",
|
||||
compaction: { mode: "provider", threshold: 120_000 },
|
||||
settings: { compaction: { type: "native" } },
|
||||
models: {
|
||||
native: {},
|
||||
reset: { compaction: { mode: "provider" } },
|
||||
threshold: { compaction: { mode: "provider", threshold: 90_000 } },
|
||||
local: { compaction: { mode: "local" }, package: "@opencode/ai/providers/openai/chat" },
|
||||
local: { settings: { compaction: { type: "summary" } }, package: "@opencode/ai/providers/openai/chat" },
|
||||
unsupported: { package: "@opencode/ai/providers/openai/chat" },
|
||||
},
|
||||
},
|
||||
@@ -62,16 +60,9 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
const local = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("local")))
|
||||
const unsupported = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("unsupported")))
|
||||
const defaultModel = required(yield* models.get(Provider.ID.make("default"), Model.ID.make("chat")))
|
||||
expect(native.compaction).toEqual({ mode: "provider", threshold: 120_000 })
|
||||
expect((yield* models.get(Provider.ID.make("custom"), Model.ID.make("reset")))?.compaction).toEqual({
|
||||
mode: "provider",
|
||||
})
|
||||
expect((yield* models.get(Provider.ID.make("custom"), Model.ID.make("threshold")))?.compaction).toEqual({
|
||||
mode: "provider",
|
||||
threshold: 90_000,
|
||||
})
|
||||
expect(local.compaction).toEqual({ mode: "local" })
|
||||
expect(defaultModel.compaction).toBeUndefined()
|
||||
expect(native.settings?.compaction).toEqual({ type: "native" })
|
||||
expect(local.settings?.compaction).toEqual({ type: "summary" })
|
||||
expect(defaultModel.settings?.compaction).toBeUndefined()
|
||||
yield* ModelResolver.fromCatalogModel(native)
|
||||
yield* ModelResolver.fromCatalogModel(local)
|
||||
yield* ModelResolver.fromCatalogModel(defaultModel)
|
||||
@@ -92,8 +83,8 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
providers: {
|
||||
custom: {
|
||||
package: "@opencode/ai/providers/openai/responses",
|
||||
transport: "http",
|
||||
models: { inherited: {}, override: { transport: "websocket" } },
|
||||
settings: { transport: "http" },
|
||||
models: { inherited: {}, override: { settings: { transport: "websocket" } } },
|
||||
},
|
||||
default: { package: "@opencode/ai/providers/openai/responses", models: { untouched: {} } },
|
||||
},
|
||||
@@ -103,9 +94,9 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
const inherited = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("inherited")))
|
||||
const override = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("override")))
|
||||
const untouched = required(yield* models.get(Provider.ID.make("default"), Model.ID.make("untouched")))
|
||||
expect(inherited.transport).toBe("http")
|
||||
expect(override.transport).toBe("websocket")
|
||||
expect(untouched.transport).toBeUndefined()
|
||||
expect(inherited.settings?.transport).toBe("http")
|
||||
expect(override.settings?.transport).toBe("websocket")
|
||||
expect(untouched.settings?.transport).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -131,7 +122,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
editor.models.update(providerID, modelID, () => {})
|
||||
})
|
||||
yield* builtin.plugin.effect(host)
|
||||
expect((yield* models.get(providerID, modelID))?.transport).toBe("websocket")
|
||||
expect((yield* models.get(providerID, modelID))?.settings?.transport).toBe("websocket")
|
||||
|
||||
yield* addPlugin([
|
||||
new Document({
|
||||
@@ -139,16 +130,16 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
info: decode({
|
||||
providers: {
|
||||
[builtin.id]: {
|
||||
transport: "http",
|
||||
models: { override: { modelID: builtin.model, transport: "websocket" } },
|
||||
settings: { transport: "http" },
|
||||
models: { override: { modelID: builtin.model, settings: { transport: "websocket" } } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
])
|
||||
|
||||
expect((yield* models.get(providerID, modelID))?.transport).toBe("http")
|
||||
expect((yield* models.get(providerID, Model.ID.make("override")))?.transport).toBe("websocket")
|
||||
expect((yield* models.get(providerID, modelID))?.settings?.transport).toBe("http")
|
||||
expect((yield* models.get(providerID, Model.ID.make("override")))?.settings?.transport).toBe("websocket")
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -473,10 +473,10 @@ describe("AzurePlugin", () => {
|
||||
yield* addPlugin()
|
||||
|
||||
const responses = required(yield* service.get(Provider.ID.azure, models.responses))
|
||||
expect(responses.transport).toBe("websocket")
|
||||
expect(responses.settings?.transport).toBe("websocket")
|
||||
for (const modelID of [models.chat, models.preview, models.deploymentURL, models.gateway, models.nonAzure]) {
|
||||
const model = required(yield* service.get(Provider.ID.azure, modelID))
|
||||
expect(model.transport).toBeUndefined()
|
||||
expect(model.settings?.transport).toBeUndefined()
|
||||
}
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -205,7 +205,7 @@ describe("OpenAIPlugin", () => {
|
||||
expect(model.package).toBe("@opencode/ai/providers/openai")
|
||||
expect(model.enabled).toBe(true)
|
||||
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
|
||||
expect(model.transport).toBe("websocket")
|
||||
expect(model.settings?.transport).toBe("websocket")
|
||||
expect(direct.headers).not.toHaveProperty("originator")
|
||||
expect(direct.baseURL).toBe("https://api.openai.com/v1")
|
||||
expect(provider.headers).not.toHaveProperty("x-codex-beta-features")
|
||||
@@ -236,7 +236,7 @@ describe("OpenAIPlugin", () => {
|
||||
id: "deployment-responses",
|
||||
provider: Provider.ID.azure,
|
||||
})
|
||||
const prepare = (preference?: Model.Info["transport"]) =>
|
||||
const prepare = (preference?: Provider.Transport) =>
|
||||
Effect.gen(function* () {
|
||||
const model = SessionRunnerModel.resolved(route.model({ id: "gpt-5.5" }), {
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
|
||||
@@ -84,7 +84,7 @@ describe("XAIPlugin", () => {
|
||||
yield* addPlugin()
|
||||
|
||||
const model = yield* models.get(providerID, Model.ID.make("grok-4.6"))
|
||||
expect(model?.transport).toBe("websocket")
|
||||
expect(model?.settings?.transport).toBe("websocket")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Agent } from "@opencode/core/agent"
|
||||
import { Plugin } from "@opencode/core/plugin"
|
||||
import { ToolInputRepairPlugin } from "@opencode/core/plugin/tool-input-repair"
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { SessionMessage } from "@opencode/core/session/message"
|
||||
import { Tool } from "@opencode/core/tool"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const identity = {
|
||||
sessionID: Session.ID.make("ses_repair"),
|
||||
agent: Agent.ID.make("build"),
|
||||
messageID: SessionMessage.ID.make("msg_repair"),
|
||||
}
|
||||
|
||||
it.effect("repairs tool input before validating its original schema", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const registry = yield* Tool.Service
|
||||
const executed: unknown[] = []
|
||||
yield* plugins.activate([
|
||||
{ ...ToolInputRepairPlugin.Plugin, revision: "1" },
|
||||
{
|
||||
id: "repairable-tool",
|
||||
revision: "1",
|
||||
effect: (ctx) =>
|
||||
ctx.tool.transform((draft) =>
|
||||
draft.add({
|
||||
name: "repairable",
|
||||
options: { codemode: false },
|
||||
description: "Repairable",
|
||||
input: Schema.Struct({ count: Schema.Int, enabled: Schema.Boolean }),
|
||||
execute: (input) => Effect.sync(() => executed.push(input)).pipe(Effect.as({ content: "ok" })),
|
||||
}),
|
||||
),
|
||||
},
|
||||
])
|
||||
const snapshot = yield* registry.snapshot()
|
||||
yield* snapshot.execute({
|
||||
...identity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-repair",
|
||||
name: "repairable",
|
||||
input: '{"count":"2","enabled":"true","extra":true}',
|
||||
},
|
||||
})
|
||||
expect(executed).toEqual([{ count: 2, enabled: true }])
|
||||
|
||||
yield* registry.transform((draft) => {
|
||||
draft.update("repairable", (tool) => {
|
||||
tool.input = Schema.Struct({ count: Schema.Boolean, enabled: Schema.Boolean })
|
||||
})
|
||||
})
|
||||
const updated = yield* registry.snapshot()
|
||||
yield* updated.execute({
|
||||
...identity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-updated",
|
||||
name: "repairable",
|
||||
input: { count: "false", enabled: "true" },
|
||||
},
|
||||
})
|
||||
expect(executed).toEqual([
|
||||
{ count: 2, enabled: true },
|
||||
{ count: false, enabled: true },
|
||||
])
|
||||
|
||||
yield* registry.transform((draft) => draft.remove("repairable"))
|
||||
const removed = yield* registry.snapshot()
|
||||
expect(
|
||||
(yield* removed
|
||||
.execute({
|
||||
...identity,
|
||||
call: { type: "tool-call", id: "call-removed", name: "repairable", input: {} },
|
||||
})
|
||||
.pipe(Effect.flip)).message,
|
||||
).toBe('No tool named "repairable" is currently available. Please use a tool from the available tool list.')
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("repairs namespaced inner tool input called from Code Mode", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const registry = yield* Tool.Service
|
||||
const executed: unknown[] = []
|
||||
yield* plugins.activate([{ ...ToolInputRepairPlugin.Plugin, revision: "1" }])
|
||||
yield* registry.transform((draft) =>
|
||||
draft.add({
|
||||
name: "count",
|
||||
options: { namespace: "example" },
|
||||
description: "Record a count",
|
||||
input: Schema.Struct({ count: Schema.Int }),
|
||||
execute: (input) => Effect.sync(() => executed.push(input)).pipe(Effect.as({ content: "ok" })),
|
||||
}),
|
||||
)
|
||||
|
||||
const snapshot = yield* registry.snapshot()
|
||||
yield* snapshot.execute({
|
||||
...identity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-codemode-repair",
|
||||
name: "execute",
|
||||
input: { code: 'return await tools.example.count({ count: "3" })' },
|
||||
},
|
||||
})
|
||||
expect(executed).toEqual([{ count: 3 }])
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,466 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Agent } from "@opencode/core/agent"
|
||||
import { ToolInputRepairPlugin } from "@opencode/core/plugin/tool-input-repair"
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { SessionMessage } from "@opencode/core/session/message"
|
||||
import type { ToolHooks } from "@opencode/plugin/effect/tool"
|
||||
import { Tool } from "@opencode/schema/tool"
|
||||
import { Effect, type JsonSchema } from "effect"
|
||||
import { it } from "../lib/effect"
|
||||
import { host } from "./host"
|
||||
|
||||
function run(input: unknown, inputSchema: JsonSchema.JsonSchema) {
|
||||
const event: ToolHooks["execute.before"] = {
|
||||
tool: "test",
|
||||
input,
|
||||
sessionID: Session.ID.make("ses_repair"),
|
||||
agent: Agent.ID.make("build"),
|
||||
messageID: SessionMessage.ID.make("msg_repair"),
|
||||
id: Tool.CallID.make("call_repair"),
|
||||
}
|
||||
const events: ToolHooks = {
|
||||
"execute.before": event,
|
||||
"execute.after": { ...event, status: "error", error: new Tool.Error({ message: "unused" }) },
|
||||
}
|
||||
const base = host()
|
||||
return ToolInputRepairPlugin.Plugin.effect(
|
||||
host({
|
||||
tool: {
|
||||
...base.tool,
|
||||
transform: (callback) =>
|
||||
Effect.sync(() => {
|
||||
const tool = {
|
||||
id: "test",
|
||||
name: "test",
|
||||
description: "Test repair",
|
||||
input: inputSchema,
|
||||
execute: () => Effect.succeed({ content: "unused" }),
|
||||
}
|
||||
callback({
|
||||
list: () => [tool],
|
||||
get: (id) => (id === tool.id ? tool : undefined),
|
||||
add: () => {},
|
||||
namespace: () => {},
|
||||
update: () => {},
|
||||
remove: () => {},
|
||||
})
|
||||
return { dispose: Effect.void }
|
||||
}),
|
||||
hook: (name, callback) => callback(events[name]).pipe(Effect.orDie, Effect.as({ dispose: Effect.void })),
|
||||
},
|
||||
}),
|
||||
).pipe(Effect.as(event))
|
||||
}
|
||||
|
||||
const object = (properties: Record<string, unknown>, required?: string[]) => ({
|
||||
type: "object" as const,
|
||||
properties,
|
||||
...(required ? { required } : {}),
|
||||
})
|
||||
|
||||
describe("tool input repair plugin", () => {
|
||||
it.effect("preserves valid input identity, nested containers, and unknown properties", () =>
|
||||
Effect.gen(function* () {
|
||||
const nested = { enabled: true }
|
||||
const items = [2, 3]
|
||||
const input = { count: 2, nested, items, extra: "keep" }
|
||||
const event = yield* run(
|
||||
input,
|
||||
object({
|
||||
count: { type: "integer" },
|
||||
nested: object({ enabled: { type: "boolean" } }),
|
||||
items: { type: "array", items: { type: "number" } },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(event.input).toBe(input)
|
||||
expect((event.input as typeof input).nested).toBe(nested)
|
||||
expect((event.input as typeof input).items).toBe(items)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses root objects and repairs nested stringified containers", () =>
|
||||
Effect.gen(function* () {
|
||||
const schema = object({
|
||||
count: { type: "integer" },
|
||||
item: object({ enabled: { type: "boolean" } }),
|
||||
list: { type: "array", items: { type: "integer" } },
|
||||
})
|
||||
|
||||
expect(
|
||||
(yield* run('{"count":"2","item":"{\\"enabled\\":\\"false\\"}","list":"[\\"3\\"]"}', schema)).input,
|
||||
).toEqual({
|
||||
count: 2,
|
||||
item: { enabled: false },
|
||||
list: [3],
|
||||
})
|
||||
expect((yield* run("{broken", schema)).input).toBe("{broken")
|
||||
expect((yield* run("[]", schema)).input).toBe("[]")
|
||||
expect((yield* run(null, schema)).input).toBeNull()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("removes extras only from explicitly closed objects without mutating inputs", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = {
|
||||
known: "2",
|
||||
extra: true,
|
||||
closed: { keep: "3", extra: true },
|
||||
open: { keep: "4", extra: true },
|
||||
items: [{ keep: "5", extra: true }],
|
||||
}
|
||||
const event = yield* run(input, {
|
||||
...object({
|
||||
known: { type: "integer" },
|
||||
closed: { ...object({ keep: { type: "integer" } }), additionalProperties: false },
|
||||
open: object({ keep: { type: "integer" } }),
|
||||
items: {
|
||||
type: "array",
|
||||
items: { ...object({ keep: { type: "integer" } }), additionalProperties: false },
|
||||
},
|
||||
}),
|
||||
additionalProperties: false,
|
||||
})
|
||||
|
||||
expect(event.input).toEqual({
|
||||
known: 2,
|
||||
closed: { keep: 3 },
|
||||
open: { keep: 4, extra: true },
|
||||
items: [{ keep: 5 }],
|
||||
})
|
||||
expect(input.extra).toBeTrue()
|
||||
expect(input.closed.extra).toBeTrue()
|
||||
expect(input.items[0]?.extra).toBeTrue()
|
||||
expect((yield* run({ extra: true }, { ...object({}), additionalProperties: false })).input).toEqual({})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves unknown keys when patterned ownership cannot be determined", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = { known: 1, match: "2", extra: true }
|
||||
const event = yield* run(input, {
|
||||
...object({ known: { type: "integer" } }),
|
||||
additionalProperties: false,
|
||||
patternProperties: { "^match$": { type: "integer" } },
|
||||
})
|
||||
|
||||
expect(event.input).toBe(input)
|
||||
expect((event.input as typeof input).match).toBe("2")
|
||||
expect((event.input as typeof input).extra).toBeTrue()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves properties that may belong to composed object schemas", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = { name: "example", extra: true }
|
||||
|
||||
for (const keyword of ["allOf", "anyOf", "oneOf"]) {
|
||||
const event = yield* run(input, {
|
||||
type: "object",
|
||||
[keyword]: [object({ name: { type: "string" } })],
|
||||
additionalProperties: false,
|
||||
})
|
||||
|
||||
expect(event.input).toBe(input)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("removes only optional nonnullable nulls and non-object empty placeholders", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = {
|
||||
optional: null,
|
||||
required: null,
|
||||
nullable: null,
|
||||
union: null,
|
||||
constant: null,
|
||||
permissive: null,
|
||||
referenced: null,
|
||||
placeholder: {},
|
||||
array: {},
|
||||
requiredPlaceholder: {},
|
||||
object: {},
|
||||
unknown: null,
|
||||
}
|
||||
const event = yield* run(
|
||||
input,
|
||||
object(
|
||||
{
|
||||
optional: { type: "string" },
|
||||
required: { type: "string" },
|
||||
nullable: { type: "string", nullable: true },
|
||||
union: { anyOf: [{ type: "integer" }, { type: "null" }] },
|
||||
constant: { anyOf: [{ type: "integer" }, { const: null }] },
|
||||
permissive: { anyOf: [{ type: "integer" }, true] },
|
||||
referenced: { anyOf: [{ type: "integer" }, { $ref: "#/$defs/nullable" }] },
|
||||
placeholder: { type: "integer" },
|
||||
array: { type: "array", items: { type: "string" } },
|
||||
requiredPlaceholder: { type: "boolean" },
|
||||
object: { type: "object" },
|
||||
unknown: {},
|
||||
},
|
||||
["required", "requiredPlaceholder"],
|
||||
),
|
||||
)
|
||||
|
||||
expect(event.input).toEqual({
|
||||
required: null,
|
||||
nullable: null,
|
||||
union: null,
|
||||
constant: null,
|
||||
permissive: null,
|
||||
referenced: null,
|
||||
requiredPlaceholder: {},
|
||||
object: {},
|
||||
unknown: null,
|
||||
})
|
||||
expect(input.optional).toBeNull()
|
||||
expect(input.placeholder).toEqual({})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves nulls whose validity is hidden inside compositions", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = { wrapped: null, enumerated: null, permissive: null, constant: null }
|
||||
const event = yield* run(
|
||||
input,
|
||||
object({
|
||||
wrapped: { anyOf: [{ type: ["string", "null"] }] },
|
||||
enumerated: { anyOf: [{ enum: [null, "keep"] }] },
|
||||
permissive: { anyOf: [{}] },
|
||||
constant: { type: "string", const: "keep" },
|
||||
}),
|
||||
)
|
||||
expect(event.input).toBe(input)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves optional-looking nulls when the parent composes requirements", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = { value: null }
|
||||
const event = yield* run(input, {
|
||||
...object({ value: { type: "string" } }),
|
||||
allOf: [{ required: ["value"] }],
|
||||
})
|
||||
expect(event.input).toBe(input)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("coerces numeric and boolean strings while preserving invalid and existing values", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = {
|
||||
number: "1.5",
|
||||
integer: "42",
|
||||
enabled: "true",
|
||||
disabled: "false",
|
||||
valid: 3,
|
||||
empty: " ",
|
||||
infinite: "Infinity",
|
||||
fractional: "1.5",
|
||||
unsafe: "9007199254740992",
|
||||
uppercase: "TRUE",
|
||||
}
|
||||
const event = yield* run(
|
||||
input,
|
||||
object({
|
||||
number: { type: "number" },
|
||||
integer: { type: "integer" },
|
||||
enabled: { type: "boolean" },
|
||||
disabled: { type: "boolean" },
|
||||
valid: { type: "integer" },
|
||||
empty: { type: "number" },
|
||||
infinite: { type: "number" },
|
||||
fractional: { type: "integer" },
|
||||
unsafe: { type: "integer" },
|
||||
uppercase: { type: "boolean" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(event.input).toEqual({ ...input, number: 1.5, integer: 42, enabled: true, disabled: false })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("wraps compatible scalars after repairing array items", () =>
|
||||
Effect.gen(function* () {
|
||||
const event = yield* run(
|
||||
{
|
||||
text: "one",
|
||||
integer: "42",
|
||||
boolean: "false",
|
||||
item: '{"count":"2"}',
|
||||
incompatible: 2,
|
||||
fractional: 1.5,
|
||||
unconstrained: "4",
|
||||
},
|
||||
object({
|
||||
text: { type: "array", items: { type: "string" } },
|
||||
integer: { type: "array", items: { type: "integer" } },
|
||||
boolean: { type: "array", items: { type: "boolean" } },
|
||||
item: { type: "array", items: object({ count: { type: "integer" } }) },
|
||||
incompatible: { type: "array", items: { type: "string" } },
|
||||
fractional: { type: "array", items: { type: "integer" } },
|
||||
unconstrained: { type: "array" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(event.input).toEqual({
|
||||
text: ["one"],
|
||||
integer: [42],
|
||||
boolean: [false],
|
||||
item: [{ count: 2 }],
|
||||
incompatible: 2,
|
||||
fractional: 1.5,
|
||||
unconstrained: "4",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("repairs nested question-like inputs without mutating original containers", () =>
|
||||
Effect.gen(function* () {
|
||||
const question = { question: "Pick one", multiple: "false", options: { label: "First", description: null } }
|
||||
const input = { questions: [question] }
|
||||
const event = yield* run(
|
||||
input,
|
||||
object({
|
||||
questions: {
|
||||
type: "array",
|
||||
items: object({
|
||||
question: { type: "string" },
|
||||
multiple: { type: "boolean" },
|
||||
options: {
|
||||
type: "array",
|
||||
items: object({ label: { type: "string" }, description: { type: "string" } }, ["label"]),
|
||||
},
|
||||
}),
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(event.input).toEqual({
|
||||
questions: [{ question: "Pick one", multiple: false, options: [{ label: "First" }] }],
|
||||
})
|
||||
expect(input).toEqual({ questions: [question] })
|
||||
expect(question.options.description).toBeNull()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("repairs unique nullable alternatives while preserving accepted union values", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = {
|
||||
number: "2",
|
||||
boolean: "false",
|
||||
nullable: null,
|
||||
typed: "3",
|
||||
typedBoolean: "true",
|
||||
accepted: "4",
|
||||
valid: 5,
|
||||
}
|
||||
const event = yield* run(
|
||||
input,
|
||||
object({
|
||||
number: { anyOf: [{ type: "number" }, { type: "null" }] },
|
||||
boolean: { oneOf: [{ type: "boolean" }, { type: "null" }] },
|
||||
nullable: { anyOf: [{ type: "number" }, { type: "null" }] },
|
||||
typed: { type: ["integer", "null"] },
|
||||
typedBoolean: { type: ["boolean", "null"] },
|
||||
accepted: { anyOf: [{ type: "string" }, { type: "number" }] },
|
||||
valid: { type: ["number", "null"] },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(event.input).toEqual({ ...input, number: 2, boolean: false, typed: 3, typedBoolean: true })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("repairs tuple positions and rest items while preserving valid array identity", () =>
|
||||
Effect.gen(function* () {
|
||||
const valid = [2, false]
|
||||
const input = { prefix: ["2", "false", "3"], draft: '["4","true"]', valid, scalar: "5" }
|
||||
const event = yield* run(
|
||||
input,
|
||||
object({
|
||||
prefix: {
|
||||
type: "array",
|
||||
prefixItems: [{ type: "integer" }, { type: "boolean" }],
|
||||
items: { type: "number" },
|
||||
},
|
||||
draft: { type: "array", items: [{ type: "integer" }, { type: "boolean" }] },
|
||||
valid: { type: "array", prefixItems: [{ type: "integer" }, { type: "boolean" }] },
|
||||
scalar: { type: "array", prefixItems: [{ type: "integer" }] },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(event.input).toEqual({ prefix: [2, false, 3], draft: [4, true], valid, scalar: "5" })
|
||||
expect((event.input as typeof input).valid).toBe(valid)
|
||||
expect(input.prefix).toEqual(["2", "false", "3"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("repairs typed dictionaries and straightforward local references", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = {
|
||||
modern: "2",
|
||||
legacy: "false",
|
||||
nested: { count: "3" },
|
||||
dictionary: { first: "4" },
|
||||
missing: "5",
|
||||
pointer: "6",
|
||||
escaped: "7",
|
||||
}
|
||||
const event = yield* run(input, {
|
||||
...object({
|
||||
modern: { $ref: "#/$defs/integer" },
|
||||
legacy: { $ref: "#/definitions/boolean" },
|
||||
nested: { $ref: "#/$defs/nested" },
|
||||
dictionary: { type: "object", additionalProperties: { $ref: "#/$defs/integer" } },
|
||||
missing: { $ref: "#/$defs/missing" },
|
||||
pointer: { $ref: "#/$defs/nested/properties/count" },
|
||||
escaped: { $ref: "#/$defs/a~1b~0c" },
|
||||
}),
|
||||
$defs: {
|
||||
integer: { type: "integer" },
|
||||
"a/b~c": { type: "integer" },
|
||||
nested: object({ count: { $ref: "#/$defs/integer" } }),
|
||||
},
|
||||
definitions: { boolean: { type: "boolean" } },
|
||||
})
|
||||
|
||||
expect(event.input).toEqual({
|
||||
modern: 2,
|
||||
legacy: false,
|
||||
nested: { count: 3 },
|
||||
dictionary: { first: 4 },
|
||||
missing: "5",
|
||||
pointer: "6",
|
||||
escaped: 7,
|
||||
})
|
||||
expect(input.nested.count).toBe("3")
|
||||
expect(input.dictionary.first).toBe("4")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("leaves ambiguous unions, compositions, and unsupported roots unchanged", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = { numeric: "2", objects: { value: "3" }, both: "4", composed: "5", unknown: "6" }
|
||||
const event = yield* run(
|
||||
input,
|
||||
object({
|
||||
numeric: { anyOf: [{ type: "number" }, { type: "integer" }] },
|
||||
objects: {
|
||||
oneOf: [
|
||||
object({ value: { type: "integer" } }, ["value"]),
|
||||
object({ value: { type: "number" } }, ["value"]),
|
||||
],
|
||||
},
|
||||
both: { anyOf: [{ type: "integer" }], oneOf: [{ type: "integer" }] },
|
||||
composed: { allOf: [{ type: "integer" }] },
|
||||
unknown: {},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(event.input).toBe(input)
|
||||
expect((yield* run(input, { properties: { numeric: { type: "integer" } } })).input).toBe(input)
|
||||
expect((yield* run(input, { allOf: [object({ numeric: { type: "integer" } })] })).input).toBe(input)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -3,11 +3,9 @@ export * as DesktopCli from "./desktop-cli"
|
||||
import { execFile, spawn } from "node:child_process"
|
||||
import { promisify } from "node:util"
|
||||
import { app } from "electron"
|
||||
import { Context, Effect, FileSystem, Layer, Option, Path } from "effect"
|
||||
import { Context, Effect, FileSystem, Layer, Path } from "effect"
|
||||
import installer from "../../../../../install?raw"
|
||||
import { DesktopPaths } from "../paths"
|
||||
import { BUNDLED_CLI_VERSION_KEY } from "../storage/keys"
|
||||
import { getStore } from "../storage/store"
|
||||
import { parseCliVersion } from "./cli-version"
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
@@ -81,36 +79,11 @@ const resolveBundledCli = Effect.fn("DesktopCli.resolveBundled")(function* (isol
|
||||
? path.join(process.resourcesPath, executableName())
|
||||
: path.join(paths.developmentResourcesRoot, isolated ? developmentExecutableName() : executableName())
|
||||
yield* Effect.logInfo("v2 CLI executable resolved", { bundled, packaged: app.isPackaged })
|
||||
const version = yield* bundledVersion(bundled)
|
||||
const version = parseCliVersion(yield* run(bundled, ["--version"]))
|
||||
const binary = app.isPackaged || isolated ? yield* installCli(bundled, version) : bundled
|
||||
return { version, binary, command: [binary] }
|
||||
})
|
||||
|
||||
// Spawning the bundled executable for `--version` costs ~400 ms of startup on a 200 MB binary, so
|
||||
// the answer is remembered per executable identity and only re-read after an update replaces it.
|
||||
const bundledVersion = Effect.fn("DesktopCli.bundledVersion")(function* (bundled: string) {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const stat = yield* fs.stat(bundled).pipe(Effect.orElseSucceed(() => undefined))
|
||||
const identity = stat ? `${stat.size}:${Option.getOrUndefined(stat.mtime)?.getTime() ?? ""}` : undefined
|
||||
const store = getStore()
|
||||
const cached = store.get(BUNDLED_CLI_VERSION_KEY)
|
||||
if (identity && isVersionCache(cached) && cached.path === bundled && cached.identity === identity) {
|
||||
yield* Effect.logInfo("v2 CLI version reused", { version: cached.version })
|
||||
return cached.version
|
||||
}
|
||||
const version = parseCliVersion(yield* run(bundled, ["--version"]))
|
||||
if (identity) store.set(BUNDLED_CLI_VERSION_KEY, { path: bundled, identity, version } satisfies VersionCache)
|
||||
return version
|
||||
})
|
||||
|
||||
type VersionCache = { path: string; identity: string; version: string }
|
||||
|
||||
function isVersionCache(value: unknown): value is VersionCache {
|
||||
if (!value || typeof value !== "object") return false
|
||||
const cache = value as Record<string, unknown>
|
||||
return typeof cache.path === "string" && typeof cache.identity === "string" && typeof cache.version === "string"
|
||||
}
|
||||
|
||||
export const cleanStages = Effect.fn("DesktopCli.cleanStages")(function* (binary: string) {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const path = yield* Path.Path
|
||||
|
||||
@@ -5,4 +5,3 @@ export const WSL_SERVERS_KEY = "wslServers"
|
||||
export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled"
|
||||
export const BACKGROUND_COLOR_KEY = "backgroundColor"
|
||||
export const WINDOW_IDS_KEY = "windowIds"
|
||||
export const BUNDLED_CLI_VERSION_KEY = "bundledCliVersion"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
+138
-144
@@ -1,4 +1,4 @@
|
||||
import type { HueName, ThemeDocument } from "./schema.js"
|
||||
import type { BaseThemeDefinition, HueName, Mode, ThemeDefinition, ThemeDocument } from "./schema.js"
|
||||
|
||||
export const DEFAULT_CATEGORICAL = [
|
||||
"blue",
|
||||
@@ -9,97 +9,96 @@ export const DEFAULT_CATEGORICAL = [
|
||||
"cyan",
|
||||
] as const satisfies readonly HueName[]
|
||||
|
||||
export const DEFAULT_THEME = {
|
||||
version: 2,
|
||||
const modes = {
|
||||
light: {
|
||||
hue: {
|
||||
gray: {
|
||||
100: "#f3f4f6",
|
||||
200: "#e5e7eb",
|
||||
300: "#d1d5db",
|
||||
400: "#9ca3af",
|
||||
100: "#111827",
|
||||
200: "#1f2937",
|
||||
300: "#374151",
|
||||
400: "#4b5563",
|
||||
500: "#6b7280",
|
||||
600: "#4b5563",
|
||||
700: "#374151",
|
||||
800: "#1f2937",
|
||||
900: "#111827",
|
||||
600: "#9ca3af",
|
||||
700: "#d1d5db",
|
||||
800: "#e5e7eb",
|
||||
900: "#f3f4f6",
|
||||
},
|
||||
red: {
|
||||
100: "#fee2e2",
|
||||
200: "#fecaca",
|
||||
300: "#fca5a5",
|
||||
400: "#f87171",
|
||||
100: "#7f1d1d",
|
||||
200: "#991b1b",
|
||||
300: "#b91c1c",
|
||||
400: "#dc2626",
|
||||
500: "#ef4444",
|
||||
600: "#dc2626",
|
||||
700: "#b91c1c",
|
||||
800: "#991b1b",
|
||||
900: "#7f1d1d",
|
||||
600: "#f87171",
|
||||
700: "#fca5a5",
|
||||
800: "#fecaca",
|
||||
900: "#fee2e2",
|
||||
},
|
||||
orange: {
|
||||
100: "#ffedd5",
|
||||
200: "#fed7aa",
|
||||
300: "#fdba74",
|
||||
400: "#fb923c",
|
||||
100: "#7c2d12",
|
||||
200: "#9a3412",
|
||||
300: "#c2410c",
|
||||
400: "#ea580c",
|
||||
500: "#f97316",
|
||||
600: "#ea580c",
|
||||
700: "#c2410c",
|
||||
800: "#9a3412",
|
||||
900: "#7c2d12",
|
||||
600: "#fb923c",
|
||||
700: "#fdba74",
|
||||
800: "#fed7aa",
|
||||
900: "#ffedd5",
|
||||
},
|
||||
yellow: {
|
||||
100: "#fef9c3",
|
||||
200: "#fef08a",
|
||||
300: "#fde047",
|
||||
400: "#facc15",
|
||||
100: "#713f12",
|
||||
200: "#854d0e",
|
||||
300: "#a16207",
|
||||
400: "#ca8a04",
|
||||
500: "#eab308",
|
||||
600: "#ca8a04",
|
||||
700: "#a16207",
|
||||
800: "#854d0e",
|
||||
900: "#713f12",
|
||||
600: "#facc15",
|
||||
700: "#fde047",
|
||||
800: "#fef08a",
|
||||
900: "#fef9c3",
|
||||
},
|
||||
green: {
|
||||
100: "#dcfce7",
|
||||
200: "#bbf7d0",
|
||||
300: "#86efac",
|
||||
400: "#4ade80",
|
||||
100: "#14532d",
|
||||
200: "#166534",
|
||||
300: "#15803d",
|
||||
400: "#16a34a",
|
||||
500: "#22c55e",
|
||||
600: "#16a34a",
|
||||
700: "#15803d",
|
||||
800: "#166534",
|
||||
900: "#14532d",
|
||||
600: "#4ade80",
|
||||
700: "#86efac",
|
||||
800: "#bbf7d0",
|
||||
900: "#dcfce7",
|
||||
},
|
||||
cyan: {
|
||||
100: "#cffafe",
|
||||
200: "#a5f3fc",
|
||||
300: "#67e8f9",
|
||||
400: "#22d3ee",
|
||||
100: "#164e63",
|
||||
200: "#155e75",
|
||||
300: "#0e7490",
|
||||
400: "#0891b2",
|
||||
500: "#06b6d4",
|
||||
600: "#0891b2",
|
||||
700: "#0e7490",
|
||||
800: "#155e75",
|
||||
900: "#164e63",
|
||||
600: "#22d3ee",
|
||||
700: "#67e8f9",
|
||||
800: "#a5f3fc",
|
||||
900: "#cffafe",
|
||||
},
|
||||
blue: {
|
||||
100: "#dbeafe",
|
||||
200: "#bfdbfe",
|
||||
300: "#93c5fd",
|
||||
400: "#60a5fa",
|
||||
100: "#1e3a8a",
|
||||
200: "#1e40af",
|
||||
300: "#1d4ed8",
|
||||
400: "#2563eb",
|
||||
500: "#3b82f6",
|
||||
600: "#2563eb",
|
||||
700: "#1d4ed8",
|
||||
800: "#1e40af",
|
||||
900: "#1e3a8a",
|
||||
600: "#60a5fa",
|
||||
700: "#93c5fd",
|
||||
800: "#bfdbfe",
|
||||
900: "#dbeafe",
|
||||
},
|
||||
purple: {
|
||||
100: "#f3e8ff",
|
||||
200: "#e9d5ff",
|
||||
300: "#d8b4fe",
|
||||
400: "#c084fc",
|
||||
100: "#581c87",
|
||||
200: "#6b21a8",
|
||||
300: "#7e22ce",
|
||||
400: "#9333ea",
|
||||
500: "#a855f7",
|
||||
600: "#9333ea",
|
||||
700: "#7e22ce",
|
||||
800: "#6b21a8",
|
||||
900: "#581c87",
|
||||
600: "#c084fc",
|
||||
700: "#d8b4fe",
|
||||
800: "#e9d5ff",
|
||||
900: "#f3e8ff",
|
||||
},
|
||||
accent: "$hue.blue",
|
||||
interactive: "$hue.blue",
|
||||
@@ -107,64 +106,64 @@ export const DEFAULT_THEME = {
|
||||
},
|
||||
categorical: DEFAULT_CATEGORICAL,
|
||||
text: {
|
||||
default: "$hue.neutral.800",
|
||||
subdued: "$hue.neutral.600",
|
||||
default: "$hue.neutral.200",
|
||||
subdued: "$hue.neutral.400",
|
||||
action: {
|
||||
primary: { default: "$hue.neutral.200", $disabled: "$hue.neutral.500" },
|
||||
primary: { default: "$hue.neutral.800", $disabled: "$hue.neutral.500" },
|
||||
secondary: { default: "$text.subdued", $hovered: "$text.default" },
|
||||
destructive: { default: "$hue.red.200", $disabled: "$hue.neutral.500" },
|
||||
destructive: { default: "$hue.red.800", $disabled: "$hue.neutral.500" },
|
||||
},
|
||||
formfield: {
|
||||
default: "$hue.neutral.800",
|
||||
default: "$hue.neutral.200",
|
||||
$focused: "$text.action.primary.default",
|
||||
$pressed: "$hue.neutral.200",
|
||||
$pressed: "$hue.neutral.800",
|
||||
$disabled: "$hue.neutral.500",
|
||||
$selected: "$hue.interactive.700",
|
||||
$selected: "$hue.interactive.300",
|
||||
},
|
||||
status: {
|
||||
running: "$hue.interactive.800",
|
||||
running: "$hue.interactive.200",
|
||||
question: "$text.status.unread",
|
||||
permission: "$text.status.unread",
|
||||
unread: "$hue.accent.800",
|
||||
unread: "$hue.accent.200",
|
||||
},
|
||||
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" },
|
||||
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.200",
|
||||
default: "$hue.neutral.800",
|
||||
raised: {
|
||||
base: "$hue.neutral.300",
|
||||
high: "$hue.neutral.400",
|
||||
base: "$hue.neutral.700",
|
||||
high: "$hue.neutral.600",
|
||||
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",
|
||||
default: "$hue.interactive.400",
|
||||
$hovered: "$hue.interactive.300",
|
||||
$focused: "$hue.interactive.300",
|
||||
$pressed: "$hue.interactive.200",
|
||||
$selected: "$hue.interactive.300",
|
||||
$disabled: "$hue.neutral.700",
|
||||
},
|
||||
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",
|
||||
default: "$hue.red.400",
|
||||
$hovered: "$hue.red.300",
|
||||
$focused: "$hue.red.300",
|
||||
$pressed: "$hue.red.200",
|
||||
$selected: "$hue.red.300",
|
||||
$disabled: "$hue.neutral.700",
|
||||
},
|
||||
},
|
||||
formfield: {
|
||||
default: "$background.default",
|
||||
$hovered: "$background.raised.base",
|
||||
$focused: "$background.action.primary.default",
|
||||
$pressed: "$hue.interactive.800",
|
||||
$pressed: "$hue.interactive.200",
|
||||
$disabled: "$background.default",
|
||||
$selected: "$background.formfield.default",
|
||||
},
|
||||
@@ -175,63 +174,56 @@ export const DEFAULT_THEME = {
|
||||
info: { default: "$background.default" },
|
||||
},
|
||||
},
|
||||
border: { default: "$hue.neutral.300" },
|
||||
scrollbar: { default: "$hue.neutral.400" },
|
||||
border: { default: "$hue.neutral.700" },
|
||||
scrollbar: { default: "$hue.neutral.600" },
|
||||
diff: {
|
||||
text: {
|
||||
added: "$hue.green.700",
|
||||
removed: "$hue.red.700",
|
||||
context: "$hue.neutral.900",
|
||||
hunkHeader: "$hue.purple.600",
|
||||
added: "$hue.green.300",
|
||||
removed: "$hue.red.300",
|
||||
context: "$hue.neutral.100",
|
||||
hunkHeader: "$hue.purple.400",
|
||||
},
|
||||
background: { added: "$hue.green.100", removed: "$hue.red.100", context: "$hue.neutral.100" },
|
||||
highlight: { added: "$hue.green.600", removed: "$hue.red.600" },
|
||||
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.600",
|
||||
background: { added: "$hue.green.200", removed: "$hue.red.200" },
|
||||
text: "$hue.neutral.400",
|
||||
background: { added: "$hue.green.800", removed: "$hue.red.800" },
|
||||
},
|
||||
},
|
||||
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",
|
||||
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.600",
|
||||
punctuation: "$hue.neutral.900",
|
||||
operator: "$hue.cyan.400",
|
||||
punctuation: "$hue.neutral.100",
|
||||
},
|
||||
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",
|
||||
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.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",
|
||||
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.100" } } },
|
||||
"@dialog": {
|
||||
text: { action: { primary: { default: "$hue.neutral.900" } } },
|
||||
background: {
|
||||
default: "$background.raised.base",
|
||||
action: { primary: { default: "$hue.interactive.500", $hovered: "$background.raised.high" } },
|
||||
},
|
||||
},
|
||||
"@context:overlay": {
|
||||
text: { action: { primary: { default: "$hue.neutral.100" } } },
|
||||
background: {
|
||||
default: "$background.raised.high",
|
||||
action: { primary: { default: "$hue.interactive.500" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
hue: {
|
||||
@@ -440,19 +432,21 @@ export const DEFAULT_THEME = {
|
||||
imageText: "$hue.cyan.400",
|
||||
codeBlock: "$hue.neutral.100",
|
||||
},
|
||||
"@context:elevated": {
|
||||
"@dialog": {
|
||||
text: { action: { primary: { default: "$hue.neutral.200" } } },
|
||||
background: {
|
||||
default: "$background.raised.base",
|
||||
action: { primary: { default: "$hue.interactive.400", $hovered: "$background.raised.high" } },
|
||||
},
|
||||
},
|
||||
"@context:overlay": {
|
||||
text: { action: { primary: { default: "$hue.neutral.200" } } },
|
||||
background: {
|
||||
default: "$background.raised.high",
|
||||
action: { primary: { default: "$hue.interactive.400" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Record<Mode, ThemeDefinition>
|
||||
|
||||
const { hue: _, ...base } = modes.light
|
||||
|
||||
export const DEFAULT_THEME = {
|
||||
version: 2,
|
||||
base: base satisfies BaseThemeDefinition,
|
||||
light: { hue: modes.light.hue },
|
||||
dark: modes.dark,
|
||||
} satisfies ThemeDocument
|
||||
|
||||
@@ -11,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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,27 +80,23 @@ function tokens(definition: ThemeDefinition): ThemeTokensDefinition {
|
||||
|
||||
function contextualize(base: ThemeTokensDefinition, override: ThemeTokensDefinition) {
|
||||
const result = mergeTheme(base, override)
|
||||
const baseText = base.text?.action
|
||||
const contextText = override.text?.action
|
||||
const baseBackground = base.background?.action
|
||||
const contextBackground = override.background?.action
|
||||
const text = result["text"] as NonNullable<ThemeTokensDefinition["text"]>
|
||||
const background = result["background"] as NonNullable<ThemeTokensDefinition["background"]>
|
||||
return {
|
||||
...result,
|
||||
text: { ...text, action: contextualActions(baseText, contextText) },
|
||||
background: { ...background, action: contextualActions(baseBackground, contextBackground) },
|
||||
text: { ...text, action: contextualActions(base.text?.action, override.text?.action) },
|
||||
background: { ...background, action: contextualActions(base.background?.action, override.background?.action) },
|
||||
} as ThemeTokensDefinition
|
||||
}
|
||||
|
||||
function contextualActions(
|
||||
base: Partial<Record<ActionVariant, StatefulColorDefinition>> | undefined,
|
||||
context: Partial<Record<ActionVariant, StatefulColorDefinition>> | undefined,
|
||||
surface: Partial<Record<ActionVariant, StatefulColorDefinition>> | undefined,
|
||||
) {
|
||||
return Object.fromEntries(
|
||||
ActionVariant.literals.map((variant) => {
|
||||
const baseVariant = base?.[variant]
|
||||
const contextVariant = context?.[variant]
|
||||
const surfaceVariant = surface?.[variant]
|
||||
return [
|
||||
variant,
|
||||
Object.fromEntries(
|
||||
@@ -120,8 +104,8 @@ function contextualActions(
|
||||
const key = state === "default" ? undefined : (`$${state}` as ActionStateKey)
|
||||
return [
|
||||
key ?? "default",
|
||||
(key ? contextVariant?.[key] : undefined) ??
|
||||
contextVariant?.default ??
|
||||
(key ? surfaceVariant?.[key] : undefined) ??
|
||||
surfaceVariant?.default ??
|
||||
(key ? baseVariant?.[key] : undefined) ??
|
||||
baseVariant?.default,
|
||||
]
|
||||
@@ -139,7 +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]) ?? "default"],
|
||||
}
|
||||
}
|
||||
|
||||
function compileHueSteps(
|
||||
|
||||
@@ -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,21 +61,6 @@ 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),
|
||||
$hovered: Schema.optional(ColorValue),
|
||||
@@ -225,44 +210,110 @@ const ThemeTokensDefinition = Schema.Struct({
|
||||
})
|
||||
export type ThemeTokensDefinition = Schema.Schema.Type<typeof ThemeTokensDefinition>
|
||||
|
||||
const CompleteStatefulColorDefinition = Schema.Struct({
|
||||
default: 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({ default: ColorValue, subdued: Schema.optional(ColorValue) })
|
||||
const CompleteBackgroundFeedbackDefinition = Schema.Struct({ default: ColorValue })
|
||||
|
||||
const CompleteThemeTokensDefinition = Schema.Struct({
|
||||
text: Schema.Struct({
|
||||
default: ColorValue,
|
||||
subdued: ColorValue,
|
||||
action: CompleteActionColorDefinition,
|
||||
formfield: CompleteStatefulColorDefinition,
|
||||
status: Schema.Struct({
|
||||
running: ColorValue,
|
||||
question: ColorValue,
|
||||
permission: ColorValue,
|
||||
unread: ColorValue,
|
||||
}),
|
||||
feedback: Schema.Struct({
|
||||
error: CompleteTextFeedbackDefinition,
|
||||
warning: CompleteTextFeedbackDefinition,
|
||||
success: CompleteTextFeedbackDefinition,
|
||||
info: CompleteTextFeedbackDefinition,
|
||||
}),
|
||||
}),
|
||||
background: Schema.Struct({
|
||||
default: 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({ default: ColorValue }),
|
||||
scrollbar: Schema.Struct({ default: 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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
HueAlias,
|
||||
HueStep,
|
||||
MarkdownToken,
|
||||
SurfaceName,
|
||||
SyntaxToken,
|
||||
} from "./schema.js"
|
||||
|
||||
@@ -16,7 +17,10 @@ export type HueScale = Readonly<Record<HueStep, RGBA>>
|
||||
export type Hue = Readonly<Record<BaseHue | HueAlias, HueScale>>
|
||||
export type HueSource = Readonly<{ hue: BaseHue | HueAlias; step: HueStep }>
|
||||
export type Categorical = readonly HueScale[]
|
||||
export type StatefulColor = Readonly<Record<ResolvedActionState, RGBA>>
|
||||
export type ActionStates = Readonly<Partial<Record<ActionState, boolean>>>
|
||||
export type StatefulColor = Readonly<Record<ResolvedActionState, RGBA>> & {
|
||||
readonly state: (states: ActionStates) => RGBA
|
||||
}
|
||||
export type FormfieldColor = StatefulColor
|
||||
|
||||
export type ResolvedThemeTokens = {
|
||||
@@ -69,8 +73,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
|
||||
}
|
||||
|
||||
@@ -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 "./defaults.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,26 @@ 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 { version: 2, base: base(definition), light: { hue: definition.hue } }
|
||||
return { version: 2, 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 +77,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,7 +107,7 @@ 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,
|
||||
@@ -94,6 +130,12 @@ function migrateMode(theme: Theme, mode: Mode): FileThemeDefinition {
|
||||
$disabled: textMuted,
|
||||
$selected: primary,
|
||||
},
|
||||
status: {
|
||||
running: "$hue.interactive.200",
|
||||
question: "$text.status.unread",
|
||||
permission: "$text.status.unread",
|
||||
unread: "$hue.accent.200",
|
||||
},
|
||||
feedback: {
|
||||
error: { default: color("error") },
|
||||
warning: { default: color("warning") },
|
||||
@@ -173,17 +215,16 @@ function migrateMode(theme: Theme, mode: Mode): FileThemeDefinition {
|
||||
imageText: color("markdownImageText"),
|
||||
codeBlock: color("markdownCodeBlock"),
|
||||
},
|
||||
"@context:elevated": {
|
||||
"@dialog": {
|
||||
background: {
|
||||
default: "$background.raised.base",
|
||||
action: { primary: { $hovered: "$background.raised.high" } },
|
||||
},
|
||||
},
|
||||
"@context:overlay": { background: { default: "$background.raised.high" } },
|
||||
})
|
||||
}
|
||||
|
||||
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 +270,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 +288,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 +307,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 +316,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 +385,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 +402,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 +421,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 +429,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 }))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { DEFAULT_THEME, ThemeDocument, migrateV1, resolveThemeDocument, selectTheme } 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) => {
|
||||
@@ -10,52 +10,64 @@ test.each(["light", "dark"] as const)("built-in %s themes resolve status colors"
|
||||
).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.running.equals(theme.hue.interactive[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)
|
||||
expect(theme.text.status.unread.equals(theme.hue.accent[200])).toBeTrue()
|
||||
expect(theme.surface("dialog").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)
|
||||
const base = selectTheme(DEFAULT_THEME, mode)
|
||||
const definition = {
|
||||
...base,
|
||||
hue: { ...base.hue, accent: "$hue.purple" },
|
||||
text: {
|
||||
...base.text,
|
||||
status: { ...base.text.status, unread: "#abcdef" },
|
||||
},
|
||||
}
|
||||
const { hue, ...tokens } = definition
|
||||
const theme = resolveThemeDocument(
|
||||
Schema.decodeUnknownSync(ThemeDocument)({
|
||||
version: 2,
|
||||
base: tokens,
|
||||
[mode]: { hue },
|
||||
}),
|
||||
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.surface("dialog").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()
|
||||
const base = selectTheme(DEFAULT_THEME, mode)
|
||||
const definition = {
|
||||
...base,
|
||||
hue: { ...base.hue, interactive: "$hue.purple", accent: "$hue.orange" },
|
||||
text: {
|
||||
...base.text,
|
||||
status: {
|
||||
...base.text.status,
|
||||
question: "#123456",
|
||||
permission: "#654321",
|
||||
},
|
||||
},
|
||||
}
|
||||
const { hue, ...tokens } = definition
|
||||
const theme = resolveThemeDocument(
|
||||
Schema.decodeUnknownSync(ThemeDocument)({
|
||||
version: 2,
|
||||
base: tokens,
|
||||
[mode]: { hue },
|
||||
}),
|
||||
mode,
|
||||
)
|
||||
expect(theme.text.status.running.equals(theme.hue.purple[200])).toBeTrue()
|
||||
expect(theme.text.status.unread.equals(theme.hue.orange[200])).toBeTrue()
|
||||
expect(theme.text.status.question.equals(RGBA.fromHex("#123456"))).toBeTrue()
|
||||
expect(theme.text.status.permission.equals(RGBA.fromHex("#654321"))).toBeTrue()
|
||||
})
|
||||
|
||||
@@ -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.default)}>
|
||||
<Show when={panel()}>
|
||||
<box
|
||||
position="absolute"
|
||||
@@ -474,7 +474,7 @@ function BarItem(props: ParentProps<{ active: boolean; onClick: () => void }>) {
|
||||
}
|
||||
|
||||
function PanelBox(props: ParentProps) {
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme()
|
||||
const renderer = useRenderer()
|
||||
return (
|
||||
<box
|
||||
@@ -487,7 +487,7 @@ function PanelBox(props: ParentProps) {
|
||||
paddingRight={2}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
backgroundColor={theme.background.default}
|
||||
backgroundColor={theme.background.raised.base}
|
||||
flexDirection="column"
|
||||
onMouseUp={(event) => {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
@@ -500,7 +500,7 @@ function PanelBox(props: ParentProps) {
|
||||
}
|
||||
|
||||
function PanelTitle(props: ParentProps) {
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme()
|
||||
return (
|
||||
<text fg={theme.text.default} attributes={TextAttributes.BOLD} marginBottom={1}>
|
||||
{props.children}
|
||||
@@ -509,7 +509,7 @@ function PanelTitle(props: ParentProps) {
|
||||
}
|
||||
|
||||
function Row(props: { label: string; value: string }) {
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme()
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg={theme.text.subdued}>{props.label}</text>
|
||||
@@ -520,12 +520,12 @@ function Row(props: { label: string; value: string }) {
|
||||
}
|
||||
|
||||
function Action(props: ParentProps<{ onClick: () => void; disabled?: boolean; hoverBackground?: boolean }>) {
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme()
|
||||
const [hovered, setHovered] = createSignal(false)
|
||||
return (
|
||||
<box
|
||||
backgroundColor={
|
||||
props.hoverBackground && hovered() && !props.disabled ? theme.background.action.primary.hovered : undefined
|
||||
props.hoverBackground && hovered() && !props.disabled ? theme.background.raised.high : undefined
|
||||
}
|
||||
onMouseOver={() => setHovered(true)}
|
||||
onMouseOut={() => setHovered(false)}
|
||||
@@ -545,7 +545,7 @@ function cpuPercent(microseconds: number, milliseconds: number) {
|
||||
}
|
||||
|
||||
function ProcessStat(props: { label: string; values: readonly number[]; unit: string; decimals?: number }) {
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme()
|
||||
const value = () => {
|
||||
const value = props.values.at(-1)
|
||||
if (value === undefined) return "--"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CliRenderEvents, TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
|
||||
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"
|
||||
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
import { createSignal, Show } from "solid-js"
|
||||
import { useConfig } from "../config"
|
||||
import { useClipboard } from "../context/clipboard"
|
||||
import { Keymap } from "../context/keymap"
|
||||
@@ -27,35 +27,11 @@ export function DialogErrorDetails(props: {
|
||||
const location = useLocation()
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
const theme = useTheme("elevated")
|
||||
const renderer = useRenderer()
|
||||
const theme = useTheme().surface("dialog")
|
||||
const dimensions = useTerminalDimensions()
|
||||
const config = useConfig().data
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
const [scrollable, setScrollable] = createSignal(false)
|
||||
const [height, setHeight] = createSignal(1)
|
||||
const maxHeight = createMemo(() => Math.max(3, Math.floor(dimensions().height / 2) - 5))
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
let measure: (() => void) | undefined
|
||||
|
||||
createEffect(() => {
|
||||
dimensions()
|
||||
props.error
|
||||
if (measure) renderer.off(CliRenderEvents.FRAME, measure)
|
||||
measure = () => {
|
||||
measure = undefined
|
||||
if (!scroll) return
|
||||
const next = Math.max(1, Math.min(maxHeight(), scroll.scrollHeight))
|
||||
setHeight(next)
|
||||
setScrollable(scroll.scrollHeight > next)
|
||||
}
|
||||
renderer.once(CliRenderEvents.FRAME, measure)
|
||||
renderer.requestRender()
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (measure) renderer.off(CliRenderEvents.FRAME, measure)
|
||||
})
|
||||
|
||||
const copy = () => {
|
||||
void clipboard
|
||||
@@ -86,11 +62,10 @@ export function DialogErrorDetails(props: {
|
||||
}))
|
||||
|
||||
useKeyboard((event) => {
|
||||
if (!scrollable()) return
|
||||
if (event.name === "up") return scroll?.scrollBy(-1)
|
||||
if (event.name === "down") return scroll?.scrollBy(1)
|
||||
if (event.name === "pageup") return scroll?.scrollBy(-maxHeight())
|
||||
if (event.name === "pagedown") return scroll?.scrollBy(maxHeight())
|
||||
if (event.name === "pageup") return scroll?.scrollBy(-20)
|
||||
if (event.name === "pagedown") return scroll?.scrollBy(20)
|
||||
if (event.name === "home") return scroll?.scrollTo(0)
|
||||
if (event.name === "end" && scroll) return scroll.scrollTo(scroll.scrollHeight)
|
||||
})
|
||||
@@ -126,7 +101,8 @@ export function DialogErrorDetails(props: {
|
||||
<box>
|
||||
<scrollbox
|
||||
ref={(element: ScrollBoxRenderable) => (scroll = element)}
|
||||
height={height()}
|
||||
maxHeight={20}
|
||||
contentOptions={{ minHeight: 0 }}
|
||||
scrollbarOptions={{ visible: false }}
|
||||
scrollAcceleration={getScrollAcceleration(config)}
|
||||
>
|
||||
@@ -151,9 +127,7 @@ export function DialogErrorDetails(props: {
|
||||
</span>
|
||||
<span style={{ fg: theme.text.subdued }}>{copied() ? "" : " copy details"}</span>
|
||||
</text>
|
||||
<Show when={scrollable()}>
|
||||
<text fg={theme.text.subdued}>↑/↓ scroll</text>
|
||||
</Show>
|
||||
<text fg={theme.text.subdued}>↑/↓ scroll</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ type ImagePreviewItem = Readonly<{
|
||||
export function DialogImagePreview(props: { images: readonly ImagePreviewItem[]; initial: number }) {
|
||||
const dialog = useDialog()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("dialog")
|
||||
const [index, setIndex] = createSignal(Math.max(0, Math.min(props.images.length - 1, props.initial)))
|
||||
const [failed, setFailed] = createSignal(false)
|
||||
const current = createMemo(() => props.images[index()])
|
||||
|
||||
@@ -78,7 +78,7 @@ export function DialogIntegration(
|
||||
const data = useData()
|
||||
const currentLocation = useLocation()
|
||||
const dialog = useDialog()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("dialog")
|
||||
const location = currentLocation.ref ?? data.location.default()
|
||||
const integrations = createMemo(() =>
|
||||
integrationOptions(data.location.integration.list(location) ?? []).filter(
|
||||
@@ -153,7 +153,7 @@ function manageConnections(
|
||||
const data = useData()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("dialog")
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
const [deleting, setDeleting] = createSignal<string>()
|
||||
const [selected, setSelected] = createSignal(methods.length ? "add" : credentialConnections(integration)[0]?.id)
|
||||
@@ -427,8 +427,8 @@ function CommandPending(props: {
|
||||
|
||||
function CommandView(props: { title: string; output: string; message: string }) {
|
||||
const dialog = useDialog()
|
||||
const theme = useTheme("elevated")
|
||||
const overlayTheme = useTheme("overlay")
|
||||
const theme = useTheme().surface("dialog")
|
||||
const overlayTheme = useTheme()
|
||||
onMount(() => dialog.setSize("large"))
|
||||
return (
|
||||
<box gap={1} paddingBottom={1}>
|
||||
@@ -441,7 +441,7 @@ function CommandView(props: { title: string; output: string; message: string })
|
||||
</text>
|
||||
</box>
|
||||
<box
|
||||
backgroundColor={overlayTheme.background.default}
|
||||
backgroundColor={overlayTheme.background.raised.high}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
paddingTop={1}
|
||||
@@ -467,7 +467,7 @@ function KeyMethod(props: {
|
||||
const dialog = useDialog()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("dialog")
|
||||
const [error, setError] = createSignal<string>()
|
||||
|
||||
return (
|
||||
@@ -672,7 +672,7 @@ function OAuthCode(props: {
|
||||
const dialog = useDialog()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("dialog")
|
||||
const [error, setError] = createSignal<string>()
|
||||
let settled = false
|
||||
|
||||
@@ -724,7 +724,7 @@ function OAuthView(props: {
|
||||
open?: boolean
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("dialog")
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
@@ -852,7 +852,7 @@ function textAnswer(
|
||||
return new Promise<FormValue | undefined | typeof CANCELLED>((resolve) => {
|
||||
dialog.replace(
|
||||
() => {
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("dialog")
|
||||
const [error, setError] = createSignal<string>()
|
||||
return (
|
||||
<DialogPrompt
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -6,7 +6,7 @@ import { For, Match, Switch, Show, createMemo } from "solid-js"
|
||||
|
||||
export function DialogStatus() {
|
||||
const data = useData()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("dialog")
|
||||
const dialog = useDialog()
|
||||
|
||||
const mcp = createMemo(() => data.location.mcp.server.list() ?? [])
|
||||
|
||||
@@ -15,7 +15,7 @@ export function DialogUpdate(props: {
|
||||
restart: () => void
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("dialog")
|
||||
const [error, setError] = createSignal<string>()
|
||||
const [active, setActive] = createSignal(0)
|
||||
const controller = new AbortController()
|
||||
|
||||
@@ -31,8 +31,8 @@ export function DialogWorkspaceFileChanges(props: {
|
||||
message?: string
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const theme = useTheme("elevated")
|
||||
const overlayTheme = useTheme("overlay")
|
||||
const theme = useTheme().surface("dialog")
|
||||
const overlayTheme = useTheme()
|
||||
const config = useConfig().data
|
||||
const dimensions = useTerminalDimensions()
|
||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
|
||||
@@ -86,7 +86,7 @@ export function DialogWorkspaceFileChanges(props: {
|
||||
</box>
|
||||
<scrollbox
|
||||
height={height()}
|
||||
backgroundColor={overlayTheme.background.default}
|
||||
backgroundColor={overlayTheme.background.raised.high}
|
||||
scrollbarOptions={{ visible: false }}
|
||||
scrollAcceleration={scrollAcceleration()}
|
||||
>
|
||||
|
||||
@@ -40,7 +40,7 @@ export function DialogWorkspaces(props: DialogWorkspacesProps) {
|
||||
const dialog = useDialog()
|
||||
const client = useClient()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("dialog")
|
||||
const sessionData = useData()
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useConfig } from "../config"
|
||||
|
||||
export function DialogWorktreeName(props: { onConfirm: (name: string) => void }) {
|
||||
const dialog = useDialog()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("dialog")
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
const config = useConfig().data
|
||||
const [inputTarget, setInputTarget] = createSignal<InputRenderable>()
|
||||
|
||||
@@ -10,7 +10,7 @@ type Progress = { label: string; numerator?: number; denominator?: number }
|
||||
export function MigrationOverlay() {
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const theme = useTheme("overlay")
|
||||
const theme = useTheme()
|
||||
const [progress, setProgress] = createSignal<Progress>()
|
||||
const abort = new AbortController()
|
||||
|
||||
@@ -52,7 +52,7 @@ export function MigrationOverlay() {
|
||||
top={1}
|
||||
right={2}
|
||||
flexDirection="row"
|
||||
backgroundColor={theme.background.default}
|
||||
backgroundColor={theme.background.raised.high}
|
||||
border={["left"]}
|
||||
borderColor={theme.text.feedback.info.default}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { BoxRenderable } from "@opentui/core"
|
||||
import { onCleanup, onMount } from "solid-js"
|
||||
import { usePanel, type PanelTarget } from "../context/panel"
|
||||
import { InteractivityProvider } from "../context/interactivity"
|
||||
import { ThemeContextProvider, useTheme } from "../context/theme"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { Slot } from "../plugin/render"
|
||||
|
||||
export function PanelHost(props: {
|
||||
@@ -19,6 +19,9 @@ export function PanelHost(props: {
|
||||
|
||||
const Content = () => {
|
||||
const theme = useTheme()
|
||||
// Side panels sit on a raised surface; fullscreen takes over the base background.
|
||||
const background = () =>
|
||||
panels.presentation() === "panel" ? theme.background.raised.base : theme.background.default
|
||||
return (
|
||||
<box
|
||||
id="session-panel"
|
||||
@@ -27,7 +30,7 @@ export function PanelHost(props: {
|
||||
minWidth={0}
|
||||
minHeight={0}
|
||||
focusable
|
||||
backgroundColor={theme.background.default}
|
||||
backgroundColor={background()}
|
||||
onMouseDown={props.onFocus}
|
||||
>
|
||||
<Slot
|
||||
@@ -55,9 +58,7 @@ export function PanelHost(props: {
|
||||
|
||||
return (
|
||||
<InteractivityProvider enabled={props.focused}>
|
||||
<ThemeContextProvider context={panels.presentation() === "panel" ? "elevated" : undefined}>
|
||||
<Content />
|
||||
</ThemeContextProvider>
|
||||
<Content />
|
||||
</InteractivityProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ export function Autocomplete(props: {
|
||||
const data = useData()
|
||||
const keymap = Keymap.use()
|
||||
const keymapCommands = Keymap.useCommands()
|
||||
const theme = useTheme("overlay")
|
||||
const theme = useTheme()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const frecency = useFrecency()
|
||||
const config = useConfig().data
|
||||
@@ -887,7 +887,7 @@ export function Autocomplete(props: {
|
||||
scroll = r
|
||||
scroll.verticalScrollBar.on("change", syncSelectionWindow)
|
||||
}}
|
||||
backgroundColor={theme.background.default}
|
||||
backgroundColor={theme.background.raised.high}
|
||||
height={height()}
|
||||
scrollbarOptions={{ visible: false }}
|
||||
scrollAcceleration={scrollAcceleration()}
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTheme } from "../context/theme"
|
||||
import { Spinner } from "./spinner"
|
||||
|
||||
export function Reconnecting(props: { managed?: boolean }) {
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme()
|
||||
|
||||
return (
|
||||
<box
|
||||
@@ -21,7 +21,7 @@ export function Reconnecting(props: { managed?: boolean }) {
|
||||
width={48}
|
||||
maxWidth="90%"
|
||||
flexDirection="column"
|
||||
backgroundColor={theme.background.default}
|
||||
backgroundColor={theme.background.raised.base}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
|
||||
@@ -10,29 +10,29 @@ export function SessionTabsRailControls(props: {
|
||||
tabs: SessionTabsController
|
||||
belowHighlighted: boolean
|
||||
}) {
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme()
|
||||
const keymap = Keymap.use()
|
||||
const [hovered, setHovered] = createSignal(false)
|
||||
const hoverColor = createMemo(() =>
|
||||
tint(theme.background.default, theme.background.action.primary.hovered, theme.background.action.primary.hovered.a),
|
||||
tint(theme.background.raised.base, theme.background.raised.high, theme.background.raised.high.a),
|
||||
)
|
||||
let pressed = false
|
||||
const search = () => (props.tabs.search ? props.tabs.search() : keymap.dispatch("session.list"))
|
||||
return (
|
||||
<box height={1} position="relative" flexShrink={0} backgroundColor={theme.background.default}>
|
||||
<box height={1} position="relative" flexShrink={0} backgroundColor={theme.background.raised.base}>
|
||||
<SessionTabHalfRow
|
||||
top={-1}
|
||||
edge="top"
|
||||
width={props.width}
|
||||
color={hovered() ? hoverColor() : theme.background.default}
|
||||
background={theme.background.default}
|
||||
color={hovered() ? hoverColor() : theme.background.raised.base}
|
||||
background={theme.background.raised.base}
|
||||
/>
|
||||
<box
|
||||
height={1}
|
||||
position="relative"
|
||||
flexDirection="row"
|
||||
justifyContent="center"
|
||||
backgroundColor={hovered() ? theme.background.action.primary.hovered : undefined}
|
||||
backgroundColor={hovered() ? theme.background.raised.high : undefined}
|
||||
onMouseOver={() => setHovered(true)}
|
||||
onMouseOut={() => setHovered(false)}
|
||||
onMouseDown={(event) => {
|
||||
@@ -59,7 +59,7 @@ export function SessionTabsRailControls(props: {
|
||||
height={1}
|
||||
zIndex={2}
|
||||
fg={hoverColor()}
|
||||
bg={hovered() && props.belowHighlighted ? hoverColor() : theme.background.default}
|
||||
bg={hovered() && props.belowHighlighted ? hoverColor() : theme.background.raised.base}
|
||||
selectable={false}
|
||||
>
|
||||
{(hovered() ? "▀" : props.belowHighlighted ? "▄" : " ").repeat(props.width)}
|
||||
|
||||
@@ -366,7 +366,9 @@ export function createTabMarquee(animations: () => boolean) {
|
||||
|
||||
function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsController; onClose: () => void }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme()
|
||||
const background = () => theme.background.raised.base
|
||||
const actionHovered = () => theme.background.raised.high
|
||||
const dialog = useDialog()
|
||||
onCleanup(Keymap.use().mode.push("menu"))
|
||||
Keymap.createLayer(() => ({
|
||||
@@ -430,7 +432,7 @@ function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsCo
|
||||
height={actions().length}
|
||||
width={CONTEXT_MENU_WIDTH}
|
||||
flexDirection="column"
|
||||
backgroundColor={theme.background.default}
|
||||
backgroundColor={background()}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) props.onClose()
|
||||
event.preventDefault()
|
||||
@@ -443,7 +445,7 @@ function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsCo
|
||||
width="100%"
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={selected() === index() ? theme.background.action.primary.hovered : undefined}
|
||||
backgroundColor={selected() === index() ? actionHovered() : undefined}
|
||||
onMouseOver={() => setSelected(index())}
|
||||
onMouseOut={() => setSelected(undefined)}
|
||||
onMouseUp={(event) => {
|
||||
@@ -515,7 +517,10 @@ function VerticalSessionTabs(props: {
|
||||
const data = props.controller ? undefined : useData()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const renderer = useRenderer()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme()
|
||||
const background = () => theme.background.raised.base
|
||||
const actionSelected = () => theme.background.action.primary.selected
|
||||
const actionHovered = () => theme.background.raised.high
|
||||
const base = useTheme()
|
||||
const config = useConfig().data
|
||||
const animations = () => props.animations ?? config.animations ?? true
|
||||
@@ -525,9 +530,9 @@ function VerticalSessionTabs(props: {
|
||||
const stride = () => (compact() ? 2 : 3)
|
||||
const unreadColor = () => theme.text.status.unread
|
||||
const activeNumber = () => theme.text.status.running
|
||||
const idleNumber = () => tint(theme.text.formfield.default, theme.background.default, 0.55)
|
||||
const separatorUpperPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.04))
|
||||
const separatorLowerPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.05))
|
||||
const idleNumber = () => tint(theme.text.formfield.default, background(), 0.55)
|
||||
const separatorUpperPulseColor = createMemo(() => tint(background(), theme.text.default, 0.04))
|
||||
const separatorLowerPulseColor = createMemo(() => tint(background(), theme.text.default, 0.05))
|
||||
const [addHovered, setAddHovered] = createSignal(false)
|
||||
const marquee = createTabMarquee(animations)
|
||||
const hovered = marquee.hovered
|
||||
@@ -564,7 +569,7 @@ function VerticalSessionTabs(props: {
|
||||
})
|
||||
const items = ordered
|
||||
const highlightColor = createMemo(() =>
|
||||
tint(theme.background.default, theme.background.action.primary.hovered, theme.background.action.primary.hovered.a),
|
||||
tint(background(), actionHovered(), actionHovered().a),
|
||||
)
|
||||
const highlighted = (sessionID: string | undefined) =>
|
||||
sessionID !== undefined && (activeID() === sessionID || hovered() === sessionID || dragging() === sessionID)
|
||||
@@ -671,7 +676,7 @@ function VerticalSessionTabs(props: {
|
||||
flexDirection="column"
|
||||
position="relative"
|
||||
paddingTop={1}
|
||||
backgroundColor={theme.background.default}
|
||||
backgroundColor={background()}
|
||||
onMouseOut={marquee.leaveHovered}
|
||||
onMouseUp={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) return
|
||||
@@ -694,7 +699,7 @@ function VerticalSessionTabs(props: {
|
||||
}}
|
||||
flexGrow={1}
|
||||
minHeight={0}
|
||||
backgroundColor={theme.background.default}
|
||||
backgroundColor={background()}
|
||||
scrollbarOptions={{ visible: false }}
|
||||
>
|
||||
<box flexShrink={0} flexDirection="column" gap={1} paddingY={compact() ? 1 : 0}>
|
||||
@@ -732,13 +737,13 @@ function VerticalSessionTabs(props: {
|
||||
const detailFades = createMemo(
|
||||
() => marqueeOverflows(tabDetail(), titleWidth()) && titleWidth() > FADE_WIDTH,
|
||||
)
|
||||
const background = createMemo(() => {
|
||||
if (selected() && !compact()) return theme.background.action.primary.selected
|
||||
const tabBackground = createMemo(() => {
|
||||
if (selected() && !compact()) return actionSelected()
|
||||
if ((compact() && selected()) || hovered() === tab.sessionID || dragging() === tab.sessionID)
|
||||
return theme.background.action.primary.hovered
|
||||
return theme.background.default
|
||||
return actionHovered()
|
||||
return background()
|
||||
})
|
||||
const pulseBackground = createMemo(() => tint(theme.background.default, background(), background().a))
|
||||
const pulseBackground = createMemo(() => tint(background(), tabBackground(), tabBackground().a))
|
||||
const runs = () => status().runs
|
||||
const numberIgnition = createNumberIgnition(runs, () => status().promptPulse, animations)
|
||||
const numberColor = () => {
|
||||
@@ -815,10 +820,10 @@ function VerticalSessionTabs(props: {
|
||||
return lastPreviousGlowHue ?? unreadColor()
|
||||
}
|
||||
const separatorUpperColor = createMemo(() =>
|
||||
tint(theme.background.default, previousGlowHue(), 0.1 * previousGlowLevel()),
|
||||
tint(background(), previousGlowHue(), 0.1 * previousGlowLevel()),
|
||||
)
|
||||
const separatorLowerColor = createMemo(() =>
|
||||
tint(theme.background.default, glowHue(), 0.12 * glowLevel()),
|
||||
tint(background(), glowHue(), 0.12 * glowLevel()),
|
||||
)
|
||||
const titleColor = (index: number, separator: boolean) => {
|
||||
const level = titleGlow.value().level
|
||||
@@ -843,7 +848,7 @@ function VerticalSessionTabs(props: {
|
||||
width="100%"
|
||||
position="relative"
|
||||
flexDirection="column"
|
||||
backgroundColor={background()}
|
||||
backgroundColor={tabBackground()}
|
||||
onMouseOver={(event) => {
|
||||
setHoverY(event.y)
|
||||
marquee.enter(tab.sessionID, title(), compact() ? Infinity : hoveredTitleWidth())
|
||||
@@ -885,7 +890,7 @@ function VerticalSessionTabs(props: {
|
||||
width={width()}
|
||||
color={pulseBackground()}
|
||||
background={
|
||||
highlighted(items()[index() - 1]?.sessionID) ? highlightColor() : theme.background.default
|
||||
highlighted(items()[index() - 1]?.sessionID) ? highlightColor() : background()
|
||||
}
|
||||
/>
|
||||
<SessionTabHalfRow
|
||||
@@ -900,7 +905,7 @@ function VerticalSessionTabs(props: {
|
||||
: highlighted(items()[index() + 1]?.sessionID)
|
||||
)
|
||||
? highlightColor()
|
||||
: theme.background.default
|
||||
: background()
|
||||
}
|
||||
/>
|
||||
</Show>
|
||||
@@ -946,8 +951,8 @@ function VerticalSessionTabs(props: {
|
||||
color={separatorLowerPulseColor()}
|
||||
width={indicatorWidth}
|
||||
outerColor={separatorUpperPulseColor()}
|
||||
flashColor={tint(theme.background.default, theme.text.default, 0.22)}
|
||||
outerFlashColor={tint(theme.background.default, theme.text.default, 0.18)}
|
||||
flashColor={tint(background(), theme.text.default, 0.22)}
|
||||
outerFlashColor={tint(background(), theme.text.default, 0.18)}
|
||||
flashTail={8}
|
||||
glowColor={separatorLowerColor()}
|
||||
outerGlowColor={separatorUpperColor()}
|
||||
@@ -955,7 +960,7 @@ function VerticalSessionTabs(props: {
|
||||
outerGlowTail={5}
|
||||
completionColor={separatorLowerColor()}
|
||||
outerCompletionColor={separatorUpperColor()}
|
||||
backgroundColor={theme.background.default}
|
||||
backgroundColor={background()}
|
||||
/>
|
||||
<Show when={index() === items().length - 1}>
|
||||
<TabPulse
|
||||
@@ -970,18 +975,18 @@ function VerticalSessionTabs(props: {
|
||||
outerComplete={false}
|
||||
glow={glows()}
|
||||
outerGlow={false}
|
||||
color={tint(theme.background.default, theme.text.default, 0.04)}
|
||||
color={tint(background(), theme.text.default, 0.04)}
|
||||
width={indicatorWidth}
|
||||
outerColor={tint(theme.background.default, theme.text.default, 0.006)}
|
||||
flashColor={tint(theme.background.default, theme.text.default, 0.18)}
|
||||
outerColor={tint(background(), theme.text.default, 0.006)}
|
||||
flashColor={tint(background(), theme.text.default, 0.18)}
|
||||
flashTail={8}
|
||||
glowColor={tint(theme.background.default, glowHue(), 0.1 * glowLevel())}
|
||||
outerGlowColor={theme.background.default}
|
||||
glowColor={tint(background(), glowHue(), 0.1 * glowLevel())}
|
||||
outerGlowColor={background()}
|
||||
glowTail={8}
|
||||
outerGlowTail={5}
|
||||
completionColor={tint(theme.background.default, glowHue(), 0.1 * glowLevel())}
|
||||
outerCompletionColor={theme.background.default}
|
||||
backgroundColor={theme.background.default}
|
||||
completionColor={tint(background(), glowHue(), 0.1 * glowLevel())}
|
||||
outerCompletionColor={background()}
|
||||
backgroundColor={background()}
|
||||
/>
|
||||
</Show>
|
||||
<box height={1} width="100%" flexDirection="row" position="relative">
|
||||
@@ -1113,10 +1118,10 @@ function VerticalSessionTabs(props: {
|
||||
alignItems="center"
|
||||
backgroundColor={
|
||||
newTab() && !compact()
|
||||
? theme.background.action.primary.selected
|
||||
? actionSelected()
|
||||
: addHovered() || (compact() && newTab())
|
||||
? theme.background.action.primary.hovered
|
||||
: theme.background.default
|
||||
? actionHovered()
|
||||
: background()
|
||||
}
|
||||
onMouseOver={() => setAddHovered(true)}
|
||||
onMouseOut={() => setAddHovered(false)}
|
||||
@@ -1145,14 +1150,14 @@ function VerticalSessionTabs(props: {
|
||||
edge="top"
|
||||
width={width()}
|
||||
color={highlightColor()}
|
||||
background={highlighted(items().at(-1)?.sessionID) ? highlightColor() : theme.background.default}
|
||||
background={highlighted(items().at(-1)?.sessionID) ? highlightColor() : background()}
|
||||
/>
|
||||
<SessionTabHalfRow
|
||||
top={1}
|
||||
edge="bottom"
|
||||
width={width()}
|
||||
color={highlightColor()}
|
||||
background={theme.background.default}
|
||||
background={background()}
|
||||
/>
|
||||
</Show>
|
||||
<text
|
||||
@@ -1211,10 +1216,10 @@ function VerticalSessionTabs(props: {
|
||||
top={0}
|
||||
edge="top"
|
||||
width={tooltipWidth()}
|
||||
color={theme.background.default}
|
||||
color={background()}
|
||||
background={base.background.default}
|
||||
/>
|
||||
<box height={2} paddingX={1} backgroundColor={theme.background.default}>
|
||||
<box height={2} paddingX={1} backgroundColor={background()}>
|
||||
<text fg={theme.text.default} wrapMode="none" selectable={false}>
|
||||
{Locale.truncateWidth(
|
||||
data?.session.get(sessionID())?.title ??
|
||||
@@ -1231,7 +1236,7 @@ function VerticalSessionTabs(props: {
|
||||
top={3}
|
||||
edge="bottom"
|
||||
width={tooltipWidth()}
|
||||
color={theme.background.default}
|
||||
color={background()}
|
||||
background={base.background.default}
|
||||
/>
|
||||
</box>
|
||||
@@ -1530,7 +1535,7 @@ function HorizontalSessionTabs(props: {
|
||||
const lifted = (hovered() === tab.sessionID || dragged()) && !selected()
|
||||
const base = lifted ? theme.background.action.primary.hovered : theme.background.default
|
||||
// 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)
|
||||
// The edge flash washes toward a brighter stop on the same background-to-text ramp,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTheme } from "../context/theme"
|
||||
import { Spinner } from "./spinner"
|
||||
|
||||
export function StartupLoading(props: { ready: () => boolean }) {
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme()
|
||||
const [show, setShow] = createSignal(false)
|
||||
const text = createMemo(() => (props.ready() ? "Finishing startup…" : "Loading plugins…"))
|
||||
let wait: NodeJS.Timeout | undefined
|
||||
@@ -54,7 +54,7 @@ export function StartupLoading(props: { ready: () => boolean }) {
|
||||
return (
|
||||
<Show when={show()}>
|
||||
<box position="absolute" zIndex={5000} left={0} right={0} bottom={1} justifyContent="center" alignItems="center">
|
||||
<box backgroundColor={theme.background.default} paddingLeft={1} paddingRight={1}>
|
||||
<box backgroundColor={theme.background.raised.base} paddingLeft={1} paddingRight={1}>
|
||||
<Spinner color={theme.text.subdued}>{text()}</Spinner>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
@@ -32,7 +32,7 @@ export function TerminalPane(props: {
|
||||
const client = useClient()
|
||||
const keymap = Keymap.use()
|
||||
const leader = Keymap.useLeaderActive()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme()
|
||||
const themes = useThemes()
|
||||
const renderer = useRenderer()
|
||||
const [failure, setFailure] = createSignal<string>()
|
||||
@@ -155,8 +155,8 @@ export function TerminalPane(props: {
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const tokens = themes.currentTokens().contextual.elevated
|
||||
terminalTheme = terminalPalette(tokens, themes.mode(), tokens.background.default)
|
||||
const tokens = themes.currentTokens()
|
||||
terminalTheme = terminalPalette(tokens, tokens.background.raised.base)
|
||||
applyTerminalTheme()
|
||||
})
|
||||
|
||||
@@ -284,7 +284,7 @@ export function TerminalPane(props: {
|
||||
minWidth={0}
|
||||
minHeight={0}
|
||||
overflow="hidden"
|
||||
backgroundColor={themes.currentTokens().contextual.elevated.background.default}
|
||||
backgroundColor={themes.currentTokens().background.raised.base}
|
||||
onSizeChange={function () {
|
||||
size = { cols: Math.max(1, this.width - 2), rows: this.height }
|
||||
if (controller && restored) interact()
|
||||
@@ -335,9 +335,9 @@ function sameSize(first: TerminalSize | undefined, second: TerminalSize | undefi
|
||||
return !!first && !!second && first.cols === second.cols && first.rows === second.rows
|
||||
}
|
||||
|
||||
function terminalPalette(theme: ResolvedThemeTokens, mode: "dark" | "light", background: RGBA) {
|
||||
const base = mode === "dark" ? 200 : 800
|
||||
const bright = mode === "dark" ? 100 : 900
|
||||
function terminalPalette(theme: ResolvedThemeTokens, background: RGBA) {
|
||||
const base = 200
|
||||
const bright = 100
|
||||
const colors = [
|
||||
background,
|
||||
theme.text.feedback.error.default,
|
||||
@@ -354,7 +354,7 @@ function terminalPalette(theme: ResolvedThemeTokens, mode: "dark" | "light", bac
|
||||
theme.hue.blue[bright],
|
||||
theme.hue.purple[bright],
|
||||
theme.hue.cyan[bright],
|
||||
theme.hue.neutral[mode === "dark" ? 100 : 900],
|
||||
theme.hue.neutral[100],
|
||||
]
|
||||
return Buffer.from(
|
||||
colors
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
type ModelPreference,
|
||||
type ModelPreferenceModel,
|
||||
} from "../model-preference"
|
||||
import { useTheme, useThemes } from "./theme"
|
||||
import { useTheme } from "./theme"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { useRoute } from "./route"
|
||||
import { useData } from "./data"
|
||||
@@ -32,7 +32,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const data = useData()
|
||||
const toast = useToast()
|
||||
const theme = useTheme()
|
||||
const { mode } = useThemes()
|
||||
const route = useRoute()
|
||||
const paths = useTuiPaths()
|
||||
const args = useArgs()
|
||||
@@ -74,7 +73,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
}),
|
||||
)
|
||||
const colors = createMemo(() => {
|
||||
const step = mode() === "light" ? 800 : 200
|
||||
const step = 200
|
||||
return dedupeWith(
|
||||
theme.categorical.map((scale) => scale[step]),
|
||||
(first, second) => first.equals(second),
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
resolveThemeDocument,
|
||||
themeModes,
|
||||
type ResolvedTheme,
|
||||
type ContextName,
|
||||
type SurfaceName,
|
||||
} from "@opencode/theme/tui"
|
||||
import {
|
||||
DEFAULT_THEMES,
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
} from "../theme"
|
||||
import { generateSystem, terminalMode } from "../theme/system"
|
||||
import { discoverThemes } from "../theme/discovery"
|
||||
import { createComponentTheme, createComponentThemeView, type ComponentTheme } from "../theme/component"
|
||||
import { createComponentTheme, type ComponentTheme } from "../theme/component"
|
||||
import { createEffect, createMemo, createSignal, onCleanup, onMount, type Accessor, type ParentProps } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
@@ -122,7 +122,7 @@ type Themes = {
|
||||
}
|
||||
|
||||
type ThemeContextValue = {
|
||||
current: ComponentTheme["contextual"][ContextName]
|
||||
current: ComponentTheme
|
||||
themes: Themes
|
||||
readonly ready: boolean
|
||||
}
|
||||
@@ -322,11 +322,11 @@ const themeContext = createSimpleContext({
|
||||
const tokens = () => selected().theme
|
||||
tokens()
|
||||
themePerformance.set("Init", `${(performance.now() - initStarted).toFixed(2)} ms`)
|
||||
const current = createComponentTheme(tokens, mode)
|
||||
const current = createComponentTheme(tokens)
|
||||
|
||||
createEffect(() => renderer.setBackgroundColor(tokens().background.default))
|
||||
|
||||
const currentSyntax = createSyntaxStyleMemo(() => generateSyntax(tokens(), mode()))
|
||||
const currentSyntax = createSyntaxStyleMemo(() => generateSyntax(tokens()))
|
||||
const service: Themes = {
|
||||
current,
|
||||
currentTokens: tokens,
|
||||
@@ -377,27 +377,18 @@ const themeContext = createSimpleContext({
|
||||
export function useThemes() {
|
||||
return themeContext.use().themes
|
||||
}
|
||||
export function useTheme(): ComponentTheme
|
||||
export function useTheme(context: ContextName): ComponentTheme["contextual"][ContextName]
|
||||
export function useTheme(context?: ContextName) {
|
||||
const value = themeContext.use()
|
||||
return context ? value.themes.current.contextual[context] : value.current
|
||||
export function useTheme(): ComponentTheme {
|
||||
return themeContext.use().current
|
||||
}
|
||||
export const ThemeProvider = themeContext.provider
|
||||
|
||||
function usablePalette(colors: TerminalColors | undefined): colors is TerminalColors {
|
||||
return Boolean(
|
||||
colors && (colors.defaultBackground ?? colors.palette[0]) && (colors.defaultForeground ?? colors.palette[7]),
|
||||
)
|
||||
}
|
||||
|
||||
/** Switches context without remounting children; undefined inherits the enclosing view. */
|
||||
export function ThemeContextProvider(props: ParentProps<{ context: ContextName | undefined }>) {
|
||||
/** Switches the ambient theme surface without remounting children; undefined inherits the enclosing view. */
|
||||
export function ThemeContextProvider(props: ParentProps<{ context: SurfaceName | undefined }>) {
|
||||
const value = themeContext.use()
|
||||
const current = createComponentThemeView(() => {
|
||||
const current = createComponentTheme(() => {
|
||||
const name = props.context
|
||||
return name ? value.themes.currentTokens().contextual[name] : value.current
|
||||
}, value.themes.mode)
|
||||
return name ? value.themes.currentTokens().surface(name) : value.current
|
||||
})
|
||||
return (
|
||||
<themeContext.context.Provider value={{ current, themes: value.themes, ready: value.ready }}>
|
||||
{props.children}
|
||||
@@ -405,6 +396,12 @@ export function ThemeContextProvider(props: ParentProps<{ context: ContextName |
|
||||
)
|
||||
}
|
||||
|
||||
function usablePalette(colors: TerminalColors | undefined): colors is TerminalColors {
|
||||
return Boolean(
|
||||
colors && (colors.defaultBackground ?? colors.palette[0]) && (colors.defaultForeground ?? colors.palette[7]),
|
||||
)
|
||||
}
|
||||
|
||||
function loadTheme(source: ThemeDocumentSource, name: string, requested: "dark" | "light") {
|
||||
const document = parseTheme(source, name)
|
||||
const modes = themeModes(document)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Plugin } from "@opencode/plugin/tui"
|
||||
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
import { createMemo, createSignal, Show } from "solid-js"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { createSignal, Show } from "solid-js"
|
||||
import { Spinner } from "../../component/spinner"
|
||||
import { useConfig } from "../../config"
|
||||
import { useClipboard } from "../../context/clipboard"
|
||||
@@ -43,6 +43,8 @@ export default Plugin.define({
|
||||
append: "app",
|
||||
render() {
|
||||
const toast = useToast()
|
||||
// Dialogs render beside PluginProvider, so Answer cannot call usePlugin().
|
||||
const plugins = usePlugin()
|
||||
context.keymap.layer(() => ({
|
||||
mode: "global",
|
||||
commands: [
|
||||
@@ -66,7 +68,9 @@ export default Plugin.define({
|
||||
await context.client.session
|
||||
.generate({ sessionID: route.sessionID, prompt: [instructions, question].join("\n\n") })
|
||||
.then((result) => {
|
||||
context.ui.dialog.show(() => <Answer question={question} answer={result.text.trim()} />)
|
||||
context.ui.dialog.show(() => (
|
||||
<Answer question={question} answer={result.text.trim()} markdown={plugins.markdown} />
|
||||
))
|
||||
context.ui.dialog.set({ size: "large", centered: true })
|
||||
})
|
||||
.catch((cause: unknown) => toast.error(cause))
|
||||
@@ -81,17 +85,18 @@ export default Plugin.define({
|
||||
},
|
||||
})
|
||||
|
||||
function Answer(props: { question: string; answer: string }) {
|
||||
export function Answer(props: {
|
||||
question: string
|
||||
answer: string
|
||||
markdown: ReturnType<typeof usePlugin>["markdown"]
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const toast = useToast()
|
||||
const clipboard = useClipboard()
|
||||
const plugins = usePlugin()
|
||||
const theme = useTheme("elevated")
|
||||
const overlay = useTheme("overlay")
|
||||
const theme = useTheme().surface("dialog")
|
||||
const overlay = useTheme()
|
||||
const syntax = useThemes().currentSyntax
|
||||
const config = useConfig().data
|
||||
const dimensions = useTerminalDimensions()
|
||||
const maxHeight = createMemo(() => Math.max(3, Math.floor(dimensions().height / 2)))
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
|
||||
@@ -111,8 +116,8 @@ function Answer(props: { question: string; answer: string }) {
|
||||
if (!scroll) return
|
||||
if (event.name === "up") return scroll.scrollBy(-1)
|
||||
if (event.name === "down") return scroll.scrollBy(1)
|
||||
if (event.name === "pageup") return scroll.scrollBy(-maxHeight())
|
||||
if (event.name === "pagedown") return scroll.scrollBy(maxHeight())
|
||||
if (event.name === "pageup") return scroll.scrollBy(-20)
|
||||
if (event.name === "pagedown") return scroll.scrollBy(20)
|
||||
if (event.name === "home") return scroll.scrollTo(0)
|
||||
if (event.name === "end") return scroll.scrollTo(scroll.scrollHeight)
|
||||
})
|
||||
@@ -128,27 +133,29 @@ function Answer(props: { question: string; answer: string }) {
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<text fg={theme.text.subdued} wrapMode="word">
|
||||
{props.question}
|
||||
</text>
|
||||
<box paddingTop={1}>
|
||||
<text fg={theme.text.subdued} wrapMode="word">
|
||||
{props.question}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
<scrollbox
|
||||
ref={(element: ScrollBoxRenderable) => (scroll = element)}
|
||||
maxHeight={maxHeight()}
|
||||
backgroundColor={overlay.background.default}
|
||||
maxHeight={20}
|
||||
backgroundColor={overlay.background.raised.high}
|
||||
scrollbarOptions={{ visible: false }}
|
||||
scrollAcceleration={getScrollAcceleration(config)}
|
||||
>
|
||||
<box paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
|
||||
<markdown
|
||||
syntaxStyle={syntax()}
|
||||
renderNode={plugins.markdown()}
|
||||
renderNode={props.markdown()}
|
||||
content={props.answer}
|
||||
conceal
|
||||
internalBlockMode="top-level"
|
||||
tableOptions={{ style: "grid", cellPaddingX: 1 }}
|
||||
fg={overlay.markdown.text}
|
||||
bg={overlay.background.default}
|
||||
bg={overlay.background.raised.high}
|
||||
/>
|
||||
</box>
|
||||
</scrollbox>
|
||||
|
||||
@@ -12,7 +12,7 @@ export function DiffFileMenu(props: {
|
||||
onClose: () => void
|
||||
}) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = props.context.theme.contextual.overlay
|
||||
const theme = props.context.theme
|
||||
const [hovered, setHovered] = createSignal(false)
|
||||
const label = () => (props.reviewed ? "Mark incomplete" : "Mark complete")
|
||||
const width = () => Math.min(19, dimensions().width)
|
||||
@@ -63,7 +63,7 @@ export function DiffFileMenu(props: {
|
||||
height={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={hovered() ? theme.background.action.primary.hovered : theme.background.default}
|
||||
backgroundColor={hovered() ? theme.background.action.primary.hovered : theme.background.raised.high}
|
||||
onMouseOver={() => setHovered(true)}
|
||||
onMouseOut={() => setHovered(false)}
|
||||
onMouseDown={(event) => {
|
||||
|
||||
@@ -27,7 +27,7 @@ export type DiffViewerFileTreeProps = {
|
||||
}
|
||||
|
||||
export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme()
|
||||
const [sourceHovered, setSourceHovered] = createSignal(false)
|
||||
const list = () => props.layout === "list"
|
||||
const tree = createMemo(() => buildFileTree(props.files))
|
||||
@@ -37,9 +37,9 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
|
||||
: flattenFileTree(tree(), props.expandedNodes),
|
||||
)
|
||||
// Quieter than subdued text: markers are affordances, not content.
|
||||
const faint = createMemo(() => tint(theme.text.subdued, theme.background.default, 0.45))
|
||||
const faint = createMemo(() => tint(theme.text.subdued, theme.background.raised.base, 0.45))
|
||||
// Rails are pure texture; keep them barely above the surface.
|
||||
const rail = createMemo(() => tint(theme.text.subdued, theme.background.default, 0.7))
|
||||
const rail = createMemo(() => tint(theme.text.subdued, theme.background.raised.base, 0.7))
|
||||
const reviewedCount = createMemo(() => props.files.filter((file) => props.reviewedFileNames?.has(file.file)).length)
|
||||
const contentWidth = () => Math.max(0, props.width - 4 - FILE_TREE_STATUS_WIDTH - 1)
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
@@ -56,7 +56,7 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
|
||||
|
||||
return (
|
||||
<box width={props.width} height="100%" minWidth={0} minHeight={0} flexShrink={0} flexDirection="column">
|
||||
<box id="diff-tree-top-edge" height={1} flexShrink={0} backgroundColor={theme.background.default} />
|
||||
<box id="diff-tree-top-edge" height={1} flexShrink={0} backgroundColor={theme.background.raised.base} />
|
||||
<box
|
||||
flexGrow={1}
|
||||
minWidth={0}
|
||||
@@ -64,7 +64,7 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
backgroundColor={theme.background.default}
|
||||
backgroundColor={theme.background.raised.base}
|
||||
>
|
||||
<box id="diff-source-header" height={1} flexShrink={0} flexDirection="row" marginBottom={1} gap={1}>
|
||||
<box
|
||||
@@ -137,8 +137,8 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
|
||||
}
|
||||
const background = () => {
|
||||
// Elevated context maps this to a quiet neutral surface step, not the loud accent.
|
||||
if (hovered()) return theme.background.action.primary.hovered
|
||||
return theme.background.default
|
||||
if (hovered()) return theme.background.raised.high
|
||||
return theme.background.raised.base
|
||||
}
|
||||
const marker = () => {
|
||||
if (row.kind !== "directory") return "≡ "
|
||||
|
||||
@@ -214,7 +214,7 @@ function DiffBaseDialog(props: {
|
||||
current?: string
|
||||
onSelect: (ref: string) => void
|
||||
}) {
|
||||
const theme = props.context.theme.contextual.elevated
|
||||
const theme = props.context.theme.surface("dialog")
|
||||
const [search, setSearch] = createDebouncedSignal("", 150)
|
||||
const [branches] = createResource(search, (search) =>
|
||||
props.context.client.vcs.branch.list({ location: props.location, search, limit: 100 }),
|
||||
@@ -1079,7 +1079,7 @@ export function DiffViewerContent(props: {
|
||||
|
||||
function DiffViewerHelpDialog(props: { context: Plugin.Context; single: boolean }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = props.context.theme.contextual.elevated
|
||||
const theme = props.context.theme.surface("dialog")
|
||||
const shortcut =
|
||||
(...ids: string[]) =>
|
||||
() =>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { TextAttributes } from "@opentui/core"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { createMemo, createResource, createSignal, For, Show } from "solid-js"
|
||||
import { Logo } from "../../component/logo"
|
||||
import { useTheme, useThemes } from "../../context/theme"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { tint } from "../../theme/color"
|
||||
import { statsMetrics, statsNumber } from "./stats-data"
|
||||
|
||||
@@ -30,7 +30,6 @@ const digits: Record<string, string[]> = {
|
||||
export function StatsPoster(props: { stats: SessionStatsInfo }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = useTheme()
|
||||
const themes = useThemes()
|
||||
const width = () => Math.max(12, Math.min(110, dimensions().width - 8))
|
||||
const compact = () => dimensions().height < 38
|
||||
const metrics = createMemo(() => statsMetrics(props.stats))
|
||||
@@ -55,7 +54,7 @@ export function StatsPoster(props: { stats: SessionStatsInfo }) {
|
||||
const shades = createMemo(() => [
|
||||
theme.text.subdued,
|
||||
...[0.3, 0.5, 0.75, 1].map((alpha) =>
|
||||
tint(theme.background.default, theme.categorical[0][themes.mode() === "light" ? 800 : 200], alpha),
|
||||
tint(theme.background.default, theme.categorical[0][200], alpha),
|
||||
),
|
||||
])
|
||||
|
||||
|
||||
@@ -14,10 +14,10 @@ export function StoryFooter(props: {
|
||||
message?: string
|
||||
controls: readonly StoryFooterControl[]
|
||||
}) {
|
||||
const theme = props.context.theme.contextual.elevated
|
||||
const theme = props.context.theme
|
||||
|
||||
return (
|
||||
<box flexShrink={0} flexDirection="column" backgroundColor={theme.background.default}>
|
||||
<box flexShrink={0} flexDirection="column" backgroundColor={theme.background.raised.base}>
|
||||
<box height={1} paddingLeft={1} paddingRight={1} flexDirection="row">
|
||||
<text fg={theme.text.default}>{props.title}</text>
|
||||
<Show when={props.details?.length}>
|
||||
|
||||
@@ -11,7 +11,7 @@ const directory = "/Users/kit/code/open-source/opencode-workerd-profile"
|
||||
|
||||
function SessionLocationMissingStory(props: { context: Plugin.Context }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = props.context.theme.contextual.elevated
|
||||
const theme = props.context.theme
|
||||
const [message, setMessage] = createSignal("Choose another directory to continue")
|
||||
const open = () =>
|
||||
props.context.ui.dialog.show(() => (
|
||||
@@ -45,7 +45,7 @@ function SessionLocationMissingStory(props: { context: Plugin.Context }) {
|
||||
}))
|
||||
|
||||
return (
|
||||
<box width={dimensions().width} height={dimensions().height} backgroundColor={theme.background.default}>
|
||||
<box width={dimensions().width} height={dimensions().height} backgroundColor={theme.background.raised.base}>
|
||||
<box paddingLeft={2} paddingRight={2} paddingTop={1} flexGrow={1}>
|
||||
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
|
||||
Workerd Modal workspace driver
|
||||
|
||||
@@ -111,11 +111,9 @@ function nearestIndexed(indexed: RGBA[], color: RGBA): RGBA {
|
||||
function map(
|
||||
theme: ResolvedTheme,
|
||||
indexed: RGBA[],
|
||||
mode: "light" | "dark",
|
||||
syntax?: SyntaxStyle,
|
||||
system = false,
|
||||
): RunTheme {
|
||||
const elevated = theme.contextual.elevated
|
||||
// V1 system migration serializes colors; restore terminal defaults before quantizing scrollback.
|
||||
const exact = (color: RGBA) => {
|
||||
if (system && color.equals(theme.text.default)) return RGBA.defaultForeground(color)
|
||||
@@ -137,29 +135,29 @@ function map(
|
||||
return {
|
||||
background: RGBA.defaultBackground(theme.background.default),
|
||||
footer: {
|
||||
actionSecondaryText: exact(elevated.text.action.secondary.default),
|
||||
actionFocusedBg: exact(elevated.background.action.primary.focused),
|
||||
actionFocusedText: exact(elevated.text.action.primary.focused),
|
||||
formfieldText: exact(elevated.text.formfield.default),
|
||||
formfieldFocusedBg: exact(elevated.background.formfield.focused),
|
||||
formfieldFocusedText: exact(elevated.text.formfield.focused),
|
||||
selection: exact(elevated.text.formfield.selected),
|
||||
actionSecondaryText: exact(theme.text.action.secondary.default),
|
||||
actionFocusedBg: exact(theme.background.action.primary.focused),
|
||||
actionFocusedText: exact(theme.text.action.primary.focused),
|
||||
formfieldText: exact(theme.text.formfield.default),
|
||||
formfieldFocusedBg: exact(theme.background.formfield.focused),
|
||||
formfieldFocusedText: exact(theme.text.formfield.focused),
|
||||
selection: exact(theme.text.formfield.selected),
|
||||
running: exact(theme.text.status.running),
|
||||
question: exact(theme.text.status.question),
|
||||
permission: exact(theme.text.status.permission),
|
||||
success: exact(theme.text.feedback.success.default),
|
||||
link: exact(theme.markdown.link),
|
||||
categorical: dedupeWith(
|
||||
theme.categorical.map((scale) => exact(scale[mode === "light" ? 800 : 200])),
|
||||
theme.categorical.map((scale) => exact(scale[200])),
|
||||
(a, b) => a.equals(b),
|
||||
),
|
||||
warning: exact(theme.text.feedback.warning.default),
|
||||
error: exact(theme.text.feedback.error.default),
|
||||
muted: exact(theme.text.subdued),
|
||||
text: exact(theme.text.default),
|
||||
shade: exact(elevated.background.default),
|
||||
surface: exact(elevated.background.default),
|
||||
pane: exact(theme.contextual.overlay.background.default),
|
||||
shade: exact(theme.background.raised.base),
|
||||
surface: exact(theme.background.raised.base),
|
||||
pane: exact(theme.background.raised.high),
|
||||
border: exact(theme.border.default),
|
||||
line: exact(theme.background.raised.high),
|
||||
},
|
||||
@@ -196,12 +194,10 @@ function map(
|
||||
export const RUN_THEME_FALLBACK = map(
|
||||
resolveThemeDocument(parseTheme(DEFAULT_THEMES.opencode), "dark"),
|
||||
ansiPalette,
|
||||
"dark",
|
||||
)
|
||||
export const RUN_THEME_FALLBACK_LIGHT = map(
|
||||
resolveThemeDocument(parseTheme(DEFAULT_THEMES.opencode), "light"),
|
||||
ansiPalette,
|
||||
"light",
|
||||
)
|
||||
|
||||
function monoTheme(mode: "dark" | "light"): RunTheme {
|
||||
@@ -301,7 +297,7 @@ export async function resolveRunTheme(
|
||||
? ansiPalette.map((color, index) => (colors.palette[index] ? RGBA.fromIndex(index, colors.palette[index]!) : color))
|
||||
: ansiPalette
|
||||
return {
|
||||
...map(theme, indexed, mode, generateSyntax(theme, mode), name === "system" && resolved !== undefined),
|
||||
...map(theme, indexed, generateSyntax(theme), name === "system" && resolved !== undefined),
|
||||
background: RGBA.defaultBackground(colors?.defaultBackground ?? theme.background.default),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export type ComposerProps = {
|
||||
}
|
||||
|
||||
export function Composer(props: ComposerProps) {
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme()
|
||||
const config = useConfig().data
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
@@ -114,7 +114,7 @@ export function Composer(props: ComposerProps) {
|
||||
{...SplitBorder}
|
||||
border={["left"]}
|
||||
borderColor={theme.border.default}
|
||||
backgroundColor={theme.background.default}
|
||||
backgroundColor={theme.background.raised.base}
|
||||
paddingLeft={1}
|
||||
paddingRight={2}
|
||||
paddingTop={1}
|
||||
|
||||
@@ -21,7 +21,7 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) {
|
||||
const dialog = useDialog()
|
||||
const clipboard = useClipboard()
|
||||
const toast = useToast()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("dialog")
|
||||
const dimensions = useTerminalDimensions()
|
||||
const config = useConfig().data
|
||||
const [copied, setCopied] = createSignal<"code" | "output">()
|
||||
@@ -224,7 +224,7 @@ function GutteredCode(props: {
|
||||
digits: number
|
||||
blocks: Set<CodeRenderable>
|
||||
}) {
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("dialog")
|
||||
const syntax = useThemes().currentSyntax
|
||||
const gutter = createMemo(() =>
|
||||
props.content
|
||||
|
||||
@@ -2,10 +2,10 @@ import { createStore, unwrap } from "solid-js/store"
|
||||
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
|
||||
import { usePaste, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import {
|
||||
CliRenderEvents,
|
||||
decodePasteBytes,
|
||||
stripAnsiSequences,
|
||||
TextAttributes,
|
||||
type BoxRenderable,
|
||||
type ScrollBoxRenderable,
|
||||
type TextareaRenderable,
|
||||
} from "@opentui/core"
|
||||
@@ -59,7 +59,7 @@ const drafts = new Map<string, FormDraft>()
|
||||
export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
const data = useData()
|
||||
const themes = useThemes()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme()
|
||||
const themeMode = themes.mode
|
||||
const renderer = useRenderer()
|
||||
const dimensions = useTerminalDimensions()
|
||||
@@ -75,8 +75,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
drafts.delete(props.form.id)
|
||||
|
||||
const [tabHover, setTabHover] = createSignal<number | "confirm" | null>(null)
|
||||
const [reviewHeight, setReviewHeight] = createSignal(1)
|
||||
const [reviewScrollable, setReviewScrollable] = createSignal(false)
|
||||
const [reviewContentHeight, setReviewContentHeight] = createSignal(0)
|
||||
const [store, setStore] = createStore<FormDraft>(
|
||||
draft ?? {
|
||||
tab: 0,
|
||||
@@ -92,7 +91,6 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
let textarea: TextareaRenderable | undefined
|
||||
const [inputTarget, setInputTarget] = createSignal<TextareaRenderable>()
|
||||
let review: ScrollBoxRenderable | undefined
|
||||
let measureReview: (() => void) | undefined
|
||||
|
||||
const message = createMemo(() => {
|
||||
const value = props.form.metadata?.["message"]
|
||||
@@ -141,6 +139,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
return current?.type === "external" ? current : undefined
|
||||
})
|
||||
const confirm = createMemo(() => !single() && store.tab >= fields().length)
|
||||
const reviewMaxHeight = createMemo(() => Math.max(3, dimensions().height - 14))
|
||||
const configuredRows = createMemo(() => {
|
||||
const current = answerField()
|
||||
return current ? formRows(current) : []
|
||||
@@ -211,32 +210,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
return "confirm"
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (measureReview) renderer.off(CliRenderEvents.FRAME, measureReview)
|
||||
if (!confirm()) {
|
||||
measureReview = undefined
|
||||
review = undefined
|
||||
setReviewScrollable(false)
|
||||
return
|
||||
}
|
||||
const limit = Math.max(3, dimensions().height - 14)
|
||||
const initial = Math.min(Math.max(1, fields().length), limit)
|
||||
Object.values(store.answers)
|
||||
setReviewHeight(initial)
|
||||
setReviewScrollable(false)
|
||||
measureReview = () => {
|
||||
measureReview = undefined
|
||||
const content = review?.scrollHeight ?? initial
|
||||
const height = Math.min(Math.max(1, content), limit)
|
||||
setReviewHeight(height)
|
||||
setReviewScrollable(content > height)
|
||||
}
|
||||
renderer.once(CliRenderEvents.FRAME, measureReview)
|
||||
renderer.requestRender()
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (measureReview) renderer.off(CliRenderEvents.FRAME, measureReview)
|
||||
// A reply or cancel removes the form from data before this unmount runs, so a
|
||||
// form still listed here is only hidden by navigation and worth restoring.
|
||||
const pending = data.session.form
|
||||
@@ -794,7 +768,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
|
||||
return (
|
||||
<box
|
||||
backgroundColor={theme.background.default}
|
||||
backgroundColor={theme.background.raised.base}
|
||||
border={["left"]}
|
||||
borderColor={theme.hue.interactive[themeMode() === "light" ? 800 : 200]}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
@@ -839,7 +813,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
? theme.background.formfield.selected
|
||||
: tabHover() === index()
|
||||
? theme.background.formfield.focused
|
||||
: theme.background.default
|
||||
: theme.background.raised.base
|
||||
}
|
||||
onMouseOver={() => setTabHover(index())}
|
||||
onMouseOut={() => setTabHover(null)}
|
||||
@@ -861,7 +835,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
? theme.background.formfield.selected
|
||||
: tabHover() === "confirm"
|
||||
? theme.background.formfield.focused
|
||||
: theme.background.default
|
||||
: theme.background.raised.base
|
||||
}
|
||||
onMouseOver={() => setTabHover("confirm")}
|
||||
onMouseOut={() => setTabHover(null)}
|
||||
@@ -969,7 +943,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
>
|
||||
<box flexDirection="row">
|
||||
<box
|
||||
backgroundColor={active() ? theme.background.formfield.focused : theme.background.default}
|
||||
backgroundColor={active() ? theme.background.formfield.focused : theme.background.raised.base}
|
||||
paddingRight={1}
|
||||
>
|
||||
<text
|
||||
@@ -977,7 +951,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
>{`${i() + 1}.`}</text>
|
||||
</box>
|
||||
<box
|
||||
backgroundColor={active() ? theme.background.formfield.focused : theme.background.default}
|
||||
backgroundColor={active() ? theme.background.formfield.focused : theme.background.raised.base}
|
||||
flexDirection="row"
|
||||
>
|
||||
<Show when={multi()}>
|
||||
@@ -1023,7 +997,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
>
|
||||
<box flexDirection="row">
|
||||
<box
|
||||
backgroundColor={other() ? theme.background.formfield.focused : theme.background.default}
|
||||
backgroundColor={other() ? theme.background.formfield.focused : theme.background.raised.base}
|
||||
paddingRight={1}
|
||||
>
|
||||
<text fg={other() ? theme.text.formfield.focused : theme.text.subdued}>
|
||||
@@ -1033,7 +1007,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
<box
|
||||
flexDirection="row"
|
||||
flexGrow={1}
|
||||
backgroundColor={other() ? theme.background.formfield.focused : theme.background.default}
|
||||
backgroundColor={other() ? theme.background.formfield.focused : theme.background.raised.base}
|
||||
>
|
||||
<Show when={multi()}>
|
||||
<text
|
||||
@@ -1102,56 +1076,63 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
|
||||
<Show when={confirm()}>
|
||||
<scrollbox
|
||||
height={reviewHeight()}
|
||||
maxHeight={reviewMaxHeight()}
|
||||
contentOptions={{ minHeight: 0 }}
|
||||
scrollbarOptions={{ visible: false }}
|
||||
ref={(r: ScrollBoxRenderable) => (review = r)}
|
||||
>
|
||||
<For each={fields()}>
|
||||
{(item) => {
|
||||
if (item.type === "external") {
|
||||
const acknowledged = () => store.answers[item.key] === true
|
||||
<box
|
||||
onSizeChange={function (this: BoxRenderable) {
|
||||
setReviewContentHeight(this.height)
|
||||
}}
|
||||
>
|
||||
<For each={fields()}>
|
||||
{(item) => {
|
||||
if (item.type === "external") {
|
||||
const acknowledged = () => store.answers[item.key] === true
|
||||
return (
|
||||
<box paddingLeft={1}>
|
||||
<text>
|
||||
<span style={{ fg: theme.text.subdued }}>{truncate(formLabel(item), 40)}:</span>{" "}
|
||||
<span
|
||||
style={{
|
||||
fg: acknowledged()
|
||||
? theme.text.feedback.success.default
|
||||
: theme.text.feedback.error.default,
|
||||
}}
|
||||
>
|
||||
{acknowledged() ? "Acknowledged" : "(acknowledgement required)"}
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
const value = () => formDisplayValue(item, store.answers[item.key], "(none)")
|
||||
const answered = () => store.answers[item.key] !== undefined
|
||||
const missing = () => !answered() && item.required === true
|
||||
const invalid = () => formValidateValue(item, store.answers[item.key])
|
||||
return (
|
||||
<box paddingLeft={1}>
|
||||
<text>
|
||||
<span style={{ fg: theme.text.subdued }}>{truncate(formLabel(item), 40)}:</span>{" "}
|
||||
<span
|
||||
style={{
|
||||
fg: acknowledged()
|
||||
? theme.text.feedback.success.default
|
||||
: theme.text.feedback.error.default,
|
||||
fg:
|
||||
invalid() || missing()
|
||||
? theme.text.feedback.error.default
|
||||
: answered()
|
||||
? theme.text.default
|
||||
: theme.text.subdued,
|
||||
}}
|
||||
>
|
||||
{acknowledged() ? "Acknowledged" : "(acknowledgement required)"}
|
||||
{invalid() ?? (answered() ? value() : missing() ? "(required)" : "(not answered)")}
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
const value = () => formDisplayValue(item, store.answers[item.key], "(none)")
|
||||
const answered = () => store.answers[item.key] !== undefined
|
||||
const missing = () => !answered() && item.required === true
|
||||
const invalid = () => formValidateValue(item, store.answers[item.key])
|
||||
return (
|
||||
<box paddingLeft={1}>
|
||||
<text>
|
||||
<span style={{ fg: theme.text.subdued }}>{truncate(formLabel(item), 40)}:</span>{" "}
|
||||
<span
|
||||
style={{
|
||||
fg:
|
||||
invalid() || missing()
|
||||
? theme.text.feedback.error.default
|
||||
: answered()
|
||||
? theme.text.default
|
||||
: theme.text.subdued,
|
||||
}}
|
||||
>
|
||||
{invalid() ?? (answered() ? value() : missing() ? "(required)" : "(not answered)")}
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
}}
|
||||
</For>
|
||||
</box>
|
||||
</scrollbox>
|
||||
</Show>
|
||||
</box>
|
||||
@@ -1175,7 +1156,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
{"↑↓"} <span style={{ fg: theme.text.subdued }}>select</span>
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={confirm() && reviewScrollable()}>
|
||||
<Show when={confirm() && reviewContentHeight() > reviewMaxHeight()}>
|
||||
<text fg={theme.text.default}>
|
||||
{"↑↓"} <span style={{ fg: theme.text.subdued }}>scroll</span>
|
||||
</text>
|
||||
|
||||
@@ -23,7 +23,7 @@ import { SplitBorder } from "../../ui/border"
|
||||
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
|
||||
import { Spinner, SPINNER_FRAMES } from "../../component/spinner"
|
||||
import { PatchDiff } from "../../component/patch-diff"
|
||||
import { createSyntaxStyleMemo, ThemeContextProvider, useTheme, useThemes } from "../../context/theme"
|
||||
import { createSyntaxStyleMemo, useTheme, useThemes } from "../../context/theme"
|
||||
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA, MouseEvent } from "@opentui/core"
|
||||
import { Prompt, type PromptRef } from "../../component/prompt"
|
||||
import type {
|
||||
@@ -1292,7 +1292,7 @@ export function Session(props: {
|
||||
paddingLeft: 1,
|
||||
visible: showScrollbar(),
|
||||
trackOptions: {
|
||||
backgroundColor: theme.raise(theme.background.raised.base),
|
||||
backgroundColor: theme.decrease(theme.background.raised.base),
|
||||
foregroundColor: theme.border.default,
|
||||
},
|
||||
}}
|
||||
@@ -1836,7 +1836,7 @@ function SessionReasoningGroupView(props: {
|
||||
<box
|
||||
border={["left"]}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={theme.raise(theme.background.raised.base)}
|
||||
borderColor={theme.decrease(theme.background.raised.base)}
|
||||
paddingLeft={1}
|
||||
>
|
||||
<code
|
||||
@@ -1936,7 +1936,7 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) {
|
||||
const config = useConfig()
|
||||
const data = useData()
|
||||
const local = useLocal()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme()
|
||||
const model = createMemo(
|
||||
() =>
|
||||
ctx
|
||||
@@ -2179,7 +2179,7 @@ function RevertMessage(props: {
|
||||
}>
|
||||
}) {
|
||||
const ctx = use()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme()
|
||||
const route = useRouteData("session")
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
@@ -2204,13 +2204,13 @@ function RevertMessage(props: {
|
||||
marginTop={1}
|
||||
border={["left"]}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={theme.background.default}
|
||||
borderColor={theme.background.raised.base}
|
||||
>
|
||||
<box
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
backgroundColor={hover() ? theme.raise(theme.background.default) : theme.background.default}
|
||||
backgroundColor={hover() ? theme.decrease(theme.background.raised.base) : theme.background.raised.base}
|
||||
>
|
||||
<text fg={theme.text.subdued}>
|
||||
{props.count} message{props.count === 1 ? "" : "s"} reverted
|
||||
@@ -2282,7 +2282,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||
),
|
||||
)
|
||||
const themes = useThemes()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme()
|
||||
const mode = themes.mode
|
||||
const [hover, setHover] = createSignal(false)
|
||||
const color = createMemo(() => local.agent.color(data.session.get(ctx.sessionID)?.agent ?? "build"))
|
||||
@@ -2301,7 +2301,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||
border={["left"]}
|
||||
borderColor={delivery() ? theme.border.default : color()}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
backgroundColor={theme.background.default}
|
||||
backgroundColor={theme.background.raised.base}
|
||||
>
|
||||
<SessionImages images={images()} paddingLeft={2} />
|
||||
<box
|
||||
@@ -2339,7 +2339,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
backgroundColor={hover() ? theme.raise(theme.background.default) : theme.background.default}
|
||||
backgroundColor={hover() ? theme.decrease(theme.background.raised.base) : theme.background.raised.base}
|
||||
flexShrink={0}
|
||||
>
|
||||
<text fg={theme.text.default}>{props.message.text}</text>
|
||||
@@ -2350,14 +2350,14 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||
<text fg={theme.text.default}>
|
||||
<span
|
||||
style={{
|
||||
bg: theme.hue.accent[mode() === "light" ? 700 : 200],
|
||||
fg: theme.background.default,
|
||||
bg: theme.hue.accent[mode() === "light" ? 300 : 200],
|
||||
fg: theme.background.raised.base,
|
||||
bold: true,
|
||||
}}
|
||||
>
|
||||
{" skill "}
|
||||
</span>
|
||||
<span style={{ bg: theme.raise(theme.background.default), fg: theme.text.subdued }}>
|
||||
<span style={{ bg: theme.decrease(theme.background.raised.base), fg: theme.text.subdued }}>
|
||||
{` ${skill.name} `}
|
||||
</span>
|
||||
</text>
|
||||
@@ -2374,14 +2374,14 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||
<text fg={theme.text.default}>
|
||||
<span
|
||||
style={{
|
||||
bg: theme.hue.accent[mode() === "light" ? 700 : 200],
|
||||
fg: theme.background.default,
|
||||
bg: theme.hue.accent[mode() === "light" ? 300 : 200],
|
||||
fg: theme.background.raised.base,
|
||||
bold: true,
|
||||
}}
|
||||
>
|
||||
{` ${label} `}
|
||||
</span>
|
||||
<span style={{ bg: theme.raise(theme.background.default), fg: theme.text.subdued }}>
|
||||
<span style={{ bg: theme.decrease(theme.background.raised.base), fg: theme.text.subdued }}>
|
||||
{" "}
|
||||
{file.name ?? (file.source.type === "uri" ? file.source.uri : "attachment")}{" "}
|
||||
</span>
|
||||
@@ -2398,7 +2398,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||
}
|
||||
|
||||
function QueuedPromptDock(props: { prompts: { id: string; text: string }[]; onOpen: () => void }) {
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme()
|
||||
const [hover, setHover] = createSignal(false)
|
||||
const next = createMemo(() => props.prompts[0]?.text.replaceAll("\n", " "))
|
||||
|
||||
@@ -2417,7 +2417,7 @@ function QueuedPromptDock(props: { prompts: { id: string; text: string }[]; onOp
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={1}
|
||||
backgroundColor={hover() ? theme.raise(theme.background.default) : theme.background.default}
|
||||
backgroundColor={hover() ? theme.decrease(theme.background.raised.base) : theme.background.raised.base}
|
||||
flexDirection="row"
|
||||
>
|
||||
<text fg={theme.text.subdued} wrapMode="none" truncate flexGrow={1} flexShrink={1} minWidth={0}>
|
||||
@@ -2758,10 +2758,11 @@ function InlineTool(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge(props: { children: string }) {
|
||||
function StatusBadge(props: { children: string; raised?: boolean }) {
|
||||
const theme = useTheme()
|
||||
const background = () => (props.raised ? theme.background.raised.base : theme.background.default)
|
||||
return (
|
||||
<text flexShrink={0} bg={theme.raise(theme.background.default)} fg={theme.text.subdued}>
|
||||
<text flexShrink={0} bg={theme.decrease(background())} fg={theme.text.subdued}>
|
||||
{" "}
|
||||
{props.children}{" "}
|
||||
</text>
|
||||
@@ -2781,16 +2782,8 @@ type BlockToolProps = {
|
||||
}
|
||||
|
||||
function BlockTool(props: BlockToolProps) {
|
||||
const parentTheme = useTheme()
|
||||
return (
|
||||
<ThemeContextProvider context="elevated">
|
||||
<BlockToolContent {...props} borderColor={parentTheme.background.default} />
|
||||
</ThemeContextProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function BlockToolContent(props: BlockToolProps & { borderColor: RGBA }) {
|
||||
const theme = useTheme()
|
||||
const background = () => theme.background.raised.base
|
||||
const ctx = use()
|
||||
const renderer = useRenderer()
|
||||
const [hover, setHover] = createSignal(false)
|
||||
@@ -2806,9 +2799,9 @@ function BlockToolContent(props: BlockToolProps & { borderColor: RGBA }) {
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
gap={1}
|
||||
backgroundColor={hover() ? theme.raise(theme.background.default) : theme.background.default}
|
||||
backgroundColor={hover() ? theme.decrease(background()) : background()}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={props.borderColor}
|
||||
borderColor={theme.background.default}
|
||||
onMouseOver={() => props.onClick && setHover(true)}
|
||||
onMouseOut={() => setHover(false)}
|
||||
onMouseUp={() => {
|
||||
@@ -3041,7 +3034,7 @@ function ShellDisplay(props: {
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={props.background}>
|
||||
<StatusBadge>Background</StatusBadge>
|
||||
<StatusBadge raised>Background</StatusBadge>
|
||||
</Show>
|
||||
</box>
|
||||
</BlockTool>
|
||||
@@ -3241,7 +3234,7 @@ function ExecuteCallView(props: { call: Accessor<ExecuteCall> }) {
|
||||
const [hover, setHover] = createSignal(false)
|
||||
const input = createMemo(() => Object.entries(props.call().input ?? {}))
|
||||
const expandable = createMemo(() => input().length > 0)
|
||||
const expandedColor = createMemo(() => theme.raise(theme.text.subdued))
|
||||
const expandedColor = createMemo(() => theme.decrease(theme.text.subdued))
|
||||
const color = createMemo(() => {
|
||||
if (props.call().status === "error") return theme.text.feedback.error.default
|
||||
if (hover()) return theme.text.default
|
||||
|
||||
@@ -13,7 +13,7 @@ export function SessionLocationMissing(props: { directory: string; projectID: st
|
||||
|
||||
export function SessionLocationUnavailable(props: { directory: string; onMove: () => void }) {
|
||||
const paths = useTuiPaths()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme()
|
||||
const directory = createMemo(() => Locale.truncateMiddle(abbreviateHome(props.directory, paths.home), 72))
|
||||
|
||||
return (
|
||||
|
||||
@@ -52,7 +52,7 @@ export function ReasoningPart(props: {
|
||||
<box
|
||||
border={!inMinimal() || expanded() ? ["left"] : undefined}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={theme.raise(theme.background.default)}
|
||||
borderColor={theme.decrease(theme.background.default)}
|
||||
paddingLeft={!inMinimal() || expanded() ? 1 : 0}
|
||||
>
|
||||
<box onMouseUp={toggle}>
|
||||
@@ -70,7 +70,7 @@ export function ReasoningPart(props: {
|
||||
<box
|
||||
border={["left"]}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={theme.raise(theme.background.default)}
|
||||
borderColor={theme.decrease(theme.background.default)}
|
||||
paddingLeft={inMinimal() ? 3 : 1}
|
||||
>
|
||||
<code
|
||||
|
||||
@@ -277,7 +277,7 @@ function RejectPrompt(props: {
|
||||
}) {
|
||||
let input: TextareaRenderable
|
||||
const enabled = useInteractivity()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme()
|
||||
const config = useConfig().data
|
||||
const dimensions = useTerminalDimensions()
|
||||
const narrow = createMemo(() => dimensions().width < 80)
|
||||
@@ -314,7 +314,7 @@ function RejectPrompt(props: {
|
||||
role: "dialog",
|
||||
label: `Reject permission: ${props.action}`,
|
||||
}))}
|
||||
backgroundColor={theme.background.default}
|
||||
backgroundColor={theme.background.raised.base}
|
||||
border={["left"]}
|
||||
borderColor={theme.text.feedback.error.default}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
@@ -335,7 +335,7 @@ function RejectPrompt(props: {
|
||||
paddingLeft={2}
|
||||
paddingRight={3}
|
||||
paddingBottom={1}
|
||||
backgroundColor={theme.raise(theme.background.default)}
|
||||
backgroundColor={theme.decrease(theme.background.raised.base)}
|
||||
justifyContent={narrow() ? "flex-start" : "space-between"}
|
||||
alignItems={narrow() ? "flex-start" : "center"}
|
||||
gap={1}
|
||||
@@ -418,7 +418,7 @@ export function SessionQuestion<const T extends Record<string, string>>(props: {
|
||||
fullscreen?: boolean
|
||||
onSelect: (option: keyof T) => void
|
||||
}) {
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const keys = Object.keys(props.options) as (keyof T)[]
|
||||
const [store, setStore] = createStore({
|
||||
@@ -507,7 +507,7 @@ export function SessionQuestion<const T extends Record<string, string>>(props: {
|
||||
label: props.semanticLabel ?? props.title,
|
||||
expanded: store.expanded,
|
||||
}))}
|
||||
backgroundColor={theme.background.default}
|
||||
backgroundColor={theme.background.raised.base}
|
||||
border={["left"]}
|
||||
borderColor={theme.background.action.primary.focused}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
@@ -546,7 +546,7 @@ export function SessionQuestion<const T extends Record<string, string>>(props: {
|
||||
paddingLeft={2}
|
||||
paddingRight={3}
|
||||
paddingBottom={1}
|
||||
backgroundColor={theme.raise(theme.background.default)}
|
||||
backgroundColor={theme.decrease(theme.background.raised.base)}
|
||||
justifyContent={narrow() ? "flex-start" : "space-between"}
|
||||
alignItems={narrow() ? "flex-start" : "center"}
|
||||
>
|
||||
|
||||
@@ -12,7 +12,7 @@ import { SESSION_SIDEBAR_WIDTH } from "../../ui/layout"
|
||||
|
||||
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
const data = useData()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme()
|
||||
const config = useConfig().data
|
||||
const session = createMemo(() => data.session.get(props.sessionID))
|
||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
|
||||
@@ -20,7 +20,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
return (
|
||||
<Show when={session()}>
|
||||
<box
|
||||
backgroundColor={theme.background.default}
|
||||
backgroundColor={theme.background.raised.base}
|
||||
width={SESSION_SIDEBAR_WIDTH}
|
||||
height="100%"
|
||||
paddingTop={1}
|
||||
@@ -37,7 +37,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
title: withTimestampedFallback(session()),
|
||||
}}
|
||||
enabled={config.animations ?? true}
|
||||
backdrop={theme.background.default}
|
||||
backdrop={theme.background.raised.base}
|
||||
attributes={
|
||||
data.session.title.pending(props.sessionID) && config.animations === false
|
||||
? TextAttributes.DIM
|
||||
@@ -61,7 +61,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
width: 1,
|
||||
height: "100%",
|
||||
trackOptions: {
|
||||
backgroundColor: theme.background.default,
|
||||
backgroundColor: theme.background.raised.base,
|
||||
foregroundColor: theme.scrollbar.default,
|
||||
},
|
||||
}}
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import type { RGBA } from "@opentui/core"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { Mode, ResolvedTheme, ResolvedThemeTokens } from "@opencode/theme/tui"
|
||||
import type { ResolvedTheme, SurfaceName } from "@opencode/theme/tui"
|
||||
|
||||
export function createComponentTheme(current: Accessor<ResolvedTheme>, mode: Accessor<Mode>) {
|
||||
return Object.assign(createComponentThemeView(current, mode), {
|
||||
contextual: {
|
||||
elevated: createComponentThemeView(() => current().contextual.elevated, mode),
|
||||
overlay: createComponentThemeView(() => current().contextual.overlay, mode),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function createComponentThemeView(view: Accessor<ResolvedThemeTokens>, mode: Accessor<Mode>) {
|
||||
export function createComponentTheme(
|
||||
view: Accessor<ResolvedTheme>,
|
||||
// Shared across a theme's surface views so `surface()` stays absolute at the wrapper level too.
|
||||
surfaces = new Map<SurfaceName, ComponentTheme>(),
|
||||
): ComponentTheme {
|
||||
return {
|
||||
get hue() {
|
||||
return view().hue
|
||||
@@ -43,8 +38,14 @@ export function createComponentThemeView(view: Accessor<ResolvedThemeTokens>, mo
|
||||
source: (color: RGBA) => view().source(color),
|
||||
increase: (color: RGBA, amount = 1) => view().increase(color, amount),
|
||||
decrease: (color: RGBA, amount = 1) => view().decrease(color, amount),
|
||||
raise: (color: RGBA) => (mode() === "light" ? view().increase(color) : view().decrease(color)),
|
||||
surface(name: SurfaceName) {
|
||||
const cached = surfaces.get(name)
|
||||
if (cached) return cached
|
||||
const created = createComponentTheme(() => view().surface(name), surfaces)
|
||||
surfaces.set(name, created)
|
||||
return created
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type ComponentTheme = ReturnType<typeof createComponentTheme>
|
||||
export type ComponentTheme = ResolvedTheme
|
||||
|
||||
@@ -11,7 +11,7 @@ export type DialogAlertProps = {
|
||||
|
||||
export function DialogAlert(props: DialogAlertProps) {
|
||||
const dialog = useDialog()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("dialog")
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "modal",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user