fix keymap fallback priority and TUI config diagnostics (#27384)

This commit is contained in:
Sebastian
2026-05-13 23:00:48 +02:00
committed by GitHub
parent c197fd92b7
commit 3b7a5e783d
7 changed files with 111 additions and 36 deletions

View File

@@ -3,9 +3,8 @@ export * as TuiConfig from "./tui"
import path from "path"
import { createBindingLookup } from "@opentui/keymap/extras"
import { mergeDeep, unique } from "remeda"
import { Context, Effect, Fiber, Layer, Schema } from "effect"
import { Cause, Context, Effect, Fiber, Layer, Schema } from "effect"
import { ConfigParse } from "@/config/parse"
import { InvalidError } from "@/config/error"
import * as ConfigPaths from "@/config/paths"
import { migrateTuiConfig } from "./tui-migrate"
import { KeymapLeaderTimeoutDefault, resolveAttentionSoundPaths, TuiInfo } from "./tui-schema"
@@ -24,6 +23,7 @@ import { ConfigVariable } from "@/config/variable"
import { Npm } from "@opencode-ai/core/npm"
import type { DeepMutable } from "@opencode-ai/core/schema"
import type { TuiAttentionSoundName } from "@opencode-ai/plugin/tui"
import { FormatError, FormatUnknownError } from "@/cli/error"
const log = Log.create({ service: "tui.config" })
@@ -79,8 +79,26 @@ function normalize(raw: Record<string, unknown>) {
}
}
function dropUnknownKeybinds(input: Record<string, unknown>, configFilepath: string) {
if (!isRecord(input.keybinds)) return input
const invalid = TuiKeybind.unknownKeys(input.keybinds)
if (!invalid.length) return input
log.warn("ignored unknown tui keybinds", {
path: configFilepath,
keybinds: invalid,
hint: "Remove these entries or rename them to keys from the tui.json schema.",
})
return {
...input,
keybinds: Object.fromEntries(Object.entries(input.keybinds).filter(([key]) => !invalid.includes(key))),
}
}
const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: string }) {
const afs = yield* AppFileSystem.Service
let appliedOrder = 0
const resolvePlugins = (config: Info, configFilepath: string): Effect.Effect<Info> =>
Effect.gen(function* () {
@@ -101,16 +119,7 @@ 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 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 normalized = dropUnknownKeybinds(normalize(data), configFilepath)
const parsed = ConfigParse.schema(Info, normalized, configFilepath)
const validated = parsed.attention?.sounds
? {
@@ -127,7 +136,12 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
// can sync-throw — those become defects, which orElseSucceed wouldn't catch.
Effect.catchCause((cause) =>
Effect.sync(() => {
log.warn("invalid tui config", { path: configFilepath, cause })
const error = Cause.squash(cause)
const reason = FormatError(error) ?? FormatUnknownError(error)
log.warn("skipping invalid tui config", {
path: configFilepath,
reason,
})
return {} as Info
}),
),
@@ -141,18 +155,28 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
const text = yield* afs.readFileStringSafe(filepath).pipe(
Effect.catchCause((cause) =>
Effect.sync(() => {
log.warn("failed to read tui config", { path: filepath, cause })
const error = Cause.squash(cause)
const reason = FormatError(error) ?? FormatUnknownError(error)
log.warn("failed to read tui config", {
path: filepath,
reason,
})
return undefined
}),
),
)
if (!text) return {} as Info
log.info("loading tui config", { path: filepath })
return yield* load(text, filepath)
})
const mergeFile = (acc: Acc, file: string) =>
Effect.gen(function* () {
const data = yield* loadFile(file)
if (Object.keys(data).length) {
appliedOrder += 1
log.info("applying tui config", { path: file, order: appliedOrder })
}
acc.result = mergeDeep(acc.result, data)
if (!data.plugin?.length) return