Просмотр исходного кода

Validate TUI config with Effect Schema (#26952)

Kit Langton 3 месяцев назад
Родитель
Сommit
46edc98f10

+ 2 - 2
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<string, unknown>
 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))
 }

+ 63 - 38
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<Schema.Schema.Type<typeof BindingValueSchema>>
 
 type Definition = {
-  default: z.input<typeof BindingValueSchema>
+  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<string, Definition>
 
-type KeybindName = keyof typeof Definitions & string
+type KeybindName = keyof typeof Definitions
+const KeybindNames = new Set<string>(Object.keys(Definitions))
 
-const KeybindShape = Object.fromEntries(
-  Object.entries(Definitions).map(([name, item]) => [
-    name,
-    BindingValueSchema.optional().default(item.default).describe(item.description),
-  ]),
-) as Record<KeybindName, z.ZodDefault<z.ZodOptional<typeof BindingValueSchema>>>
-
-const KeybindOverrideShape = Object.fromEntries(
-  Object.entries(Definitions).map(([name, item]) => [name, BindingValueSchema.optional().describe(item.description)]),
-) as Record<KeybindName, z.ZodOptional<typeof BindingValueSchema>>
-
-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<KeybindName, string>
@@ -387,8 +389,8 @@ const CommandDescriptions = Object.fromEntries(
   ]),
 ) as Record<string, string>
 
-export type Keybinds = z.output<typeof Keybinds>
-export type KeybindOverrides = z.output<typeof KeybindOverrides>
+export type Keybinds = { [K in KeybindName]: BindingValueSchema }
+export type KeybindOverrides = Partial<Keybinds>
 export type BindingLookupView = {
   readonly bindings: readonly Binding<Renderable, KeyEvent>[]
   get(command: string): readonly Binding<Renderable, KeyEvent>[]
@@ -402,6 +404,29 @@ export function toBindingConfig(keybinds: Keybinds): BindingConfig<Renderable, K
   return Object.fromEntries(Object.entries(keybinds)) as BindingConfig<Renderable, KeyEvent>
 }
 
+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<Renderable, KeyEvent> {
   return ({ command, binding }) => {
     if (binding.desc !== undefined) return

+ 0 - 66
packages/opencode/src/cli/cmd/tui/config/tui-json-schema.ts

@@ -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"

+ 28 - 26
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<string, unknown>) {
-  const parsed = TuiLegacy.parse(data)
-  if (
-    parsed.scroll_speed === undefined &&
+function normalizeTui(data: Record<string, unknown>):
+  | {
+      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) {

+ 26 - 26
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")
+const KeymapLeaderTimeout = Schema.Int.check(Schema.isGreaterThan(0)).annotate({
+  description: "Leader key timeout in milliseconds",
+})
+
+export const ScrollSpeed = Schema.Number.check(Schema.isGreaterThanOrEqualTo(0.001))
 
-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)"),
+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 = 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 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)" }),
+})

+ 18 - 12
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<typeof Info>
+export type Info = DeepMutable<Schema.Schema.Type<typeof Info>>
 
 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), {

+ 1 - 5
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<typeof Options>
 
 // 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<typeof Spec>
 
 export type Scope = "global" | "local"