diff --git a/.agent/memory/INDEX.md b/.agent/memory/INDEX.md
index 6d7f811..d23ba68 100644
--- a/.agent/memory/INDEX.md
+++ b/.agent/memory/INDEX.md
@@ -17,6 +17,7 @@ Auto-grown list of memory entries. Agents append after each task that produced n
- [2026-05-21-claude-code-stats-cache-stale](facts/2026-05-21-claude-code-stats-cache-stale.md) — `stats-cache.json` is a stale periodic rollup; live token data lives in per-session JSONL files under `projects/`
- [2026-05-21-claude-usage-display-format](facts/2026-05-21-claude-usage-display-format.md) — Exact fields, computation logic, and date-range strategy of `claude /usage`; total tokens = input+output only (cache excluded)
+- [2026-09-23-gpui-keymap-dispatch](facts/2026-09-23-gpui-keymap-dispatch.md) — GPUI bindings need a focused root element; same-node contexts tie on depth so insertion order decides; `NoAction` masks lower contexts
## patterns/
diff --git a/.agent/memory/facts/2026-09-23-gpui-keymap-dispatch.md b/.agent/memory/facts/2026-09-23-gpui-keymap-dispatch.md
new file mode 100644
index 0000000..eba84f4
--- /dev/null
+++ b/.agent/memory/facts/2026-09-23-gpui-keymap-dispatch.md
@@ -0,0 +1,33 @@
+---
+title: GPUI key bindings need a focused element and insertion order breaks context ties
+status: current
+version: 0.1.0
+last_updated: 2026-09-23
+last_verified: 2026-09-23
+source_refs:
+ - crates/aura/src/keys.rs
+ - crates/aura/src/app.rs
+ - vendor/gpui/src/keymap.rs
+ - vendor/gpui/src/window.rs
+owner: "@rfluid"
+tags: [memory, fact, gpui, keybindings]
+source_task: keybindings feature (docs/keybindings.md)
+---
+
+# GPUI keymap dispatch facts
+
+- With nothing focused, GPUI dispatches from the dispatch tree's root node, which is
+ *not* the view's root `div`. Contexts and `on_action` listeners on that div are then
+ off the dispatch path, so bindings never match. The modal root holds a
+ `FocusHandle` (`track_focus`) focused at open; a mouse-down anywhere on a
+ focus-tracked element re-focuses it.
+- `Keymap::bindings_for_input` ranks by context depth, then by insertion order (later
+ wins). `Aura` and `overlay` live on the same node, so they tie on depth: overlay
+ bindings must be `bind_keys`'d after global ones.
+- `NoAction` bindings without `meta` count as user unbinds and mask lower-precedence
+ matches — that is how `"x" = "none"` in `[overlay]` blocks a `[global]` binding.
+- Keystroke observers (`observe_keystrokes`, incl. gpui-selectable-text's bridge) see
+ `event.action.is_some()` once a binding fires and step aside, so binding
+ ctrl-c / ctrl-a / shift+arrows steals them from text selection.
+- `KeyBinding::load` parses `G` as `shift-g`; typed shift-g matches both spellings.
+ `?` matches via `key_char`.
diff --git a/Cargo.lock b/Cargo.lock
index f3b8b14..78e0ea6 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -527,6 +527,7 @@ dependencies = [
"tempfile",
"thiserror 2.0.20",
"toml 1.1.3+spec-1.1.0",
+ "toml_edit 0.25.11+spec-1.1.0",
"ureq",
]
@@ -6265,6 +6266,7 @@ dependencies = [
"indexmap",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
+ "toml_writer",
"winnow 1.0.3",
]
diff --git a/Cargo.toml b/Cargo.toml
index 95709d8..ab912c0 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -42,4 +42,7 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
toml = "1.1"
+# Format-preserving edits to keybindings.toml (`aura keys set` etc.), so a
+# user's comments and layout survive a CLI change.
+toml_edit = "0.25"
ureq = { version = "3", features = ["json"] }
diff --git a/README.md b/README.md
index 186ace2..7a8da77 100644
--- a/README.md
+++ b/README.md
@@ -115,6 +115,7 @@ running a CLI command.
- **Agent profiles** — configure multiple instances of the same agent (e.g. personal vs. enterprise workspaces) and toggle between them; last selection is persisted across sessions.
- **Plugin system** — extend Aura with custom metrics panels; anyone can author a plugin. First-party plugins (incl. RTK Gains for [RTK](https://github.com/rtk) token-savings) are installed separately.
- **Single-click activation** — left-click the tray icon to open / close the modal; right-click for Show / Quit; Escape closes.
+- **Keyboard-driven** — vim-style shortcuts out of the box (`j`/`k` scroll, `h`/`l` sections, `H`/`L` profiles, `q` closes, `?` lists them all), remappable in `keybindings.toml` with warnings for bad entries. See [`docs/keybindings.md`](docs/keybindings.md).
- **A real indicator, not a launcher** — the tooltip carries live quota usage, the ring fills with your session while its color tracks your week, and the icon turns red near the limit — without opening anything.
- **Tray-native** — uses [`ksni`](https://github.com/iovxw/ksni) on Linux for direct StatusNotifierItem (Plasma / GNOME / sway / etc.) and `tray-icon` on macOS / Windows for AppKit / Win32 menu-bar integration.
@@ -229,6 +230,27 @@ modal's refresh button both re-read the file, so edits take effect
without restarting Aura. See
[`docs/configuration.md`](docs/configuration.md) for the full schema.
+## Keybindings
+
+The modal is keyboard-driven with vim-style defaults:
+
+| Keys | Does |
+| ---- | ---- |
+| `j` / `k`, `ctrl-d` / `ctrl-u`, `g g` / `G` | Scroll line, half page, top / bottom |
+| `h` / `l`, `tab`, `1`–`9` | Previous / next / Nth section tab |
+| `H` / `L`, `[` / `]` | Previous / next agent (or plugin) |
+| `m`, `p` / `P` | Toggle agents ↔ plugins, cycle the period |
+| `r`, `,`, `.`, `?` | Refresh, settings, more menu, shortcut list |
+| `esc`, `q` | Close the open overlay / close the window |
+
+Override or unbind any of them in `keybindings.toml` next to `config.toml`,
+by hand or from the CLI — `aura keys set ctrl-j scroll_down`, `aura keys
+wizard`, `aura keys merge team.toml`, `aura keys describe` to see every
+action (edits keep your comments). `aura keys validate` reports unknown
+actions, bad keystrokes and conflicts. Turn shortcuts off entirely
+with `[keybindings] enabled = false` in `config.toml`. Full reference:
+[`docs/keybindings.md`](docs/keybindings.md).
+
## Themes
Aura's color tokens are user-customizable via a sibling file:
@@ -282,7 +304,9 @@ moment you log in:
**Left-click** the tray icon to open Aura's modal; left-click again to
close. **Middle-click** does the same. **Right-click** for an explicit menu
with **Show Aura** and **Quit Aura** (Cmd/Ctrl+Q while the menu is open).
-**Escape** closes the modal, as does clicking anywhere outside it.
+**Escape** closes the modal (or the open menu first), as does **q** or
+clicking anywhere outside it. Press **?** in the modal for every keyboard
+shortcut — see [Keybindings](#keybindings).
`just stop` / `systemctl --user stop aura` (Linux) and `just stop-windows`
(Windows) are equivalent CLI exits.
diff --git a/assets/icons/keyboard.svg b/assets/icons/keyboard.svg
new file mode 100644
index 0000000..b5bcd8c
--- /dev/null
+++ b/assets/icons/keyboard.svg
@@ -0,0 +1,4 @@
+
diff --git a/crates/aura-core/Cargo.toml b/crates/aura-core/Cargo.toml
index 2058e7c..899cd94 100644
--- a/crates/aura-core/Cargo.toml
+++ b/crates/aura-core/Cargo.toml
@@ -15,6 +15,7 @@ serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
toml.workspace = true
+toml_edit.workspace = true
ureq.workspace = true
# Claude Code stores OAuth credentials in the macOS Keychain rather than
diff --git a/crates/aura-core/src/config.rs b/crates/aura-core/src/config.rs
index c04784a..f87990c 100644
--- a/crates/aura-core/src/config.rs
+++ b/crates/aura-core/src/config.rs
@@ -441,6 +441,27 @@ pub struct UpdateConfig {
pub dismiss_all: bool,
}
+// ── Keybindings ──────────────────────────────────────────────────────────────
+
+/// Master switch for the modal's keyboard shortcuts. The bindings themselves
+/// live in their own file, `keybindings.toml` (see [`crate::keymap`]); this
+/// section only decides whether that keymap is installed at all.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(default)]
+pub struct KeybindingsConfig {
+ /// Install the keymap (built-in defaults plus `keybindings.toml`).
+ /// Default true. Set false to turn every shortcut off: the modal is then
+ /// mouse-only, apart from Escape closing it, which works either way.
+ #[serde(default = "default_true")]
+ pub enabled: bool,
+}
+
+impl Default for KeybindingsConfig {
+ fn default() -> Self {
+ Self { enabled: true }
+ }
+}
+
// ── AppConfig ─────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
@@ -457,6 +478,8 @@ pub struct AppConfig {
pub content: ContentConfig,
#[serde(default)]
pub update: UpdateConfig,
+ #[serde(default)]
+ pub keybindings: KeybindingsConfig,
}
impl AppConfig {
@@ -526,6 +549,7 @@ impl AppConfig {
tray: TrayConfig::default(),
content: ContentConfig::default(),
update: UpdateConfig::default(),
+ keybindings: KeybindingsConfig::default(),
}
}
@@ -796,6 +820,7 @@ mod tests {
..ContentConfig::default()
},
update: UpdateConfig::default(),
+ keybindings: KeybindingsConfig::default(),
};
cfg.apply_plugin_order();
let names: Vec<&str> = cfg.plugins.iter().map(|p| p.name.as_str()).collect();
@@ -815,6 +840,7 @@ mod tests {
..ContentConfig::default()
},
update: UpdateConfig::default(),
+ keybindings: KeybindingsConfig::default(),
};
cfg.apply_plugin_order();
let names: Vec<&str> = cfg.plugins.iter().map(|p| p.name.as_str()).collect();
@@ -830,6 +856,7 @@ mod tests {
tray: TrayConfig::default(),
content: ContentConfig::default(),
update: UpdateConfig::default(),
+ keybindings: KeybindingsConfig::default(),
};
cfg.apply_plugin_order();
let names: Vec<&str> = cfg.plugins.iter().map(|p| p.name.as_str()).collect();
@@ -911,6 +938,7 @@ dismiss_all = true
tray: TrayConfig::default(),
content: ContentConfig::default(),
update: UpdateConfig::default(),
+ keybindings: KeybindingsConfig::default(),
};
let added = cfg.merge_agents(vec![
@@ -960,6 +988,7 @@ dismiss_all = true
tray: TrayConfig::default(),
content: ContentConfig::default(),
update: UpdateConfig::default(),
+ keybindings: KeybindingsConfig::default(),
};
let added = cfg.merge_agents(vec![AgentConfig {
diff --git a/crates/aura-core/src/config_schema.rs b/crates/aura-core/src/config_schema.rs
index 8a48fc6..6a96646 100644
--- a/crates/aura-core/src/config_schema.rs
+++ b/crates/aura-core/src/config_schema.rs
@@ -7,7 +7,7 @@
//! - `aura config wizard`
//!
//! Every settable scalar field under `[window]` / `[tray]` / `[content]` /
-//! `[update]` has a [`FieldDescriptor`] here. A unit test
+//! `[update]` / `[keybindings]` has a [`FieldDescriptor`] here. A unit test
//! (`registry_covers_every_field`) serializes a default config and asserts
//! each leaf key is described, so adding a struct field without documenting
//! it breaks the build.
@@ -60,7 +60,7 @@ pub struct SectionField {
/// The scalar `[section]`s of the config, in template-emission order. The
/// repeatable `[[agents]]` / `[[plugins]]` tables are not here — they are
/// documented by [`agent_fields`] / [`plugin_fields`] and edited elsewhere.
-pub const SECTIONS: &[&str] = &["window", "tray", "content", "update"];
+pub const SECTIONS: &[&str] = &["window", "tray", "content", "update", "keybindings"];
/// All settable scalar fields, in template-emission order ([`SECTIONS`]).
pub fn fields() -> &'static [FieldDescriptor] {
@@ -288,6 +288,21 @@ pub fn fields() -> &'static [FieldDescriptor] {
default.",
example: "false",
},
+ // ── [keybindings] ──
+ FieldDescriptor {
+ key: "keybindings.enabled",
+ type_label: "bool",
+ allowed: &["true", "false"],
+ default: "true",
+ summary: "Turn the modal's keyboard shortcuts on or off.",
+ description: "Install the modal's keymap: the built-in vim-style defaults (j/k to \
+ scroll, h/l for sections, q to close, ? for help, …) plus anything in \
+ keybindings.toml next to this file. Default true. Set false to turn every \
+ shortcut off and leave the modal mouse-only; Escape still closes it. Run \
+ `aura keys list` to see the active bindings and `aura keys validate` to check \
+ keybindings.toml.",
+ example: "true",
+ },
]
}
@@ -497,6 +512,7 @@ pub fn get_value(cfg: &AppConfig, key: &str) -> Result {
.clone()
.unwrap_or_else(|| "(unset)".to_string()),
"update.dismiss_all" => cfg.update.dismiss_all.to_string(),
+ "keybindings.enabled" => cfg.keybindings.enabled.to_string(),
_ => return Err(unknown_key(key)),
};
Ok(v)
@@ -530,6 +546,7 @@ pub fn set_value(cfg: &mut AppConfig, key: &str, raw: &str) -> Result<(), Schema
"content.goblin_mode" => cfg.content.goblin_mode = parse_bool(key, raw)?,
"update.dismissed_version" => cfg.update.dismissed_version = parse_opt_string(raw),
"update.dismiss_all" => cfg.update.dismiss_all = parse_bool(key, raw)?,
+ "keybindings.enabled" => cfg.keybindings.enabled = parse_bool(key, raw)?,
_ => return Err(unknown_key(key)),
}
Ok(())
@@ -704,6 +721,7 @@ fn toml_rhs(cfg: &AppConfig, key: &str) -> Option {
"content.goblin_mode" => cfg.content.goblin_mode.to_string(),
"update.dismissed_version" => return cfg.update.dismissed_version.as_deref().map(quote),
"update.dismiss_all" => cfg.update.dismiss_all.to_string(),
+ "keybindings.enabled" => cfg.keybindings.enabled.to_string(),
_ => return None,
})
}
@@ -764,7 +782,8 @@ fn wrap_text(text: &str, width: usize) -> Vec {
mod tests {
use super::*;
use crate::config::{
- AgentConfig, AgentKind, ContentConfig, PluginConfig, TrayConfig, UpdateConfig, WindowConfig,
+ AgentConfig, AgentKind, ContentConfig, KeybindingsConfig, PluginConfig, TrayConfig,
+ UpdateConfig, WindowConfig,
};
/// Walk a serialized default config and assert every leaf key under each
@@ -909,6 +928,7 @@ mod tests {
assert_eq!(parsed.tray, cfg.tray);
assert_eq!(parsed.content, cfg.content);
assert_eq!(parsed.update, cfg.update);
+ assert_eq!(parsed.keybindings, cfg.keybindings);
}
#[test]
@@ -959,6 +979,7 @@ mod tests {
dismissed_version: Some("0.1.18".to_string()),
dismiss_all: true,
},
+ keybindings: KeybindingsConfig { enabled: false },
};
assert_round_trips(&cfg);
}
diff --git a/crates/aura-core/src/keymap/file.rs b/crates/aura-core/src/keymap/file.rs
new file mode 100644
index 0000000..b632b60
--- /dev/null
+++ b/crates/aura-core/src/keymap/file.rs
@@ -0,0 +1,683 @@
+//! Editing `keybindings.toml`: the write half of [`super`].
+//!
+//! [`KeymapFile`] wraps the user's document in `toml_edit`, so a change made
+//! from the CLI (`aura keys set`, `wizard`, `merge`, …) touches only the entry
+//! it is about. Comments, ordering and entries this module can't parse are
+//! left exactly as the user wrote them. [`KeymapFile::document`] is the one
+//! operation that rewrites the whole file, and it says what it drops.
+//!
+//! Every operation compares keystrokes in canonical form, so `G` and
+//! `shift-g` are the same entry: binding one replaces the other.
+
+use std::{fs, path::Path};
+
+use anyhow::{Context as _, Result};
+use serde::Serialize;
+use toml_edit::{value, DocumentMut, Item, Table, TableLike};
+
+use super::{
+ parse_action, parse_keys, render_file, BindingContext, KeyAction, Keymap, RenderEntry, DEFAULTS,
+};
+
+/// A user keymap file open for editing.
+#[derive(Debug, Clone)]
+pub struct KeymapFile {
+ doc: DocumentMut,
+}
+
+/// One entry of the file that parses: a valid keystroke bound to a known
+/// action (or `"none"`).
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+pub struct FileEntry {
+ pub context: BindingContext,
+ /// The key exactly as written in the file.
+ pub raw: String,
+ pub canonical: String,
+ pub display: String,
+ pub action: Option,
+}
+
+/// What [`KeymapFile::merge`] does when both files bind the same keys to
+/// different actions.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum MergePrefer {
+ /// The incoming file wins.
+ Theirs,
+ /// The existing file wins; the incoming entry is reported as a conflict.
+ Ours,
+}
+
+/// One entry a merge touched (or declined to).
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+pub struct MergeChange {
+ pub context: BindingContext,
+ pub keys: String,
+ /// Action in the existing file, `None` when it had no entry.
+ pub ours: Option,
+ /// Action in the incoming file.
+ pub theirs: String,
+}
+
+/// Outcome of [`KeymapFile::merge`].
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
+pub struct MergeReport {
+ /// New entries.
+ pub added: Vec,
+ /// Entries whose action the incoming file replaced.
+ pub changed: Vec,
+ /// Entries both files agree on.
+ pub unchanged: usize,
+ /// Conflicts the existing file kept (`MergePrefer::Ours`).
+ pub kept: Vec,
+ /// Problems in the incoming file; its broken entries are not merged.
+ pub skipped: Vec,
+ /// `use_defaults` as `(ours, theirs)` when the two disagree.
+ pub use_defaults: Option<(bool, bool)>,
+}
+
+impl MergeReport {
+ /// Whether the merge changes the file.
+ pub fn changes_anything(&self, prefer: MergePrefer) -> bool {
+ !self.added.is_empty()
+ || !self.changed.is_empty()
+ || (prefer == MergePrefer::Theirs && self.use_defaults.is_some())
+ }
+}
+
+impl Default for KeymapFile {
+ /// The starter file, as `aura keys init` writes it.
+ fn default() -> Self {
+ Self::parse(&Keymap::default_file_contents()).expect("starter keymap parses")
+ }
+}
+
+impl KeymapFile {
+ /// Parse a document. Only TOML syntax is checked here; entries Aura can't
+ /// use are kept as-is and reported by [`Self::keymap`]'s warnings.
+ pub fn parse(content: &str) -> Result {
+ let doc = content
+ .parse::()
+ .context("keybindings.toml is not valid TOML")?;
+ Ok(Self { doc })
+ }
+
+ /// Open `path`, or the starter file when it doesn't exist yet.
+ pub fn load(path: &Path) -> Result {
+ if !path.exists() {
+ return Ok(Self::default());
+ }
+ let content =
+ fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
+ Self::parse(&content).with_context(|| format!("parse {}", path.display()))
+ }
+
+ pub fn save(&self, path: &Path) -> Result<()> {
+ if let Some(parent) = path.parent() {
+ fs::create_dir_all(parent)
+ .with_context(|| format!("create config dir {}", parent.display()))?;
+ }
+ fs::write(path, self.contents()).with_context(|| format!("write {}", path.display()))
+ }
+
+ pub fn contents(&self) -> String {
+ self.doc.to_string()
+ }
+
+ /// The effective keymap this file produces over the defaults.
+ pub fn keymap(&self) -> Keymap {
+ Keymap::from_toml(&self.contents())
+ }
+
+ /// `use_defaults` as written, `None` when absent or not a boolean.
+ pub fn use_defaults(&self) -> Option {
+ self.doc.get("use_defaults").and_then(Item::as_bool)
+ }
+
+ pub fn set_use_defaults(&mut self, on: bool) {
+ match self.doc.get_mut("use_defaults") {
+ Some(item) => *item = value(on),
+ None => {
+ self.doc.insert("use_defaults", value(on));
+ }
+ }
+ }
+
+ /// Every valid entry, in file order.
+ pub fn entries(&self) -> Vec {
+ let mut out = Vec::new();
+ for context in BindingContext::ALL {
+ let Some(table) = self.table(context) else {
+ continue;
+ };
+ for (raw, item) in table.iter() {
+ let (Ok(keys), Some(Ok(action))) =
+ (parse_keys(raw), item.as_str().map(parse_action))
+ else {
+ continue;
+ };
+ out.push(FileEntry {
+ context,
+ raw: raw.to_string(),
+ canonical: keys.canonical,
+ display: keys.display,
+ action,
+ });
+ }
+ }
+ out
+ }
+
+ /// The file's entry for `canonical` in `context`, if it has one.
+ pub fn entry(&self, context: BindingContext, canonical: &str) -> Option {
+ self.entries()
+ .into_iter()
+ .rev()
+ .find(|e| e.context == context && e.canonical == canonical)
+ }
+
+ /// Bind `keys` to `action` (`None` = unbind) in `context`, replacing any
+ /// entry for the same keystroke, whatever its spelling. An existing entry
+ /// written exactly as `keys` is updated in place, keeping its position.
+ pub fn bind(
+ &mut self,
+ context: BindingContext,
+ keys: &str,
+ action: Option,
+ ) -> Result<(), String> {
+ let parsed = parse_keys(keys)?;
+ let raw = keys.split_whitespace().collect::>().join(" ");
+ let new_value = value(action.map(KeyAction::name).unwrap_or("none"));
+
+ let table = self.table_mut(context);
+ let spellings: Vec = table
+ .iter()
+ .filter(|(k, _)| *k != raw && same_keys(k, &parsed.canonical))
+ .map(|(k, _)| k.to_string())
+ .collect();
+ for k in spellings {
+ table.remove(&k);
+ }
+ match table.get_mut(&raw) {
+ Some(item) => *item = new_value,
+ None => {
+ table.insert(&raw, new_value);
+ }
+ }
+ Ok(())
+ }
+
+ /// Delete the file's entry for `keys` in `context` (every spelling), so
+ /// the default — if any — applies again. Returns how many were removed.
+ pub fn remove(&mut self, context: BindingContext, keys: &str) -> Result {
+ let parsed = parse_keys(keys)?;
+ Ok(self.remove_canonical(context, &parsed.canonical))
+ }
+
+ fn remove_canonical(&mut self, context: BindingContext, canonical: &str) -> usize {
+ let Some(table) = self.table_mut_existing(context) else {
+ return 0;
+ };
+ let matching: Vec = table
+ .iter()
+ .filter(|(k, _)| same_keys(k, canonical))
+ .map(|(k, _)| k.to_string())
+ .collect();
+ for k in &matching {
+ table.remove(k);
+ }
+ matching.len()
+ }
+
+ /// Delete every valid entry in `context` (or in every context), leaving
+ /// the defaults. Entries Aura can't parse are left for the user to fix.
+ pub fn clear(&mut self, context: Option) -> usize {
+ let targets: Vec = self
+ .entries()
+ .into_iter()
+ .filter(|e| context.is_none_or(|c| c == e.context))
+ .collect();
+ for e in &targets {
+ if let Some(table) = self.table_mut_existing(e.context) {
+ table.remove(&e.raw);
+ }
+ }
+ targets.len()
+ }
+
+ /// Make `keys` exactly the keystrokes that run `action` in `context`.
+ ///
+ /// A key the action loses is unbound when it's one of the action's
+ /// defaults, and otherwise has its entry removed (so whatever the default
+ /// for that key is applies again). A key it gains is bound. Returns notes
+ /// about keys taken from other actions or handed back to their defaults.
+ pub fn set_action_keys(
+ &mut self,
+ context: BindingContext,
+ action: KeyAction,
+ keys: &[String],
+ ) -> Result, String> {
+ let map = self.keymap();
+ let defaults_on = self.use_defaults() != Some(false);
+ let default_action = |canonical: &str| {
+ defaults_on
+ .then(|| {
+ DEFAULTS
+ .iter()
+ .find(|(c, k, _)| {
+ *c == context && parse_keys(k).is_ok_and(|p| p.canonical == canonical)
+ })
+ .map(|(_, _, a)| *a)
+ })
+ .flatten()
+ };
+
+ // Validate everything before touching the document.
+ let mut wanted: Vec<(String, super::ParsedKeys)> = Vec::new();
+ for k in keys {
+ let parsed = parse_keys(k)?;
+ if !wanted.iter().any(|(_, p)| p.canonical == parsed.canonical) {
+ wanted.push((k.clone(), parsed));
+ }
+ }
+
+ let current: Vec<(String, String)> = map
+ .bindings
+ .iter()
+ .filter(|b| b.context == context && b.action == Some(action))
+ .map(|b| (b.keys.clone(), b.display.clone()))
+ .collect();
+
+ let mut notes = Vec::new();
+ for (canonical, display) in ¤t {
+ if wanted.iter().any(|(_, p)| &p.canonical == canonical) {
+ continue;
+ }
+ match default_action(canonical) {
+ Some(a) if a == action => self.bind(context, display, None)?,
+ other => {
+ self.remove_canonical(context, canonical);
+ if let Some(a) = other {
+ notes.push(format!("`{display}` goes back to its default, {a}"));
+ }
+ }
+ }
+ }
+ for (raw, parsed) in &wanted {
+ if current.iter().any(|(c, _)| *c == parsed.canonical) {
+ continue;
+ }
+ if let Some(Some(other)) = map
+ .lookup(context, &parsed.canonical)
+ .map(|b| b.action)
+ .filter(|a| *a != Some(action))
+ {
+ notes.push(format!("`{}` was {other}; now {action}", parsed.display));
+ }
+ if default_action(&parsed.canonical) == Some(action) {
+ // The default already does this; drop whatever overrode it.
+ self.remove_canonical(context, &parsed.canonical);
+ } else {
+ self.bind(context, raw, Some(action))?;
+ }
+ }
+ Ok(notes)
+ }
+
+ /// Put `action` back to its default keys in `context`: remove every file
+ /// entry that binds it, and every entry that overrides one of its
+ /// default keys. Returns how many entries were removed.
+ pub fn restore_action(&mut self, context: BindingContext, action: KeyAction) -> usize {
+ let defaults: Vec = DEFAULTS
+ .iter()
+ .filter(|(c, _, a)| *c == context && *a == action)
+ .filter_map(|(_, k, _)| parse_keys(k).ok().map(|p| p.canonical))
+ .collect();
+ let targets: Vec = self
+ .entries()
+ .into_iter()
+ .filter(|e| {
+ e.context == context
+ && (e.action == Some(action) || defaults.contains(&e.canonical))
+ })
+ .collect();
+ for e in &targets {
+ if let Some(table) = self.table_mut_existing(e.context) {
+ table.remove(&e.raw);
+ }
+ }
+ targets.len()
+ }
+
+ /// Fold `other`'s entries into this file. Same keys bound to the same
+ /// action count as unchanged; a different action is resolved by `prefer`.
+ /// `other`'s broken entries are skipped and reported, never copied.
+ pub fn merge(&mut self, other: &KeymapFile, prefer: MergePrefer) -> MergeReport {
+ let mut report = MergeReport {
+ skipped: other
+ .keymap()
+ .warnings
+ .into_iter()
+ .map(|w| w.message)
+ .collect(),
+ ..MergeReport::default()
+ };
+
+ let ours_defaults = self.use_defaults().unwrap_or(true);
+ if let Some(theirs) = other.use_defaults() {
+ if theirs != ours_defaults {
+ report.use_defaults = Some((ours_defaults, theirs));
+ if prefer == MergePrefer::Theirs {
+ self.set_use_defaults(theirs);
+ }
+ }
+ }
+
+ for incoming in other.entries() {
+ let name = |a: Option| a.map(KeyAction::name).unwrap_or("none").to_string();
+ let existing = self.entry(incoming.context, &incoming.canonical);
+ let change = MergeChange {
+ context: incoming.context,
+ keys: incoming.display.clone(),
+ ours: existing.as_ref().map(|e| name(e.action)),
+ theirs: name(incoming.action),
+ };
+ match existing {
+ None => {
+ // Parsed by `entries`, so binding can't fail.
+ let _ = self.bind(incoming.context, &incoming.raw, incoming.action);
+ report.added.push(change);
+ }
+ Some(e) if e.action == incoming.action => report.unchanged += 1,
+ Some(_) => match prefer {
+ MergePrefer::Theirs => {
+ let _ = self.bind(incoming.context, &incoming.raw, incoming.action);
+ report.changed.push(change);
+ }
+ MergePrefer::Ours => report.kept.push(change),
+ },
+ }
+ }
+ report
+ }
+
+ /// Rewrite the file in the generated layout: the explanatory header,
+ /// every valid entry with its action's description, and the defaults as a
+ /// commented reference. Returns the new file and what it could not carry
+ /// over (the current file's warnings; comments are regenerated).
+ pub fn document(&self) -> (KeymapFile, Vec) {
+ let entries: Vec = self
+ .entries()
+ .into_iter()
+ .map(|e| RenderEntry {
+ context: e.context,
+ keys: e.raw,
+ action: e.action,
+ })
+ .collect();
+ let rendered = render_file(
+ None,
+ self.use_defaults().unwrap_or(true),
+ &dedupe_last(entries),
+ true,
+ );
+ let dropped = self
+ .keymap()
+ .warnings
+ .into_iter()
+ .map(|w| w.message)
+ // Prefix clashes survive the rewrite; they aren't dropped entries.
+ .filter(|m| !m.contains("is the start of"))
+ .collect();
+ (
+ KeymapFile::parse(&rendered).expect("rendered keymap parses"),
+ dropped,
+ )
+ }
+
+ fn table(&self, context: BindingContext) -> Option<&dyn TableLike> {
+ self.doc.get(context.name()).and_then(Item::as_table_like)
+ }
+
+ fn table_mut_existing(&mut self, context: BindingContext) -> Option<&mut dyn TableLike> {
+ self.doc
+ .get_mut(context.name())
+ .and_then(Item::as_table_like_mut)
+ }
+
+ /// The table for `context`, created if missing (or replaced, if the key
+ /// holds something that isn't a table — `from_toml` already warns that
+ /// such a value is ignored).
+ fn table_mut(&mut self, context: BindingContext) -> &mut dyn TableLike {
+ let name = context.name();
+ let is_table = self
+ .doc
+ .get(name)
+ .is_some_and(|item| item.as_table_like().is_some());
+ if !is_table {
+ self.doc.insert(name, Item::Table(Table::new()));
+ }
+ self.doc
+ .get_mut(name)
+ .and_then(Item::as_table_like_mut)
+ .expect("table was just ensured")
+ }
+}
+
+fn same_keys(raw: &str, canonical: &str) -> bool {
+ parse_keys(raw).is_ok_and(|p| p.canonical == canonical)
+}
+
+/// Keep only the last entry per `(context, keystroke)`, as the loader does.
+fn dedupe_last(entries: Vec) -> Vec {
+ let mut out: Vec = Vec::new();
+ for e in entries {
+ let canonical = parse_keys(&e.keys).map(|p| p.canonical).ok();
+ out.retain(|o| {
+ !(o.context == e.context && parse_keys(&o.keys).map(|p| p.canonical).ok() == canonical)
+ });
+ out.push(e);
+ }
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const G: BindingContext = BindingContext::Global;
+ const O: BindingContext = BindingContext::Overlay;
+
+ fn action(file: &KeymapFile, context: BindingContext, keys: &str) -> Option