Validate TUI config with Effect Schema (#26952)
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { Schema } from "effect"
|
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>
|
type JsonSchema = Record<string, unknown>
|
||||||
const MODEL_REF = "https://models.dev/model-schema.json#/$defs/Model"
|
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) {
|
if (tuiFile) {
|
||||||
console.log(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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,33 +3,39 @@ export * as TuiKeybind from "./keybind"
|
|||||||
import type { KeyEvent, Renderable } from "@opentui/core"
|
import type { KeyEvent, Renderable } from "@opentui/core"
|
||||||
import type { Binding } from "@opentui/keymap"
|
import type { Binding } from "@opentui/keymap"
|
||||||
import type { BindingCommandMap, BindingConfig, BindingDefaults } from "@opentui/keymap/extras"
|
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
|
const KeyStroke = Schema.Struct({
|
||||||
.object({
|
name: Schema.String,
|
||||||
name: z.string(),
|
ctrl: Schema.optional(Schema.Boolean),
|
||||||
ctrl: z.boolean().optional(),
|
shift: Schema.optional(Schema.Boolean),
|
||||||
shift: z.boolean().optional(),
|
meta: Schema.optional(Schema.Boolean),
|
||||||
meta: z.boolean().optional(),
|
super: Schema.optional(Schema.Boolean),
|
||||||
super: z.boolean().optional(),
|
hyper: Schema.optional(Schema.Boolean),
|
||||||
hyper: z.boolean().optional(),
|
})
|
||||||
})
|
|
||||||
.strict()
|
|
||||||
|
|
||||||
const BindingObject = z
|
const BindingObject = Schema.StructWithRest(
|
||||||
.object({
|
Schema.Struct({
|
||||||
key: z.union([z.string(), KeyStroke]),
|
key: Schema.Union([Schema.String, KeyStroke]),
|
||||||
event: z.enum(["press", "release"]).optional(),
|
event: Schema.optional(Schema.Literals(["press", "release"])),
|
||||||
preventDefault: z.boolean().optional(),
|
preventDefault: Schema.optional(Schema.Boolean),
|
||||||
fallthrough: z.boolean().optional(),
|
fallthrough: Schema.optional(Schema.Boolean),
|
||||||
})
|
}),
|
||||||
.passthrough()
|
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||||
|
)
|
||||||
|
|
||||||
const BindingItem = z.union([z.string(), KeyStroke, BindingObject])
|
const BindingItem = Schema.Union([Schema.String, KeyStroke, BindingObject])
|
||||||
export const BindingValueSchema = z.union([z.literal(false), z.literal("none"), BindingItem, z.array(BindingItem)])
|
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 = {
|
type Definition = {
|
||||||
default: z.input<typeof BindingValueSchema>
|
default: BindingValueSchema
|
||||||
description: string
|
description: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,21 +220,17 @@ export const Definitions = {
|
|||||||
which_key_end: keybind("ctrl+alt+end", "Jump to last which-key binding"),
|
which_key_end: keybind("ctrl+alt+end", "Jump to last which-key binding"),
|
||||||
} satisfies Record<string, Definition>
|
} 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(
|
export const KeybindOverrides = Schema.Struct(
|
||||||
Object.entries(Definitions).map(([name, item]) => [
|
Object.fromEntries(
|
||||||
name,
|
Object.entries(Definitions).map(([name, item]) => [
|
||||||
BindingValueSchema.optional().default(item.default).describe(item.description),
|
name,
|
||||||
]),
|
Schema.optional(BindingValueSchema).annotate({ description: item.description }),
|
||||||
) as Record<KeybindName, z.ZodDefault<z.ZodOptional<typeof BindingValueSchema>>>
|
]),
|
||||||
|
),
|
||||||
const KeybindOverrideShape = Object.fromEntries(
|
).annotate({ description: "TUI keybinding overrides" })
|
||||||
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 Descriptions = Object.fromEntries(
|
export const Descriptions = Object.fromEntries(
|
||||||
Object.entries(Definitions).map(([name, item]) => [name, item.description]),
|
Object.entries(Definitions).map(([name, item]) => [name, item.description]),
|
||||||
) as Record<KeybindName, string>
|
) as Record<KeybindName, string>
|
||||||
@@ -387,8 +389,8 @@ const CommandDescriptions = Object.fromEntries(
|
|||||||
]),
|
]),
|
||||||
) as Record<string, string>
|
) as Record<string, string>
|
||||||
|
|
||||||
export type Keybinds = z.output<typeof Keybinds>
|
export type Keybinds = { [K in KeybindName]: BindingValueSchema }
|
||||||
export type KeybindOverrides = z.output<typeof KeybindOverrides>
|
export type KeybindOverrides = Partial<Keybinds>
|
||||||
export type BindingLookupView = {
|
export type BindingLookupView = {
|
||||||
readonly bindings: readonly Binding<Renderable, KeyEvent>[]
|
readonly bindings: readonly Binding<Renderable, KeyEvent>[]
|
||||||
get(command: string): 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>
|
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> {
|
export function bindingDefaults(): BindingDefaults<Renderable, KeyEvent> {
|
||||||
return ({ command, binding }) => {
|
return ({ command, binding }) => {
|
||||||
if (binding.desc !== undefined) return
|
if (binding.desc !== undefined) return
|
||||||
|
|||||||
@@ -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"
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { type ParseError as JsoncParseError, applyEdits, modify, parse as parseJsonc } from "jsonc-parser"
|
import { type ParseError as JsoncParseError, applyEdits, modify, parse as parseJsonc } from "jsonc-parser"
|
||||||
import { unique } from "remeda"
|
import { unique } from "remeda"
|
||||||
import z from "zod"
|
import { Option, Schema } from "effect"
|
||||||
import { TuiInfo, TuiOptions } from "./tui-schema"
|
import { DiffStyle, ScrollAcceleration, ScrollSpeed } from "./tui-schema"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
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 TUI_SCHEMA_URL = "https://opencode.ai/tui.json"
|
||||||
|
|
||||||
const LegacyTheme = TuiInfo.shape.theme.optional()
|
const decodeTheme = Schema.decodeUnknownOption(Schema.String)
|
||||||
const LegacyRecord = z.record(z.string(), z.unknown()).optional()
|
const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Unknown))
|
||||||
|
const decodeScrollSpeed = Schema.decodeUnknownOption(ScrollSpeed)
|
||||||
const TuiLegacy = z
|
const decodeScrollAcceleration = Schema.decodeUnknownOption(ScrollAcceleration)
|
||||||
.object({
|
const decodeDiffStyle = Schema.decodeUnknownOption(DiffStyle)
|
||||||
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()
|
|
||||||
|
|
||||||
interface MigrateInput {
|
interface MigrateInput {
|
||||||
cwd: string
|
cwd: string
|
||||||
@@ -46,13 +41,13 @@ export async function migrateTuiConfig(input: MigrateInput) {
|
|||||||
const data = parseJsonc(source, errors, { allowTrailingComma: true })
|
const data = parseJsonc(source, errors, { allowTrailingComma: true })
|
||||||
if (errors.length || !data || typeof data !== "object" || Array.isArray(data)) continue
|
if (errors.length || !data || typeof data !== "object" || Array.isArray(data)) continue
|
||||||
|
|
||||||
const theme = LegacyTheme.safeParse("theme" in data ? data.theme : undefined)
|
const theme = decodeTheme("theme" in data ? data.theme : undefined)
|
||||||
const keybinds = LegacyRecord.safeParse("keybinds" in data ? data.keybinds : undefined)
|
const keybinds = decodeRecord("keybinds" in data ? data.keybinds : undefined)
|
||||||
const legacyTui = LegacyRecord.safeParse("tui" in data ? data.tui : undefined)
|
const legacyTui = decodeRecord("tui" in data ? data.tui : undefined)
|
||||||
const extracted = {
|
const extracted = {
|
||||||
theme: theme.success ? theme.data : undefined,
|
theme: Option.getOrUndefined(theme),
|
||||||
keybinds: keybinds.success ? keybinds.data : undefined,
|
keybinds: Option.getOrUndefined(keybinds),
|
||||||
tui: legacyTui.success ? legacyTui.data : undefined,
|
tui: Option.getOrUndefined(legacyTui),
|
||||||
}
|
}
|
||||||
const tui = extracted.tui ? normalizeTui(extracted.tui) : undefined
|
const tui = extracted.tui ? normalizeTui(extracted.tui) : undefined
|
||||||
if (extracted.theme === undefined && extracted.keybinds === undefined && !tui) continue
|
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>) {
|
function normalizeTui(data: Record<string, unknown>):
|
||||||
const parsed = TuiLegacy.parse(data)
|
| {
|
||||||
if (
|
scroll_speed: number | undefined
|
||||||
parsed.scroll_speed === 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.diff_style === undefined &&
|
||||||
parsed.scroll_acceleration === undefined
|
parsed.scroll_acceleration === undefined
|
||||||
) {
|
? undefined
|
||||||
return
|
: parsed
|
||||||
}
|
|
||||||
return parsed
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function backupAndStripLegacy(file: string, source: string) {
|
async function backupAndStripLegacy(file: string, source: string) {
|
||||||
|
|||||||
@@ -1,33 +1,33 @@
|
|||||||
import z from "zod"
|
|
||||||
import { ConfigPlugin } from "@/config/plugin"
|
import { ConfigPlugin } from "@/config/plugin"
|
||||||
import { TuiKeybind } from "./keybind"
|
import { TuiKeybind } from "./keybind"
|
||||||
|
import { Schema } from "effect"
|
||||||
|
|
||||||
export const KeymapLeaderTimeoutDefault = 2000
|
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 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 TuiInfo = z
|
export const ScrollSpeed = Schema.Number.check(Schema.isGreaterThanOrEqualTo(0.001))
|
||||||
.object({
|
|
||||||
$schema: z.string().optional(),
|
export const ScrollAcceleration = Schema.Struct({
|
||||||
theme: z.string().optional(),
|
enabled: Schema.Boolean.annotate({ description: "Enable scroll acceleration" }),
|
||||||
keybinds: TuiKeybind.KeybindOverrides.optional(),
|
}).annotate({ description: "Scroll acceleration settings" })
|
||||||
plugin: ConfigPlugin.Spec.zod.array().optional(),
|
|
||||||
plugin_enabled: z.record(z.string(), z.boolean()).optional(),
|
export const DiffStyle = Schema.Literals(["auto", "stacked"]).annotate({
|
||||||
})
|
description: "Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column",
|
||||||
.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)" }),
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
export * as TuiConfig from "./tui"
|
export * as TuiConfig from "./tui"
|
||||||
|
|
||||||
import type z from "zod"
|
|
||||||
import { createBindingLookup } from "@opentui/keymap/extras"
|
import { createBindingLookup } from "@opentui/keymap/extras"
|
||||||
import { mergeDeep, unique } from "remeda"
|
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 { ConfigParse } from "@/config/parse"
|
||||||
import { InvalidError } from "@/config/error"
|
import { InvalidError } from "@/config/error"
|
||||||
import * as ConfigPaths from "@/config/paths"
|
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 * as Log from "@opencode-ai/core/util/log"
|
||||||
import { ConfigVariable } from "@/config/variable"
|
import { ConfigVariable } from "@/config/variable"
|
||||||
import { Npm } from "@opencode-ai/core/npm"
|
import { Npm } from "@opencode-ai/core/npm"
|
||||||
|
import type { DeepMutable } from "@opencode-ai/core/schema"
|
||||||
|
|
||||||
const log = Log.create({ service: "tui.config" })
|
const log = Log.create({ service: "tui.config" })
|
||||||
|
|
||||||
export const Info = TuiInfo
|
export const Info = TuiInfo
|
||||||
export type Info = z.output<typeof Info>
|
export type Info = DeepMutable<Schema.Schema.Type<typeof Info>>
|
||||||
|
|
||||||
type Acc = {
|
type Acc = {
|
||||||
result: Info
|
result: Info
|
||||||
@@ -91,9 +91,17 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
|
|||||||
if (!isRecord(data)) return {} as Info
|
if (!isRecord(data)) return {} as Info
|
||||||
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
|
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
|
||||||
// (mirroring the old opencode.json shape) still get their settings applied.
|
// (mirroring the old opencode.json shape) still get their settings applied.
|
||||||
const parsed = Info.safeParse(normalize(data))
|
const normalized = normalize(data)
|
||||||
if (!parsed.success) throw new InvalidError({ path: configFilepath, issues: parsed.error.issues })
|
if (isRecord(normalized.keybinds)) {
|
||||||
const validated = parsed.data
|
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)
|
return yield* resolvePlugins(validated, configFilepath)
|
||||||
}).pipe(
|
}).pipe(
|
||||||
// catchCause (not tapErrorCause + orElseSucceed) because JSONC parsing and validation
|
// 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") {
|
if (process.platform === "win32") {
|
||||||
// Native Windows terminals do not support POSIX suspend, so prefer prompt undo.
|
// Native Windows terminals do not support POSIX suspend, so prefer prompt undo.
|
||||||
keybinds.terminal_suspend = "none"
|
keybinds.terminal_suspend = "none"
|
||||||
keybinds.input_undo ??= unique([
|
const inputUndo = TuiKeybind.defaultValue("input_undo")
|
||||||
"ctrl+z",
|
keybinds.input_undo ??= unique(["ctrl+z", ...(typeof inputUndo === "string" ? inputUndo.split(",") : [])]).join(",")
|
||||||
...String(TuiKeybind.Keybinds.shape.input_undo.parse(undefined)).split(","),
|
|
||||||
]).join(",")
|
|
||||||
}
|
}
|
||||||
const parsedKeybinds = TuiKeybind.Keybinds.parse(keybinds)
|
const parsedKeybinds = TuiKeybind.parse(keybinds)
|
||||||
const result: Resolved = {
|
const result: Resolved = {
|
||||||
...acc.result,
|
...acc.result,
|
||||||
keybinds: createBindingLookup(TuiKeybind.toBindingConfig(parsedKeybinds), {
|
keybinds: createBindingLookup(TuiKeybind.toBindingConfig(parsedKeybinds), {
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import { Glob } from "@opencode-ai/core/util/glob"
|
|||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { pathToFileURL } from "url"
|
import { pathToFileURL } from "url"
|
||||||
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
|
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"
|
import path from "path"
|
||||||
|
|
||||||
export const Options = Schema.Record(Schema.String, Schema.Unknown)
|
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.
|
// 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.
|
// 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(
|
export const Spec = Schema.Union([Schema.String, Schema.mutable(Schema.Tuple([Schema.String, Options]))])
|
||||||
withStatics((s) => ({ zod: zod(s) })),
|
|
||||||
)
|
|
||||||
export type Spec = Schema.Schema.Type<typeof Spec>
|
export type Spec = Schema.Schema.Type<typeof Spec>
|
||||||
|
|
||||||
export type Scope = "global" | "local"
|
export type Scope = "global" | "local"
|
||||||
|
|||||||
Reference in New Issue
Block a user