From 46edc98f1042ec1e377a318ec1348db3cfe2620b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 19:51:45 -0400 Subject: [PATCH] Validate TUI config with Effect Schema (#26952) --- packages/opencode/script/schema.ts | 4 +- .../src/cli/cmd/tui/config/keybind.ts | 101 +++++++++++------- .../src/cli/cmd/tui/config/tui-json-schema.ts | 66 ------------ .../src/cli/cmd/tui/config/tui-migrate.ts | 54 +++++----- .../src/cli/cmd/tui/config/tui-schema.ts | 54 +++++----- .../opencode/src/cli/cmd/tui/config/tui.ts | 30 +++--- packages/opencode/src/config/plugin.ts | 6 +- 7 files changed, 139 insertions(+), 176 deletions(-) delete mode 100644 packages/opencode/src/cli/cmd/tui/config/tui-json-schema.ts diff --git a/packages/opencode/script/schema.ts b/packages/opencode/script/schema.ts index b335c62df4..b34eaf7f0e 100755 --- a/packages/opencode/script/schema.ts +++ b/packages/opencode/script/schema.ts @@ -2,7 +2,7 @@ import { Config } from "@/config/config" import { Schema } from "effect" -import { TuiJsonSchema } from "../src/cli/cmd/tui/config/tui-json-schema" +import { TuiInfo } from "../src/cli/cmd/tui/config/tui-schema" type JsonSchema = Record const MODEL_REF = "https://models.dev/model-schema.json#/$defs/Model" @@ -72,5 +72,5 @@ await Bun.write(configFile, JSON.stringify(generateEffect(Config.Info), null, 2) if (tuiFile) { console.log(tuiFile) - await Bun.write(tuiFile, JSON.stringify(generateEffect(TuiJsonSchema.Info), null, 2)) + await Bun.write(tuiFile, JSON.stringify(generateEffect(TuiInfo), null, 2)) } diff --git a/packages/opencode/src/cli/cmd/tui/config/keybind.ts b/packages/opencode/src/cli/cmd/tui/config/keybind.ts index 5e7fec4018..4623893161 100644 --- a/packages/opencode/src/cli/cmd/tui/config/keybind.ts +++ b/packages/opencode/src/cli/cmd/tui/config/keybind.ts @@ -3,33 +3,39 @@ export * as TuiKeybind from "./keybind" import type { KeyEvent, Renderable } from "@opentui/core" import type { Binding } from "@opentui/keymap" import type { BindingCommandMap, BindingConfig, BindingDefaults } from "@opentui/keymap/extras" -import z from "zod" +import type { DeepMutable } from "@opencode-ai/core/schema" +import { Schema } from "effect" -const KeyStroke = z - .object({ - name: z.string(), - ctrl: z.boolean().optional(), - shift: z.boolean().optional(), - meta: z.boolean().optional(), - super: z.boolean().optional(), - hyper: z.boolean().optional(), - }) - .strict() +const KeyStroke = Schema.Struct({ + name: Schema.String, + ctrl: Schema.optional(Schema.Boolean), + shift: Schema.optional(Schema.Boolean), + meta: Schema.optional(Schema.Boolean), + super: Schema.optional(Schema.Boolean), + hyper: Schema.optional(Schema.Boolean), +}) -const BindingObject = z - .object({ - key: z.union([z.string(), KeyStroke]), - event: z.enum(["press", "release"]).optional(), - preventDefault: z.boolean().optional(), - fallthrough: z.boolean().optional(), - }) - .passthrough() +const BindingObject = Schema.StructWithRest( + Schema.Struct({ + key: Schema.Union([Schema.String, KeyStroke]), + event: Schema.optional(Schema.Literals(["press", "release"])), + preventDefault: Schema.optional(Schema.Boolean), + fallthrough: Schema.optional(Schema.Boolean), + }), + [Schema.Record(Schema.String, Schema.Unknown)], +) -const BindingItem = z.union([z.string(), KeyStroke, BindingObject]) -export const BindingValueSchema = z.union([z.literal(false), z.literal("none"), BindingItem, z.array(BindingItem)]) +const BindingItem = Schema.Union([Schema.String, KeyStroke, BindingObject]) +export const BindingValueSchema = Schema.Union([ + Schema.Literal(false), + Schema.Literal("none"), + BindingItem, + Schema.Array(BindingItem), +]) +export type BindingValueSchema = DeepMutable> type Definition = { - default: z.input + default: BindingValueSchema description: string } @@ -214,21 +220,17 @@ export const Definitions = { which_key_end: keybind("ctrl+alt+end", "Jump to last which-key binding"), } satisfies Record -type KeybindName = keyof typeof Definitions & string +type KeybindName = keyof typeof Definitions +const KeybindNames = new Set(Object.keys(Definitions)) -const KeybindShape = Object.fromEntries( - Object.entries(Definitions).map(([name, item]) => [ - name, - BindingValueSchema.optional().default(item.default).describe(item.description), - ]), -) as Record>> - -const KeybindOverrideShape = Object.fromEntries( - Object.entries(Definitions).map(([name, item]) => [name, BindingValueSchema.optional().describe(item.description)]), -) as Record> - -export const Keybinds = z.strictObject(KeybindShape).describe("TUI keybinding configuration") -export const KeybindOverrides = z.strictObject(KeybindOverrideShape).describe("TUI keybinding overrides") +export const KeybindOverrides = Schema.Struct( + Object.fromEntries( + Object.entries(Definitions).map(([name, item]) => [ + name, + Schema.optional(BindingValueSchema).annotate({ description: item.description }), + ]), + ), +).annotate({ description: "TUI keybinding overrides" }) export const Descriptions = Object.fromEntries( Object.entries(Definitions).map(([name, item]) => [name, item.description]), ) as Record @@ -387,8 +389,8 @@ const CommandDescriptions = Object.fromEntries( ]), ) as Record -export type Keybinds = z.output -export type KeybindOverrides = z.output +export type Keybinds = { [K in KeybindName]: BindingValueSchema } +export type KeybindOverrides = Partial export type BindingLookupView = { readonly bindings: readonly Binding[] get(command: string): readonly Binding[] @@ -402,6 +404,29 @@ export function toBindingConfig(keybinds: Keybinds): BindingConfig } +const decodeBindingValue = Schema.decodeUnknownSync(BindingValueSchema) + +export function defaultValue(name: KeybindName) { + return Definitions[name].default +} + +export function parse(keybinds: KeybindOverrides): Keybinds { + const invalid = unknownKeys(keybinds) + if (invalid.length) throw new Error(`Unrecognized keybind${invalid.length === 1 ? "" : "s"}: ${invalid.join(", ")}`) + return Object.fromEntries( + Object.entries(Definitions).map(([name, item]) => [ + name, + decodeBindingValue(keybinds[name as KeybindName] ?? item.default), + ]), + ) as Keybinds +} + +export const Keybinds = { parse } + +export function unknownKeys(input: object) { + return Object.keys(input).filter((key) => !KeybindNames.has(key)) +} + export function bindingDefaults(): BindingDefaults { return ({ command, binding }) => { if (binding.desc !== undefined) return diff --git a/packages/opencode/src/cli/cmd/tui/config/tui-json-schema.ts b/packages/opencode/src/cli/cmd/tui/config/tui-json-schema.ts deleted file mode 100644 index 7784ce3ac6..0000000000 --- a/packages/opencode/src/cli/cmd/tui/config/tui-json-schema.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { ConfigPlugin } from "@/config/plugin" -import { Schema } from "effect" -import { TuiKeybind } from "./keybind" - -const KeymapLeaderTimeout = Schema.Int.check(Schema.isGreaterThan(0)).annotate({ - description: "Leader key timeout in milliseconds", -}) - -const KeyStroke = Schema.Struct({ - name: Schema.String, - ctrl: Schema.optional(Schema.Boolean), - shift: Schema.optional(Schema.Boolean), - meta: Schema.optional(Schema.Boolean), - super: Schema.optional(Schema.Boolean), - hyper: Schema.optional(Schema.Boolean), -}) - -const BindingObject = Schema.StructWithRest( - Schema.Struct({ - key: Schema.Union([Schema.String, KeyStroke]), - event: Schema.optional(Schema.Literals(["press", "release"])), - preventDefault: Schema.optional(Schema.Boolean), - fallthrough: Schema.optional(Schema.Boolean), - }), - [Schema.Record(Schema.String, Schema.Unknown)], -) - -const BindingItem = Schema.Union([Schema.String, KeyStroke, BindingObject]) -const BindingValue = Schema.Union([ - Schema.Literal(false), - Schema.Literal("none"), - BindingItem, - Schema.Array(BindingItem), -]) - -const KeybindOverrides = Schema.Struct( - Object.fromEntries( - Object.entries(TuiKeybind.Definitions).map(([name, item]) => [ - name, - Schema.optional(BindingValue).annotate({ description: item.description }), - ]), - ), -).annotate({ description: "TUI keybinding overrides" }) - -export const Info = Schema.Struct({ - $schema: Schema.optional(Schema.String), - theme: Schema.optional(Schema.String), - keybinds: Schema.optional(KeybindOverrides), - plugin: Schema.optional(Schema.Array(ConfigPlugin.Spec)), - plugin_enabled: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), - leader_timeout: Schema.optional(KeymapLeaderTimeout), - scroll_speed: Schema.optional(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0.001))).annotate({ - description: "TUI scroll speed", - }), - scroll_acceleration: Schema.optional( - Schema.Struct({ - enabled: Schema.Boolean.annotate({ description: "Enable scroll acceleration" }), - }), - ).annotate({ description: "Scroll acceleration settings" }), - diff_style: Schema.optional(Schema.Literals(["auto", "stacked"])).annotate({ - description: "Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column", - }), - mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable or disable mouse capture (default: true)" }), -}) - -export * as TuiJsonSchema from "./tui-json-schema" diff --git a/packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts b/packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts index b90ce2a414..b4dc02d3b8 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts @@ -1,8 +1,8 @@ import path from "path" import { type ParseError as JsoncParseError, applyEdits, modify, parse as parseJsonc } from "jsonc-parser" import { unique } from "remeda" -import z from "zod" -import { TuiInfo, TuiOptions } from "./tui-schema" +import { Option, Schema } from "effect" +import { DiffStyle, ScrollAcceleration, ScrollSpeed } from "./tui-schema" import { Flag } from "@opencode-ai/core/flag/flag" import { Global } from "@opencode-ai/core/global" import { Filesystem } from "@/util/filesystem" @@ -13,16 +13,11 @@ const log = Log.create({ service: "tui.migrate" }) const TUI_SCHEMA_URL = "https://opencode.ai/tui.json" -const LegacyTheme = TuiInfo.shape.theme.optional() -const LegacyRecord = z.record(z.string(), z.unknown()).optional() - -const TuiLegacy = z - .object({ - scroll_speed: TuiOptions.shape.scroll_speed.catch(undefined), - scroll_acceleration: TuiOptions.shape.scroll_acceleration.catch(undefined), - diff_style: TuiOptions.shape.diff_style.catch(undefined), - }) - .strip() +const decodeTheme = Schema.decodeUnknownOption(Schema.String) +const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Unknown)) +const decodeScrollSpeed = Schema.decodeUnknownOption(ScrollSpeed) +const decodeScrollAcceleration = Schema.decodeUnknownOption(ScrollAcceleration) +const decodeDiffStyle = Schema.decodeUnknownOption(DiffStyle) interface MigrateInput { cwd: string @@ -46,13 +41,13 @@ export async function migrateTuiConfig(input: MigrateInput) { const data = parseJsonc(source, errors, { allowTrailingComma: true }) if (errors.length || !data || typeof data !== "object" || Array.isArray(data)) continue - const theme = LegacyTheme.safeParse("theme" in data ? data.theme : undefined) - const keybinds = LegacyRecord.safeParse("keybinds" in data ? data.keybinds : undefined) - const legacyTui = LegacyRecord.safeParse("tui" in data ? data.tui : undefined) + const theme = decodeTheme("theme" in data ? data.theme : undefined) + const keybinds = decodeRecord("keybinds" in data ? data.keybinds : undefined) + const legacyTui = decodeRecord("tui" in data ? data.tui : undefined) const extracted = { - theme: theme.success ? theme.data : undefined, - keybinds: keybinds.success ? keybinds.data : undefined, - tui: legacyTui.success ? legacyTui.data : undefined, + theme: Option.getOrUndefined(theme), + keybinds: Option.getOrUndefined(keybinds), + tui: Option.getOrUndefined(legacyTui), } const tui = extracted.tui ? normalizeTui(extracted.tui) : undefined if (extracted.theme === undefined && extracted.keybinds === undefined && !tui) continue @@ -85,16 +80,23 @@ export async function migrateTuiConfig(input: MigrateInput) { } } -function normalizeTui(data: Record) { - const parsed = TuiLegacy.parse(data) - if ( - parsed.scroll_speed === undefined && +function normalizeTui(data: Record): + | { + scroll_speed: number | undefined + scroll_acceleration: { enabled: boolean } | undefined + diff_style: "auto" | "stacked" | undefined + } + | undefined { + const parsed = { + scroll_speed: Option.getOrUndefined(decodeScrollSpeed(data.scroll_speed)), + scroll_acceleration: Option.getOrUndefined(decodeScrollAcceleration(data.scroll_acceleration)), + diff_style: Option.getOrUndefined(decodeDiffStyle(data.diff_style)), + } + return parsed.scroll_speed === undefined && parsed.diff_style === undefined && parsed.scroll_acceleration === undefined - ) { - return - } - return parsed + ? undefined + : parsed } async function backupAndStripLegacy(file: string, source: string) { diff --git a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts index 318a702464..80765da3c7 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts @@ -1,33 +1,33 @@ -import z from "zod" import { ConfigPlugin } from "@/config/plugin" import { TuiKeybind } from "./keybind" +import { Schema } from "effect" export const KeymapLeaderTimeoutDefault = 2000 -const KeymapLeaderTimeout = z.number().int().positive().describe("Leader key timeout in milliseconds") - -export const TuiOptions = z.object({ - leader_timeout: KeymapLeaderTimeout.optional(), - scroll_speed: z.number().min(0.001).optional().describe("TUI scroll speed"), - scroll_acceleration: z - .object({ - enabled: z.boolean().describe("Enable scroll acceleration"), - }) - .optional() - .describe("Scroll acceleration settings"), - diff_style: z - .enum(["auto", "stacked"]) - .optional() - .describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"), - mouse: z.boolean().optional().describe("Enable or disable mouse capture (default: true)"), +const KeymapLeaderTimeout = Schema.Int.check(Schema.isGreaterThan(0)).annotate({ + description: "Leader key timeout in milliseconds", }) -export const TuiInfo = z - .object({ - $schema: z.string().optional(), - theme: z.string().optional(), - keybinds: TuiKeybind.KeybindOverrides.optional(), - plugin: ConfigPlugin.Spec.zod.array().optional(), - plugin_enabled: z.record(z.string(), z.boolean()).optional(), - }) - .extend(TuiOptions.shape) - .strict() +export const ScrollSpeed = Schema.Number.check(Schema.isGreaterThanOrEqualTo(0.001)) + +export const ScrollAcceleration = Schema.Struct({ + enabled: Schema.Boolean.annotate({ description: "Enable scroll acceleration" }), +}).annotate({ description: "Scroll acceleration settings" }) + +export const DiffStyle = Schema.Literals(["auto", "stacked"]).annotate({ + description: "Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column", +}) + +export const TuiInfo = Schema.Struct({ + $schema: Schema.optional(Schema.String), + theme: Schema.optional(Schema.String), + keybinds: Schema.optional(TuiKeybind.KeybindOverrides), + plugin: Schema.optional(Schema.Array(ConfigPlugin.Spec)), + plugin_enabled: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), + leader_timeout: Schema.optional(KeymapLeaderTimeout), + scroll_speed: Schema.optional(ScrollSpeed).annotate({ + description: "TUI scroll speed", + }), + scroll_acceleration: Schema.optional(ScrollAcceleration), + diff_style: Schema.optional(DiffStyle), + mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable or disable mouse capture (default: true)" }), +}) diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index 572f50c4d1..e53e20d343 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -1,9 +1,8 @@ export * as TuiConfig from "./tui" -import type z from "zod" import { createBindingLookup } from "@opentui/keymap/extras" import { mergeDeep, unique } from "remeda" -import { Context, Effect, Fiber, Layer } from "effect" +import { Context, Effect, Fiber, Layer, Schema } from "effect" import { ConfigParse } from "@/config/parse" import { InvalidError } from "@/config/error" import * as ConfigPaths from "@/config/paths" @@ -22,11 +21,12 @@ import { Filesystem } from "@/util/filesystem" import * as Log from "@opencode-ai/core/util/log" import { ConfigVariable } from "@/config/variable" import { Npm } from "@opencode-ai/core/npm" +import type { DeepMutable } from "@opencode-ai/core/schema" const log = Log.create({ service: "tui.config" }) export const Info = TuiInfo -export type Info = z.output +export type Info = DeepMutable> type Acc = { result: Info @@ -91,9 +91,17 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: if (!isRecord(data)) return {} as Info // Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json // (mirroring the old opencode.json shape) still get their settings applied. - const parsed = Info.safeParse(normalize(data)) - if (!parsed.success) throw new InvalidError({ path: configFilepath, issues: parsed.error.issues }) - const validated = parsed.data + const normalized = normalize(data) + if (isRecord(normalized.keybinds)) { + const invalid = TuiKeybind.unknownKeys(normalized.keybinds) + if (invalid.length) { + throw new InvalidError({ + path: configFilepath, + message: `Unrecognized keybind${invalid.length === 1 ? "" : "s"}: ${invalid.join(", ")}`, + }) + } + } + const validated = ConfigParse.schema(Info, normalized, configFilepath) return yield* resolvePlugins(validated, configFilepath) }).pipe( // catchCause (not tapErrorCause + orElseSucceed) because JSONC parsing and validation @@ -179,16 +187,14 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: } } - const keybinds = { ...(acc.result.keybinds ?? {}) } + const keybinds = { ...acc.result.keybinds } if (process.platform === "win32") { // Native Windows terminals do not support POSIX suspend, so prefer prompt undo. keybinds.terminal_suspend = "none" - keybinds.input_undo ??= unique([ - "ctrl+z", - ...String(TuiKeybind.Keybinds.shape.input_undo.parse(undefined)).split(","), - ]).join(",") + const inputUndo = TuiKeybind.defaultValue("input_undo") + keybinds.input_undo ??= unique(["ctrl+z", ...(typeof inputUndo === "string" ? inputUndo.split(",") : [])]).join(",") } - const parsedKeybinds = TuiKeybind.Keybinds.parse(keybinds) + const parsedKeybinds = TuiKeybind.parse(keybinds) const result: Resolved = { ...acc.result, keybinds: createBindingLookup(TuiKeybind.toBindingConfig(parsedKeybinds), { diff --git a/packages/opencode/src/config/plugin.ts b/packages/opencode/src/config/plugin.ts index c70442427c..1c4d4037eb 100644 --- a/packages/opencode/src/config/plugin.ts +++ b/packages/opencode/src/config/plugin.ts @@ -2,8 +2,6 @@ import { Glob } from "@opencode-ai/core/util/glob" import { Schema } from "effect" import { pathToFileURL } from "url" import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared" -import { zod } from "@opencode-ai/core/effect-zod" -import { withStatics } from "@opencode-ai/core/schema" import path from "path" export const Options = Schema.Record(Schema.String, Schema.Unknown) @@ -11,9 +9,7 @@ export type Options = Schema.Schema.Type // Spec is the user-config value: either just a plugin identifier, or the identifier plus inline options. // It answers "what should we load?" but says nothing about where that value came from. -export const Spec = Schema.Union([Schema.String, Schema.mutable(Schema.Tuple([Schema.String, Options]))]).pipe( - withStatics((s) => ({ zod: zod(s) })), -) +export const Spec = Schema.Union([Schema.String, Schema.mutable(Schema.Tuple([Schema.String, Options]))]) export type Spec = Schema.Schema.Type export type Scope = "global" | "local"