From 6053d4d02ca80a2465ced2fe36b15c149733fdd7 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Tue, 10 Feb 2026 01:26:18 -0500 Subject: [PATCH] keybinds --- src/App.tsx | 15 +- src/config/keybind.jsonc | 17 ++ src/context/KeybindContext.tsx | 216 +++++++++++-------------- src/ui/command.tsx | 256 ++++++++++++++++-------------- src/utils/jsonc.ts | 32 ++++ src/utils/keybind.ts | 187 ---------------------- src/utils/keybinds-persistence.ts | 80 ++++++++++ 7 files changed, 367 insertions(+), 436 deletions(-) create mode 100644 src/config/keybind.jsonc create mode 100644 src/utils/jsonc.ts delete mode 100644 src/utils/keybind.ts create mode 100644 src/utils/keybinds-persistence.ts diff --git a/src/App.tsx b/src/App.tsx index 08b59f3..0dc25d0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -60,13 +60,14 @@ export function App() { }); }); - //useKeyboard( - //(keyEvent) => { - ////handle intra layer navigation - //if(keyEvent.name == "up" || keyEvent.name) - //}, - //{ release: false }, // Not strictly necessary - //); + useKeyboard( + (keyEvent) => { + //handle intra layer navigation + if (keyEvent.name == "up" || keyEvent.name) { + } + }, + { release: false }, // Not strictly necessary + ); return ( q"], + "audio-toggle": ["p"], + "audio-pause": [], + "audio-play": [], + "audio-next": ["n"], + "audio-prev": ["l"], +} diff --git a/src/context/KeybindContext.tsx b/src/context/KeybindContext.tsx index d7fca75..1b811c2 100644 --- a/src/context/KeybindContext.tsx +++ b/src/context/KeybindContext.tsx @@ -1,134 +1,104 @@ -import { createMemo } from "solid-js" -import type { ParsedKey, Renderable } from "@opentui/core" -import { createStore } from "solid-js/store" -import { useKeyboard, useRenderer } from "@opentui/solid" -import { createSimpleContext } from "./helper" -import { Keybind, DEFAULT_KEYBINDS, type KeybindsConfig } from "../utils/keybind" +import { createSignal, onMount } from "solid-js"; +import { createSimpleContext } from "./helper"; +import { + copyKeybindsIfNeeded, + loadKeybindsFromFile, + saveKeybindsToFile, +} from "../utils/keybinds-persistence"; +import { createStore } from "solid-js/store"; -/** - * Keybind context provider for managing keyboard shortcuts. - * - * Features: - * - Leader key support (like vim's leader key) - * - Configurable keybindings - * - Key parsing and matching - * - Display-friendly key representations - */ -export const { use: useKeybind, provider: KeybindProvider } = createSimpleContext({ - name: "Keybind", - init: (props: { keybinds?: Partial }) => { - // Merge default keybinds with custom keybinds - const customKeybinds = props.keybinds ?? {} - const mergedKeybinds = { ...DEFAULT_KEYBINDS, ...customKeybinds } +// ── Type Definitions ──────────────────────────────────────────────────────────── - const keybinds = createMemo(() => { - const result: Record = {} - for (const [key, value] of Object.entries(mergedKeybinds)) { - result[key] = Keybind.parse(value) - } - return result - }) +export type KeybindsResolved = { + up: string[]; + down: string[]; + left: string[]; + right: string[]; + cycle: string[]; // this will cycle no matter the depth/orientation + dive: string[]; + out: string[]; + inverse: string[]; + leader: string; // will not trigger while focused on input + quit: string[]; + "audio-toggle": string[]; + "audio-pause": []; + "audio-play": string[]; + "audio-next": string[]; + "audio-prev": string[]; +}; - const [store, setStore] = createStore({ - leader: false, - }) +// ── Context Implementation ──────────────────────────────────────────────────────────── - const renderer = useRenderer() +export const { use: useKeybinds, provider: KeybindProvider } = + createSimpleContext({ + name: "Keybinds", + init: () => { + const [store, setStore] = createStore({ + up: [], + down: [], + left: [], + right: [], + cycle: [], + dive: [], + out: [], + inverse: [], + leader: "", + quit: [], + "audio-toggle": [], + "audio-pause": [], + "audio-play": [], + "audio-next": [], + "audio-prev": [], + }); + const [ready, setReady] = createSignal(false); - let focus: Renderable | null = null - let timeout: NodeJS.Timeout | undefined - - function leader(active: boolean) { - if (active) { - setStore("leader", true) - focus = renderer.currentFocusedRenderable - focus?.blur() - if (timeout) clearTimeout(timeout) - timeout = setTimeout(() => { - if (!store.leader) return - leader(false) - if (!focus || focus.isDestroyed) return - focus.focus() - }, 2000) // Leader key timeout - return + async function load() { + await copyKeybindsIfNeeded(); + const keybinds = await loadKeybindsFromFile(); + setStore(keybinds); + setReady(true); } - if (!active) { - if (focus && !renderer.currentFocusedRenderable) { - focus.focus() + async function save() { + saveKeybindsToFile(store); + } + + function print(input: keyof KeybindsResolved): string { + const keys = store[input] || []; + return Array.isArray(keys) ? keys.join(", ") : keys; + } + + function match( + keybind: keyof KeybindsResolved, + evt: { name: string; ctrl?: boolean; meta?: boolean; shift?: boolean }, + ): boolean { + const keys = store[keybind]; + if (!keys) return false; + + for (const key of keys) { + if (evt.name === key) return true; + if (evt.shift && key.toLowerCase() !== key) return false; + if (evt.ctrl && !key.toLowerCase().includes("ctrl")) return false; + if (evt.meta && !key.toLowerCase().includes("meta")) return false; } - setStore("leader", false) - } - } - - // Handle leader key - useKeyboard(async (evt) => { - // Don't intercept leader key when a text-editing renderable (input/textarea) - // has focus — let it handle text input (including space for the leader key). - const focused = renderer.currentFocusedRenderable - if (focused && "insertText" in focused) return - - if (!store.leader && result.match("leader", evt)) { - leader(true) - return + return false; } - if (store.leader && evt.name) { - setImmediate(() => { - if (focus && renderer.currentFocusedRenderable === focus) { - focus.focus() - } - leader(false) - }) - } - }) + // Load on mount + onMount(() => { + load().catch(() => {}); + }); - const result = { - get all() { - return keybinds() - }, - get leader() { - return store.leader - }, - /** - * Parse a keyboard event into a Keybind.Info. - */ - parse(evt: ParsedKey): Keybind.Info { - // Handle special case for Ctrl+Underscore (represented as \x1F) - if (evt.name === "\x1F") { - return Keybind.fromParsedKey({ ...evt, name: "_", ctrl: true }, store.leader) - } - return Keybind.fromParsedKey(evt, store.leader) - }, - /** - * Check if a keyboard event matches a registered keybind. - */ - match(key: keyof KeybindsConfig, evt: ParsedKey): boolean { - const keybind = keybinds()[key] - if (!keybind) return false - const parsed: Keybind.Info = result.parse(evt) - for (const kb of keybind) { - if (Keybind.match(kb, parsed)) { - return true - } - } - return false - }, - /** - * Get a display string for a registered keybind. - */ - print(key: keyof KeybindsConfig): string { - const first = keybinds()[key]?.at(0) - if (!first) return "" - const display = Keybind.toString(first) - // Replace leader placeholder with actual leader key - const leaderKey = keybinds().leader?.[0] - if (leaderKey) { - return display.replace("", Keybind.toString(leaderKey)) - } - return display - }, - } - return result - }, -}) + return { + get ready() { + return ready(); + }, + get keybinds() { + return store; + }, + save, + print, + match, + }; + }, + }); diff --git a/src/ui/command.tsx b/src/ui/command.tsx index d05c8db..d035ce6 100644 --- a/src/ui/command.tsx +++ b/src/ui/command.tsx @@ -8,67 +8,71 @@ import { type ParentProps, For, Show, -} from "solid-js" -import { useKeyboard, useTerminalDimensions } from "@opentui/solid" -import { useKeybind } from "../context/KeybindContext" -import { useDialog } from "./dialog" -import { useTheme } from "../context/ThemeContext" -import type { KeybindsConfig } from "../utils/keybind" -import { TextAttributes } from "@opentui/core" -import { emit } from "../utils/event-bus" +} from "solid-js"; +import { useKeyboard, useTerminalDimensions } from "@opentui/solid"; +import { KeybindsResolved, useKeybinds } from "../context/KeybindContext"; +import { useDialog } from "./dialog"; +import { useTheme } from "../context/ThemeContext"; +import { TextAttributes } from "@opentui/core"; +import { emit } from "../utils/event-bus"; /** * Command option for the command palette. */ export type CommandOption = { /** Display title */ - title: string + title: string; /** Unique identifier */ - value: string + value: string; /** Description shown below title */ - description?: string + description?: string; /** Category for grouping */ - category?: string + category?: string; /** Keybind reference */ - keybind?: keyof KeybindsConfig + keybind?: keyof KeybindsResolved; /** Whether this command is suggested */ - suggested?: boolean + suggested?: boolean; /** Slash command configuration */ slash?: { - name: string - aliases?: string[] - } + name: string; + aliases?: string[]; + }; /** Whether to hide from command list */ - hidden?: boolean + hidden?: boolean; /** Whether command is enabled */ - enabled?: boolean + enabled?: boolean; /** Footer text (usually keybind display) */ - footer?: string + footer?: string; /** Handler when command is selected */ - onSelect?: (dialog: ReturnType) => void -} + onSelect?: (dialog: ReturnType) => void; +}; -type CommandContext = ReturnType -const ctx = createContext() +type CommandContext = ReturnType; +const ctx = createContext(); function init() { - const [registrations, setRegistrations] = createSignal[]>([]) - const [suspendCount, setSuspendCount] = createSignal(0) - const dialog = useDialog() - const keybind = useKeybind() + const [registrations, setRegistrations] = createSignal< + Accessor[] + >([]); + const [suspendCount, setSuspendCount] = createSignal(0); + const dialog = useDialog(); + const keybind = useKeybinds(); const entries = createMemo(() => { - const all = registrations().flatMap((x) => x()) + const all = registrations().flatMap((x) => x()); return all.map((x) => ({ ...x, footer: x.keybind ? keybind.print(x.keybind) : undefined, - })) - }) + })); + }); - const isEnabled = (option: CommandOption) => option.enabled !== false - const isVisible = (option: CommandOption) => isEnabled(option) && !option.hidden + const isEnabled = (option: CommandOption) => option.enabled !== false; + const isVisible = (option: CommandOption) => + isEnabled(option) && !option.hidden; - const visibleOptions = createMemo(() => entries().filter((option) => isVisible(option))) + const visibleOptions = createMemo(() => + entries().filter((option) => isVisible(option)), + ); const suggestedOptions = createMemo(() => visibleOptions() .filter((option) => option.suggested) @@ -77,23 +81,23 @@ function init() { value: `suggested:${option.value}`, category: "Suggested", })), - ) - const suspended = () => suspendCount() > 0 + ); + const suspended = () => suspendCount() > 0; // Handle keybind shortcuts useKeyboard((evt) => { - if (suspended()) return - if (dialog.isOpen) return + if (suspended()) return; + if (dialog.isOpen) return; for (const option of entries()) { - if (!isEnabled(option)) continue + if (!isEnabled(option)) continue; if (option.keybind && keybind.match(option.keybind, evt)) { - evt.preventDefault() - option.onSelect?.(dialog) - emit("command.execute", { command: option.value }) - return + evt.preventDefault(); + option.onSelect?.(dialog); + emit("command.execute", { command: option.value }); + return; } } - }) + }); const result = { /** @@ -102,10 +106,10 @@ function init() { trigger(name: string) { for (const option of entries()) { if (option.value === name) { - if (!isEnabled(option)) return - option.onSelect?.(dialog) - emit("command.execute", { command: name }) - return + if (!isEnabled(option)) return; + option.onSelect?.(dialog); + emit("command.execute", { command: name }); + return; } } }, @@ -114,159 +118,163 @@ function init() { */ slashes() { return visibleOptions().flatMap((option) => { - const slash = option.slash - if (!slash) return [] + const slash = option.slash; + if (!slash) return []; return { display: "/" + slash.name, description: option.description ?? option.title, aliases: slash.aliases?.map((alias) => "/" + alias), onSelect: () => result.trigger(option.value), - } - }) + }; + }); }, /** * Enable/disable keybinds temporarily. */ keybinds(enabled: boolean) { - setSuspendCount((count) => count + (enabled ? -1 : 1)) + setSuspendCount((count) => count + (enabled ? -1 : 1)); }, suspended, /** * Show the command palette dialog. */ show() { - dialog.replace(() => ) + dialog.replace(() => ( + + )); }, /** * Register commands. Returns cleanup function. */ register(cb: () => CommandOption[]) { - const results = createMemo(cb) - setRegistrations((arr) => [results, ...arr]) + const results = createMemo(cb); + setRegistrations((arr) => [results, ...arr]); onCleanup(() => { - setRegistrations((arr) => arr.filter((x) => x !== results)) - }) + setRegistrations((arr) => arr.filter((x) => x !== results)); + }); }, /** * Get all visible options. */ get options() { - return visibleOptions() + return visibleOptions(); }, - } - return result + }; + return result; } export function useCommandDialog() { - const value = useContext(ctx) + const value = useContext(ctx); if (!value) { - throw new Error("useCommandDialog must be used within a CommandProvider") + throw new Error("useCommandDialog must be used within a CommandProvider"); } - return value + return value; } export function CommandProvider(props: ParentProps) { - const value = init() - const dialog = useDialog() - const keybind = useKeybind() + const value = init(); + const dialog = useDialog(); + const keybind = useKeybinds(); // Open command palette on ctrl+p or command_list keybind useKeyboard((evt) => { - if (value.suspended()) return - if (dialog.isOpen) return - if (evt.defaultPrevented) return + if (value.suspended()) return; + if (dialog.isOpen) return; + if (evt.defaultPrevented) return; if (keybind.match("command_list", evt)) { - evt.preventDefault() - value.show() - return + evt.preventDefault(); + value.show(); + return; } - }) + }); - return {props.children} + return {props.children}; } /** * Command palette dialog component. */ -function CommandDialog(props: { options: CommandOption[]; suggestedOptions: CommandOption[] }) { - const { theme } = useTheme() - const dialog = useDialog() - const dimensions = useTerminalDimensions() - const [filter, setFilter] = createSignal("") - const [selectedIndex, setSelectedIndex] = createSignal(0) +function CommandDialog(props: { + options: CommandOption[]; + suggestedOptions: CommandOption[]; +}) { + const { theme } = useTheme(); + const dialog = useDialog(); + const dimensions = useTerminalDimensions(); + const [filter, setFilter] = createSignal(""); + const [selectedIndex, setSelectedIndex] = createSignal(0); const filteredOptions = createMemo(() => { - const query = filter().toLowerCase() + const query = filter().toLowerCase(); if (!query) { - return [...props.suggestedOptions, ...props.options] + return [...props.suggestedOptions, ...props.options]; } return props.options.filter( (option) => option.title.toLowerCase().includes(query) || option.description?.toLowerCase().includes(query) || - option.category?.toLowerCase().includes(query) - ) - }) + option.category?.toLowerCase().includes(query), + ); + }); // Reset selection when filter changes createMemo(() => { - filter() - setSelectedIndex(0) - }) + filter(); + setSelectedIndex(0); + }); useKeyboard((evt) => { if (evt.name === "escape") { - dialog.clear() - evt.preventDefault() - return + dialog.clear(); + evt.preventDefault(); + return; } if (evt.name === "return" || evt.name === "enter") { - const option = filteredOptions()[selectedIndex()] + const option = filteredOptions()[selectedIndex()]; if (option) { - option.onSelect?.(dialog) - dialog.clear() + option.onSelect?.(dialog); + dialog.clear(); } - evt.preventDefault() - return + evt.preventDefault(); + return; } if (evt.name === "up" || (evt.ctrl && evt.name === "p")) { - setSelectedIndex((i) => Math.max(0, i - 1)) - evt.preventDefault() - return + setSelectedIndex((i) => Math.max(0, i - 1)); + evt.preventDefault(); + return; } if (evt.name === "down" || (evt.ctrl && evt.name === "n")) { - setSelectedIndex((i) => Math.min(filteredOptions().length - 1, i + 1)) - evt.preventDefault() - return + setSelectedIndex((i) => Math.min(filteredOptions().length - 1, i + 1)); + evt.preventDefault(); + return; } // Handle text input if (evt.name && evt.name.length === 1 && !evt.ctrl && !evt.meta) { - setFilter((f) => f + evt.name) - return + setFilter((f) => f + evt.name); + return; } if (evt.name === "backspace") { - setFilter((f) => f.slice(0, -1)) - return + setFilter((f) => f.slice(0, -1)); + return; } - }) + }); - const maxHeight = Math.floor(dimensions().height * 0.6) + const maxHeight = Math.floor(dimensions().height * 0.6); return ( {/* Search input */} - - {"> "} - - - {filter() || "Type to search commands..."} - + {"> "} + {filter() || "Type to search commands..."} {/* Command list */} @@ -274,14 +282,24 @@ function CommandDialog(props: { options: CommandOption[]; suggestedOptions: Comm {(option, index) => ( {option.title} @@ -303,5 +321,5 @@ function CommandDialog(props: { options: CommandOption[]; suggestedOptions: Comm - ) + ); } diff --git a/src/utils/jsonc.ts b/src/utils/jsonc.ts new file mode 100644 index 0000000..0a300aa --- /dev/null +++ b/src/utils/jsonc.ts @@ -0,0 +1,32 @@ +/** + * JSONC parser utility for handling JSON with comments + * + * JSONC (JSON with Comments) is a superset of JSON that allows single-line + * and multi-line comments, which is useful for configuration files. + */ + +/** + * Remove JSONC comments from a string + */ +export function stripComments(jsonString: string): string { + const comments = [ + { pattern: /\/\/.*$/gm, replacement: "" }, + { pattern: /\/\*[\s\S]*?\*\//g, replacement: "" }, + ]; + + let result = jsonString; + + for (const { pattern, replacement } of comments) { + result = result.replace(pattern, replacement); + } + + return result; +} + +/** + * Parse JSONC string into a JavaScript object + */ +export function parseJSONC(jsonString: string): unknown { + const stripped = stripComments(jsonString); + return JSON.parse(stripped); +} diff --git a/src/utils/keybind.ts b/src/utils/keybind.ts deleted file mode 100644 index 2370444..0000000 --- a/src/utils/keybind.ts +++ /dev/null @@ -1,187 +0,0 @@ -import type { ParsedKey } from "@opentui/core" - -/** - * Keyboard shortcut parsing and matching utilities. - * - * Supports key combinations like: - * - "ctrl+c" - Control + c - * - "alt+x" - Alt + x - * - "shift+enter" - Shift + Enter - * - "n" - Leader key followed by n - * - "ctrl+shift+p" - Control + Shift + p - */ - -export namespace Keybind { - export interface Info { - key: string - ctrl: boolean - alt: boolean - shift: boolean - meta: boolean - leader: boolean - } - - /** - * Parse a keybind string into a structured Info object. - * - * Examples: - * - "ctrl+c" -> { key: "c", ctrl: true, ... } - * - "n" -> { key: "n", leader: true, ... } - * - "alt+shift+x" -> { key: "x", alt: true, shift: true, ... } - */ - export function parse(input: string): Info[] { - if (!input) return [] - - // Handle multiple keybinds separated by comma or space - const parts = input.split(/[,\s]+/).filter(Boolean) - - return parts.map((part) => { - const info: Info = { - key: "", - ctrl: false, - alt: false, - shift: false, - meta: false, - leader: false, - } - - // Check for leader key prefix - if (part.startsWith("")) { - info.leader = true - part = part.substring(8) // Remove "" - } - - // Split by + for modifiers - const tokens = part.toLowerCase().split("+") - - for (const token of tokens) { - switch (token) { - case "ctrl": - case "control": - info.ctrl = true - break - case "alt": - case "option": - info.alt = true - break - case "shift": - info.shift = true - break - case "meta": - case "cmd": - case "command": - case "win": - case "super": - info.meta = true - break - default: - // The last non-modifier token is the key - info.key = token - } - } - - return info - }) - } - - /** - * Convert a ParsedKey event to a Keybind.Info. - */ - export function fromParsedKey(evt: ParsedKey, leader: boolean = false): Info { - // ParsedKey has ctrl, shift, meta but may not have alt directly - // We need to check what properties are available - const evtAny = evt as unknown as Record - return { - key: evt.name?.toLowerCase() ?? "", - ctrl: evt.ctrl ?? false, - alt: (evtAny.alt as boolean) ?? false, - shift: evt.shift ?? false, - meta: evt.meta ?? false, - leader, - } - } - - /** - * Check if a keybind matches a parsed key event. - */ - export function match(keybind: Info, evt: Info): boolean { - return ( - keybind.key === evt.key && - keybind.ctrl === evt.ctrl && - keybind.alt === evt.alt && - keybind.shift === evt.shift && - keybind.meta === evt.meta && - keybind.leader === evt.leader - ) - } - - /** - * Convert a keybind Info to a display string. - */ - export function toString(info: Info): string { - const parts: string[] = [] - - if (info.leader) parts.push("") - if (info.ctrl) parts.push("Ctrl") - if (info.alt) parts.push("Alt") - if (info.shift) parts.push("Shift") - if (info.meta) parts.push("Cmd") - - if (info.key) { - // Capitalize special keys - const displayKey = info.key.length === 1 ? info.key.toUpperCase() : capitalize(info.key) - parts.push(displayKey) - } - - return parts.join("+") - } - - function capitalize(str: string): string { - return str.charAt(0).toUpperCase() + str.slice(1) - } -} - -/** - * Default keybindings configuration. - */ -export const DEFAULT_KEYBINDS = { - // Leader key (space by default) - leader: "space", - - // Navigation - tab_next: "tab", - tab_prev: "shift+tab", - - // App commands - command_list: "ctrl+p", - help: "?", - quit: "ctrl+c", - - // Session/content - session_new: "n", - session_list: "s", - - // Theme - theme_list: "t", - - // Player - player_play: "space", - player_pause: "space", - player_next: "n", - player_prev: "p", - player_seek_forward: "l", - player_seek_backward: "h", - - // List navigation - list_up: "k", - list_down: "j", - list_top: "g", - list_bottom: "G", - list_select: "enter", - - // Search - search_focus: "/", - search_clear: "escape", -} - -export type KeybindsConfig = typeof DEFAULT_KEYBINDS diff --git a/src/utils/keybinds-persistence.ts b/src/utils/keybinds-persistence.ts new file mode 100644 index 0000000..3329fa4 --- /dev/null +++ b/src/utils/keybinds-persistence.ts @@ -0,0 +1,80 @@ +/** + * Keybinds persistence via JSONC file in XDG_CONFIG_HOME + * + * Handles copying keybind.jsonc from package to user config directory + * and loading/saving keybind configurations. + */ + +import { copyFile, mkdir } from "fs/promises"; +import path from "path"; +import { parseJSONC } from "./jsonc"; +import { getConfigFilePath, ensureConfigDir } from "./config-dir"; +import type { KeybindsResolved } from "../context/KeybindContext"; + +const KEYBINDS_SOURCE = path.join(process.cwd(), "src", "config", "keybind.jsonc"); +const KEYBINDS_FILE = "keybinds.jsonc"; + +/** Default keybinds from package */ +const DEFAULT_KEYBINDS: KeybindsResolved = { + up: ["up", "k"], + down: ["down", "j"], + left: ["left", "h"], + right: ["right", "l"], + cycle: ["tab"], + dive: ["return"], + out: ["esc"], + inverse: ["shift"], + leader: ":", + quit: ["q"], + "audio-toggle": ["p"], + "audio-pause": [], + "audio-play": [], + "audio-next": ["n"], + "audio-prev": ["l"], +}; + +/** Copy keybind.jsonc to user config directory on first run */ +export async function copyKeybindsIfNeeded(): Promise { + try { + const targetPath = getConfigFilePath(KEYBINDS_FILE); + + // Check if file already exists + const targetFile = Bun.file(targetPath); + if (await targetFile.exists()) return; + + await ensureConfigDir(); + await copyFile(KEYBINDS_SOURCE, targetPath); + } catch { + // Silently ignore errors + } +} + +/** Load keybinds from JSONC file */ +export async function loadKeybindsFromFile(): Promise { + try { + const filePath = getConfigFilePath(KEYBINDS_FILE); + const file = Bun.file(filePath); + + if (!(await file.exists())) return DEFAULT_KEYBINDS; + + const raw = await file.text(); + const parsed = parseJSONC(raw); + + if (!parsed || typeof parsed !== "object") return DEFAULT_KEYBINDS; + + return { ...DEFAULT_KEYBINDS, ...parsed } as KeybindsResolved; + } catch { + return DEFAULT_KEYBINDS; + } +} + +/** Save keybinds to JSONC file */ +export async function saveKeybindsToFile(keybinds: KeybindsResolved): Promise { + try { + await ensureConfigDir(); + const filePath = getConfigFilePath(KEYBINDS_FILE); + await Bun.write(filePath, JSON.stringify(keybinds, null, 2)); + } catch { + // Silently ignore write errors + } +}