diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index aa62c6c58ef..0cdceb27cd8 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -4,6 +4,8 @@ import { TextAttributes } from "@opentui/core" import { RouteProvider, useRoute } from "@tui/context/route" import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js" import { Installation } from "@/installation" +import { Config } from "@/config/config" +import { Global } from "@/global" import { Flag } from "@/flag/flag" import { DialogProvider, useDialog } from "@tui/ui/dialog" import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider" @@ -624,6 +626,16 @@ function App() { }) }) + // Subscribe to config warning events + sdk.event.on(Config.Event.Warning.type, (evt) => { + toast.show({ + variant: "warning", + title: "Config Warning", + message: evt.properties.message, + duration: 5000, + }) + }) + return ( @@ -21,11 +25,31 @@ export type CommandOption = DialogSelectOption & { suggested?: boolean } +type ParsedUnknownKeybind = { + name: string + parsed: Keybind.Info[] +} + function init() { const [registrations, setRegistrations] = createSignal[]>([]) const [suspendCount, setSuspendCount] = createSignal(0) + const [unknownKeybinds, setUnknownKeybinds] = createSignal([]) const dialog = useDialog() const keybind = useKeybind() + const toast = useToast() + const sdk = useSDK() + + // Subscribe to config warning events to get unknown keybinds + sdk.event.on(Config.Event.Warning.type, (evt) => { + if (evt.properties.type === "unknown_keybind" && evt.properties.keybinds) { + const parsed = evt.properties.keybinds.map((kb) => ({ + name: kb.name, + parsed: Keybind.parse(kb.binding), + })) + setUnknownKeybinds(parsed) + } + }) + const options = createMemo(() => { const all = registrations().flatMap((x) => x()) const suggested = all.filter((x) => x.suggested) @@ -53,6 +77,22 @@ function init() { return } } + + // Check if the pressed key matches an unknown keybind command + const evtParsed = keybind.parse(evt) + for (const { name, parsed } of unknownKeybinds()) { + for (const binding of parsed) { + if (Keybind.match(binding, evtParsed)) { + evt.preventDefault() + toast.show({ + variant: "warning", + message: `Unknown keybind command: ${name}`, + duration: 3000, + }) + return + } + } + } }) const result = { diff --git a/packages/opencode/src/cli/network.ts b/packages/opencode/src/cli/network.ts index fe5731d0713..904d0efc396 100644 --- a/packages/opencode/src/cli/network.ts +++ b/packages/opencode/src/cli/network.ts @@ -32,7 +32,7 @@ export function withNetworkOptions(yargs: Argv) { } export async function resolveNetworkOptions(args: NetworkOptions) { - const config = await Config.global() + const config = (await Config.global()).config const portExplicitlySet = process.argv.includes("--port") const hostnameExplicitlySet = process.argv.includes("--hostname") const mdnsExplicitlySet = process.argv.includes("--mdns") diff --git a/packages/opencode/src/cli/upgrade.ts b/packages/opencode/src/cli/upgrade.ts index 2d46ae39fa2..bab062bedb8 100644 --- a/packages/opencode/src/cli/upgrade.ts +++ b/packages/opencode/src/cli/upgrade.ts @@ -4,7 +4,7 @@ import { Flag } from "@/flag/flag" import { Installation } from "@/installation" export async function upgrade() { - const config = await Config.global() + const config = (await Config.global()).config const method = await Installation.method() const latest = await Installation.latest(method).catch(() => {}) if (!latest) return diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index ead3a0149b4..718fba244ad 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1,3 +1,4 @@ +import { BusEvent } from "../bus/bus-event" import { Log } from "../util/log" import path from "path" import { pathToFileURL } from "url" @@ -35,8 +36,9 @@ export namespace Config { return merged } - export const state = Instance.state(async () => { +export const state = Instance.state(async () => { const auth = await Auth.all() + const allUnknownKeybinds: Array<{ name: string; binding: string }> = [] // Load remote/well-known config first as the base layer (lowest precedence) // This allows organizations to provide default configs that users can override @@ -53,20 +55,23 @@ export namespace Config { const remoteConfig = wellknown.config ?? {} // Add $schema to prevent load() from trying to write back to a non-existent file if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json" - result = mergeConfigConcatArrays( - result, - await load(JSON.stringify(remoteConfig), `${key}/.well-known/opencode`), - ) + const loaded = await load(JSON.stringify(remoteConfig), `${key}/.well-known/opencode`) + result = mergeConfigConcatArrays(result, loaded.config) + allUnknownKeybinds.push(...loaded.unknownKeybinds) log.debug("loaded remote config from well-known", { url: key }) } } // Global user config overrides remote config - result = mergeConfigConcatArrays(result, await global()) + const globalResult = await global() + result = mergeConfigConcatArrays(result, globalResult.config) + allUnknownKeybinds.push(...globalResult.unknownKeybinds) // Custom config path overrides global if (Flag.OPENCODE_CONFIG) { - result = mergeConfigConcatArrays(result, await loadFile(Flag.OPENCODE_CONFIG)) + const loaded = await loadFile(Flag.OPENCODE_CONFIG) + result = mergeConfigConcatArrays(result, loaded.config) + allUnknownKeybinds.push(...loaded.unknownKeybinds) log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG }) } @@ -74,7 +79,9 @@ export namespace Config { for (const file of ["opencode.jsonc", "opencode.json"]) { const found = await Filesystem.findUp(file, Instance.directory, Instance.worktree) for (const resolved of found.toReversed()) { - result = mergeConfigConcatArrays(result, await loadFile(resolved)) + const loaded = await loadFile(resolved) + result = mergeConfigConcatArrays(result, loaded.config) + allUnknownKeybinds.push(...loaded.unknownKeybinds) } } @@ -83,7 +90,6 @@ export namespace Config { result = mergeConfigConcatArrays(result, JSON.parse(Flag.OPENCODE_CONFIG_CONTENT)) log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT") } - result.agent = result.agent || {} result.mode = result.mode || {} result.plugin = result.plugin || [] @@ -115,7 +121,9 @@ export namespace Config { if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) { for (const file of ["opencode.jsonc", "opencode.json"]) { log.debug(`loading config from ${path.join(dir, file)}`) - result = mergeConfigConcatArrays(result, await loadFile(path.join(dir, file))) + const loaded = await loadFile(path.join(dir, file)) + result = mergeConfigConcatArrays(result, loaded.config) + allUnknownKeybinds.push(...loaded.unknownKeybinds) // to satisfy the type checker result.agent ??= {} result.mode ??= {} @@ -178,11 +186,23 @@ export namespace Config { result.compaction = { ...result.compaction, prune: false } } + // Generate warnings for unknown keybind names (collected during pre-processing) + const warnings: Warning[] = [] + if (allUnknownKeybinds.length > 0) { + const names = allUnknownKeybinds.map((kb) => kb.name) + warnings.push({ + type: "unknown_keybind", + message: `Unknown keybind ${names.length === 1 ? "command" : "commands"}: ${names.join(", ")}`, + keybinds: allUnknownKeybinds, + }) + } + result.plugin = deduplicatePlugins(result.plugin ?? []) return { config: result, directories, + warnings, } }) @@ -758,6 +778,29 @@ export namespace Config { ref: "KeybindsConfig", }) + // Set of valid keybind names for validation + export const ValidKeybindNames = new Set(Object.keys(Keybinds.shape).filter((key) => key !== "leader")) + + export const Warning = z + .object({ + type: z.enum(["unknown_keybind"]), + message: z.string(), + keybinds: z + .array( + z.object({ + name: z.string(), + binding: z.string(), + }), + ) + .optional(), + }) + .meta({ ref: "ConfigWarning" }) + export type Warning = z.infer + + export const Event = { + Warning: BusEvent.define("config.warning", Warning), + } + export const TUI = z.object({ scroll_speed: z.number().min(0.001).optional().describe("TUI scroll speed"), scroll_acceleration: z @@ -1056,13 +1099,18 @@ export namespace Config { export type Info = z.output - export const global = lazy(async () => { - let result: Info = pipe( - {}, - mergeDeep(await loadFile(path.join(Global.Path.config, "config.json"))), - mergeDeep(await loadFile(path.join(Global.Path.config, "opencode.json"))), - mergeDeep(await loadFile(path.join(Global.Path.config, "opencode.jsonc"))), - ) + export const global = lazy(async (): Promise => { + const globalUnknownKeybinds: Array<{ name: string; binding: string }> = [] + + const configJson = await loadFile(path.join(Global.Path.config, "config.json")) + const opencodeJson = await loadFile(path.join(Global.Path.config, "opencode.json")) + const opencodeJsonc = await loadFile(path.join(Global.Path.config, "opencode.jsonc")) + + globalUnknownKeybinds.push(...configJson.unknownKeybinds) + globalUnknownKeybinds.push(...opencodeJson.unknownKeybinds) + globalUnknownKeybinds.push(...opencodeJsonc.unknownKeybinds) + + let result: Info = pipe({}, mergeDeep(configJson.config), mergeDeep(opencodeJson.config), mergeDeep(opencodeJsonc.config)) await import(path.join(Global.Path.config, "config"), { with: { @@ -1079,10 +1127,12 @@ export namespace Config { }) .catch(() => {}) - return result + return { config: result, unknownKeybinds: globalUnknownKeybinds } }) - async function loadFile(filepath: string): Promise { + type LoadResult = { config: Info; unknownKeybinds: Array<{ name: string; binding: string }> } + + async function loadFile(filepath: string): Promise { log.info("loading", { path: filepath }) let text = await Bun.file(filepath) .text() @@ -1090,7 +1140,7 @@ export namespace Config { if (err.code === "ENOENT") return throw new JsonError({ path: filepath }, { cause: err }) }) - if (!text) return {} + if (!text) return { config: {}, unknownKeybinds: [] } return load(text, filepath) } @@ -1160,6 +1210,21 @@ export namespace Config { }) } + // Pre-process keybinds: extract and strip unknown keybind keys before Zod validation + const unknownKeybinds: Array<{ name: string; binding: string }> = [] + if (data && typeof data === "object" && "keybinds" in data && data.keybinds && typeof data.keybinds === "object") { + const keybindsObj = data.keybinds as Record + for (const key of Object.keys(keybindsObj)) { + if (key !== "leader" && !ValidKeybindNames.has(key)) { + const binding = keybindsObj[key] + if (typeof binding === "string") { + unknownKeybinds.push({ name: key, binding }) + } + delete keybindsObj[key] + } + } + } + const parsed = Info.safeParse(data) if (parsed.success) { if (!parsed.data.$schema) { @@ -1175,7 +1240,7 @@ export namespace Config { } catch (err) {} } } - return data + return { config: data, unknownKeybinds } } throw new InvalidError({ @@ -1183,6 +1248,7 @@ export namespace Config { issues: parsed.error.issues, }) } + export const JsonError = NamedError.create( "ConfigJsonError", z.object({ @@ -1215,12 +1281,16 @@ export namespace Config { export async function update(config: Info) { const filepath = path.join(Instance.directory, "config.json") - const existing = await loadFile(filepath) - await Bun.write(filepath, JSON.stringify(mergeDeep(existing, config), null, 2)) + const loaded = await loadFile(filepath) + await Bun.write(filepath, JSON.stringify(mergeDeep(loaded.config, config), null, 2)) await Instance.dispose() } export async function directories() { return state().then((x) => x.directories) } + + export async function warnings() { + return state().then((x) => x.warnings) + } } diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 087eb0c628c..e4ff7b8423b 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -870,6 +870,67 @@ test("merges legacy tools with existing permission config", async () => { }) }) +test("unknown keybind names generate warnings", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + keybinds: { + unknown_command: "ctrl+x", + another_fake_command: "ctrl+y", + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const warnings = await Config.warnings() + expect(warnings.length).toBe(1) + expect(warnings[0].type).toBe("unknown_keybind") + expect(warnings[0].message).toContain("unknown_command") + expect(warnings[0].message).toContain("another_fake_command") + // Verify the keybinds array contains name and binding + expect(warnings[0].keybinds).toBeDefined() + expect(warnings[0].keybinds!.length).toBe(2) + const names = warnings[0].keybinds!.map((kb) => kb.name) + expect(names).toContain("unknown_command") + expect(names).toContain("another_fake_command") + const bindings = warnings[0].keybinds!.map((kb) => kb.binding) + expect(bindings).toContain("ctrl+x") + expect(bindings).toContain("ctrl+y") + }, + }) +}) + +test("valid keybind names do not generate warnings", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + keybinds: { + leader: "ctrl+x", + app_exit: "ctrl+q", + session_new: "ctrl+n", + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const warnings = await Config.warnings() + expect(warnings.length).toBe(0) + }, + }) +}) + test("permission config preserves key order", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index e423fecea42..6bdae9fda0e 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -40,6 +40,20 @@ export type EventProjectUpdated = { properties: Project } +export type ConfigWarning = { + type: "unknown_keybind" + message: string + keybinds?: Array<{ + name: string + binding: string + }> +} + +export type EventConfigWarning = { + type: "config.warning" + properties: ConfigWarning +} + export type EventServerInstanceDisposed = { type: "server.instance.disposed" properties: { @@ -844,6 +858,7 @@ export type Event = | EventInstallationUpdated | EventInstallationUpdateAvailable | EventProjectUpdated + | EventConfigWarning | EventServerInstanceDisposed | EventLspClientDiagnostics | EventLspUpdated diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 367985e5d29..d8efc60fa17 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -5693,6 +5693,47 @@ }, "required": ["type", "properties"] }, + "ConfigWarning": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["unknown_keybind"] + }, + "message": { + "type": "string" + }, + "keybinds": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "binding": { + "type": "string" + } + }, + "required": ["name", "binding"] + } + } + }, + "required": ["type", "message"] + }, + "Event.config.warning": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "config.warning" + }, + "properties": { + "$ref": "#/components/schemas/ConfigWarning" + } + }, + "required": ["type", "properties"] + }, "Event.server.instance.disposed": { "type": "object", "properties": { @@ -7919,6 +7960,9 @@ { "$ref": "#/components/schemas/Event.project.updated" }, + { + "$ref": "#/components/schemas/Event.config.warning" + }, { "$ref": "#/components/schemas/Event.server.instance.disposed" },