Sfoglia il codice sorgente

Generate TUI schema from Effect Schema (#26945)

Kit Langton 3 mesi fa
parent
commit
812668ae2f

+ 55 - 3
packages/opencode/script/schema.ts

@@ -3,7 +3,10 @@
 import { z } from "zod"
 import { Config } from "@/config/config"
 import { zodObject } from "@opencode-ai/core/effect-zod"
-import { TuiConfig } from "../src/cli/cmd/tui/config/tui"
+import { TuiJsonSchema } from "../src/cli/cmd/tui/config/tui-json-schema"
+import { Schema } from "effect"
+
+type JsonSchema = Record<string, unknown>
 
 function generate(schema: z.ZodType) {
   const result = z.toJSONSchema(schema, {
@@ -34,7 +37,7 @@ function generate(schema: z.ZodType) {
           schema.examples = [schema.default]
         }
 
-        schema.description = [schema.description || "", `default: \`${String(schema.default)}\``]
+        schema.description = [schema.description || "", `default: \`${formatDefault(schema.default)}\``]
           .filter(Boolean)
           .join("\n\n")
           .trim()
@@ -52,6 +55,55 @@ function generate(schema: z.ZodType) {
   return result
 }
 
+function formatDefault(value: unknown) {
+  if (typeof value !== "object" || value === null) return String(value)
+  return JSON.stringify(value)
+}
+
+function generateEffect(schema: Schema.Top) {
+  const document = Schema.toJsonSchemaDocument(schema)
+  const normalized = normalize({
+    $schema: "https://json-schema.org/draft/2020-12/schema",
+    ...document.schema,
+    $defs: document.definitions,
+  })
+  if (!isRecord(normalized)) throw new Error("schema generator produced a non-object schema")
+  normalized.allowComments = true
+  normalized.allowTrailingCommas = true
+  return normalized
+}
+
+function normalize(value: unknown): unknown {
+  if (Array.isArray(value)) return value.map(normalize)
+  if (!isRecord(value)) return value
+
+  const schema = Object.fromEntries(Object.entries(value).map(([key, item]) => [key, normalize(item)]))
+
+  if (Array.isArray(schema.anyOf)) {
+    const anyOf = schema.anyOf.filter((item) => !isRecord(item) || item.type !== "null")
+    if (anyOf.length !== schema.anyOf.length) {
+      const { anyOf: _, ...rest } = schema
+      if (anyOf.length === 1 && isRecord(anyOf[0])) return normalize({ ...anyOf[0], ...rest })
+      return { ...rest, anyOf }
+    }
+  }
+
+  if (Array.isArray(schema.allOf) && schema.allOf.length === 1 && isRecord(schema.allOf[0])) {
+    const { allOf: _, ...rest } = schema
+    return normalize({ ...schema.allOf[0], ...rest })
+  }
+
+  if (schema.type === "integer" && schema.maximum === undefined) {
+    return { ...schema, maximum: Number.MAX_SAFE_INTEGER }
+  }
+
+  return schema
+}
+
+function isRecord(value: unknown): value is JsonSchema {
+  return typeof value === "object" && value !== null && !Array.isArray(value)
+}
+
 const configFile = process.argv[2]
 const tuiFile = process.argv[3]
 
@@ -60,5 +112,5 @@ await Bun.write(configFile, JSON.stringify(generate(zodObject(Config.Info).stric
 
 if (tuiFile) {
   console.log(tuiFile)
-  await Bun.write(tuiFile, JSON.stringify(generate(TuiConfig.JsonSchemaInfo), null, 2))
+  await Bun.write(tuiFile, JSON.stringify(generateEffect(TuiJsonSchema.Info), null, 2))
 }

+ 2 - 2
packages/opencode/src/cli/cmd/tui/config/keybind.ts

@@ -2,7 +2,7 @@ export * as TuiKeybind from "./keybind"
 
 import type { KeyEvent, Renderable } from "@opentui/core"
 import type { Binding } from "@opentui/keymap"
-import type { BindingCommandMap, BindingConfig, BindingDefaults, BindingValue } from "@opentui/keymap/extras"
+import type { BindingCommandMap, BindingConfig, BindingDefaults } from "@opentui/keymap/extras"
 import z from "zod"
 
 const KeyStroke = z
@@ -38,7 +38,7 @@ export const LeaderDefault = "ctrl+x"
 
 const keybind = (value: Definition["default"], description: string): Definition => ({ default: value, description })
 
-const Definitions = {
+export const Definitions = {
   leader: keybind(LeaderDefault, "Leader key for keybind combinations"),
 
   app_exit: keybind("ctrl+c,ctrl+d,<leader>q", "Exit the application"),

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

@@ -0,0 +1,66 @@
+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"

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

@@ -31,5 +31,3 @@ export const TuiInfo = z
   })
   .extend(TuiOptions.shape)
   .strict()
-
-export const TuiJsonSchemaInfo = TuiInfo

+ 1 - 2
packages/opencode/src/cli/cmd/tui/config/tui.ts

@@ -8,7 +8,7 @@ import { ConfigParse } from "@/config/parse"
 import { InvalidError } from "@/config/error"
 import * as ConfigPaths from "@/config/paths"
 import { migrateTuiConfig } from "./tui-migrate"
-import { KeymapLeaderTimeoutDefault, TuiInfo, TuiJsonSchemaInfo } from "./tui-schema"
+import { KeymapLeaderTimeoutDefault, TuiInfo } from "./tui-schema"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { isRecord } from "@/util/record"
 import { Global } from "@opencode-ai/core/global"
@@ -26,7 +26,6 @@ import { Npm } from "@opencode-ai/core/npm"
 const log = Log.create({ service: "tui.config" })
 
 export const Info = TuiInfo
-export const JsonSchemaInfo = TuiJsonSchemaInfo
 export type Info = z.output<typeof Info>
 
 type Acc = {