Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
dbed38e
fix: make unknown keybind commands non-fatal with warnings
ariane-emory Dec 31, 2025
b29b897
fix: add toast when pressing keybind for unknown command
ariane-emory Dec 31, 2025
df21fc8
fix: use server-provided unknown keybinds list for toast messages
ariane-emory Dec 31, 2025
fbec33f
fix: use consistent 'Unknown keybind command' message
ariane-emory Dec 31, 2025
71f9391
fix: use proper pluralization for unknown keybind commands message
ariane-emory Dec 31, 2025
18b74aa
chore: restore unrelated duplicate block to keep branch tightly scoped
ariane-emory Dec 31, 2025
0dd8343
Merge branch 'dev' into fix/nonfatal-missing-key-commands
ariane-emory Dec 31, 2025
78984eb
Fix: Implement nonfatal missing keybind commands with pre-processing …
ariane-emory Dec 31, 2025
5d208f1
revert: Remove unnecessary null check in keybind parsing
ariane-emory Dec 31, 2025
3dfd35f
refactor: Simplify Config.global() access to single line
ariane-emory Dec 31, 2025
78285ae
chore: remove unused Event.config.warning from openapi schema
ariane-emory Dec 31, 2025
9e4520b
refactor: use BusEvent for config warnings instead of API endpoint
ariane-emory Jan 1, 2026
435081a
Merge branch 'dev' into fix/nonfatal-missing-key-commands
ariane-emory Jan 1, 2026
89610f0
Merge branch 'dev' into fix/nonfatal-missing-key-commands
ariane-emory Jan 1, 2026
201aa02
Merge branch 'dev' into fix/nonfatal-missing-key-commands
ariane-emory Jan 1, 2026
97099fc
revert: remove unnecessary formatting changes from openapi.json
ariane-emory Jan 1, 2026
557390a
fix: add ConfigWarning schema to openapi.json for SDK compatibility
ariane-emory Jan 1, 2026
15d1e54
Merge dev into fix/nonfatal-missing-key-commands
ariane-emory Jan 2, 2026
7a270e5
Merge dev into fix/nonfatal-missing-key-commands
ariane-emory Jan 3, 2026
c003898
Merge branch 'dev' into fix/nonfatal-missing-key-commands
ariane-emory Jan 3, 2026
2e28b7a
Merge branch 'dev' into fix/nonfatal-missing-key-commands
ariane-emory Jan 4, 2026
628da33
Merge branch 'dev' into fix/nonfatal-missing-key-commands
ariane-emory Jan 4, 2026
14c8fc1
Merge branch 'dev' into fix/nonfatal-missing-key-commands
ariane-emory Jan 4, 2026
d3bc26f
Merge branch 'dev' into fix/nonfatal-missing-key-commands
ariane-emory Jan 5, 2026
e400353
Merge branch 'dev' into fix/nonfatal-missing-key-commands
ariane-emory Jan 5, 2026
7d67a39
Merge branch 'dev' into fix/nonfatal-missing-key-commands
ariane-emory Jan 5, 2026
f841b56
Merge branch 'dev' into fix/nonfatal-missing-key-commands
ariane-emory Jan 5, 2026
d06d11d
Merge branch 'dev' into fix/nonfatal-missing-key-commands
ariane-emory Jan 6, 2026
32c4b8d
Merge branch 'dev' into fix/nonfatal-missing-key-commands
ariane-emory Jan 6, 2026
98137e5
Merge branch 'dev' into fix/nonfatal-missing-key-commands
ariane-emory Jan 6, 2026
470d591
Merge branch 'dev' into fix/nonfatal-missing-key-commands
ariane-emory Jan 6, 2026
be945f0
Merge branch 'dev' into fix/nonfatal-missing-key-commands
ariane-emory Jan 6, 2026
5ecf1ed
Merge branch 'dev' into fix/nonfatal-missing-key-commands
ariane-emory Jan 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -610,6 +612,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 (
<box
width={dimensions().width}
Expand Down
40 changes: 40 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ import {
} from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { useKeybind } from "@tui/context/keybind"
import { useToast } from "@tui/ui/toast"
import { useSDK } from "@tui/context/sdk"
import { Keybind } from "@/util/keybind"
import { Config } from "@/config/config"
import type { KeybindsConfig } from "@opencode-ai/sdk/v2"

type Context = ReturnType<typeof init>
Expand All @@ -21,11 +25,31 @@ export type CommandOption = DialogSelectOption & {
suggested?: boolean
}

type ParsedUnknownKeybind = {
name: string
parsed: Keybind.Info[]
}

function init() {
const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
const [suspendCount, setSuspendCount] = createSignal(0)
const [unknownKeybinds, setUnknownKeybinds] = createSignal<ParsedUnknownKeybind[]>([])
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)
Expand Down Expand Up @@ -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 = {
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/cli/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export function withNetworkOptions<T>(yargs: Argv<T>) {
}

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")
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/cli/upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
109 changes: 91 additions & 18 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { BusEvent } from "../bus/bus-event"
import { Log } from "../util/log"
import path from "path"
import { pathToFileURL } from "url"
Expand Down Expand Up @@ -37,18 +38,24 @@ export namespace Config {

export const state = Instance.state(async () => {
const auth = await Auth.all()
let result = await global()
const globalResult = await global()
let result = globalResult.config
const allUnknownKeybinds: Array<{ name: string; binding: string }> = [...globalResult.unknownKeybinds]

// Override with custom config if provided
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 })
}

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)
}
}

Expand All @@ -61,7 +68,9 @@ export namespace Config {
if (value.type === "wellknown") {
process.env[value.key] = value.token
const wellknown = (await fetch(`${key}/.well-known/opencode`).then((x) => x.json())) as any
result = mergeConfigConcatArrays(result, await load(JSON.stringify(wellknown.config ?? {}), process.cwd()))
const loaded = await load(JSON.stringify(wellknown.config ?? {}), process.cwd())
result = mergeConfigConcatArrays(result, loaded.config)
allUnknownKeybinds.push(...loaded.unknownKeybinds)
}
}

Expand Down Expand Up @@ -96,7 +105,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 ??= {}
Expand Down Expand Up @@ -159,9 +170,21 @@ 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,
})
}

return {
config: result,
directories,
warnings,
}
})

Expand Down Expand Up @@ -679,6 +702,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<typeof Warning>

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
Expand Down Expand Up @@ -977,13 +1023,18 @@ export namespace Config {

export type Info = z.output<typeof Info>

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<LoadResult> => {
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: {
Expand All @@ -1000,18 +1051,20 @@ export namespace Config {
})
.catch(() => {})

return result
return { config: result, unknownKeybinds: globalUnknownKeybinds }
})

async function loadFile(filepath: string): Promise<Info> {
type LoadResult = { config: Info; unknownKeybinds: Array<{ name: string; binding: string }> }

async function loadFile(filepath: string): Promise<LoadResult> {
log.info("loading", { path: filepath })
let text = await Bun.file(filepath)
.text()
.catch((err) => {
if (err.code === "ENOENT") return
throw new JsonError({ path: filepath }, { cause: err })
})
if (!text) return {}
if (!text) return { config: {}, unknownKeybinds: [] }
return load(text, filepath)
}

Expand Down Expand Up @@ -1081,6 +1134,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<string, unknown>
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) {
Expand All @@ -1096,14 +1164,15 @@ export namespace Config {
} catch (err) {}
}
}
return data
return { config: data, unknownKeybinds }
}

throw new InvalidError({
path: configFilepath,
issues: parsed.error.issues,
})
}

export const JsonError = NamedError.create(
"ConfigJsonError",
z.object({
Expand Down Expand Up @@ -1136,12 +1205,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)
}
}
12 changes: 12 additions & 0 deletions packages/opencode/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2772,6 +2772,18 @@ export namespace Server {
properties: {},
}),
})

// Send config warnings to newly connected client
const warnings = await Config.warnings()
for (const warning of warnings) {
stream.writeSSE({
data: JSON.stringify({
type: Config.Event.Warning.type,
properties: warning,
}),
})
}

const unsub = Bus.subscribeAll(async (event) => {
await stream.writeSSE({
data: JSON.stringify(event),
Expand Down
Loading