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

View File

@@ -439,6 +439,25 @@ it.instance("merges keybind overrides across precedence layers", () =>
),
)
it.instance("ignores unknown keybind names without dropping valid overrides from the same file", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const test = yield* TestInstance
yield* fs.writeJson(path.join(Global.Path.config, "tui.json"), {
keybinds: {
session_delete: "ctrl+d",
not_a_real_keybind: "ctrl+q",
},
})
const config = yield* getTuiConfig(test.directory)
expect(config.keybinds.get("session.delete")?.[0]?.key).toBe("ctrl+d")
expect(config.keybinds.get("not_a_real_keybind")).toEqual([])
}),
),
)
it.instance("resolves keybind lookup from canonical keybinds", () =>
withCleanState(
Effect.gen(function* () {

View File

@@ -47,3 +47,31 @@ it.live("init cleanup keeps the newest timestamped logs", () =>
expect(next).toContain(list.at(-1)!)
}),
)
it.live("local dev log is not truncated twice for the same run", () =>
Effect.gen(function* () {
const log = Global.Path.log
const runID = process.env.OPENCODE_RUN_ID
const initialized = process.env.OPENCODE_LOG_INITIALIZED_RUN_ID
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
Global.Path.log = log
if (runID === undefined) delete process.env.OPENCODE_RUN_ID
else process.env.OPENCODE_RUN_ID = runID
if (initialized === undefined) delete process.env.OPENCODE_LOG_INITIALIZED_RUN_ID
else process.env.OPENCODE_LOG_INITIALIZED_RUN_ID = initialized
}),
)
const dir = yield* tmpdirScoped()
Global.Path.log = dir
process.env.OPENCODE_RUN_ID = "run-1"
delete process.env.OPENCODE_LOG_INITIALIZED_RUN_ID
yield* Effect.promise(() => Log.init({ print: false, dev: true }))
yield* Effect.promise(() => fs.writeFile(path.join(dir, "dev.log"), "main startup\n"))
yield* Effect.promise(() => Log.init({ print: false, dev: true }))
expect(yield* Effect.promise(() => fs.readFile(path.join(dir, "dev.log"), "utf8"))).toContain("main startup")
}),
)