mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-19 11:02:50 +00:00
fix keymap fallback priority and TUI config diagnostics (#27384)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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* () {
|
||||
|
||||
@@ -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")
|
||||
}),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user