From 270b43f1dbd3c097dbefa7c882cbb3c4b1b962f3 Mon Sep 17 00:00:00 2001 From: Rfluid Date: Wed, 23 Sep 2026 22:41:22 -0300 Subject: [PATCH] feat(keys): configurable vim-style keybindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add keyboard shortcuts to the modal, driven by a keymap that users can customize in `~/.config/aura/keybindings.toml` and manage from the CLI. Keymap (aura-core `keymap`): - Action catalogue (scroll, navigate, commands) and vim-style defaults: j/k, ctrl-d/u, ctrl-f/b, gg/G, h/l + tab + gt/gT, 1-9, H/L + [/], m, p/P, r, `,`, `.`, `?`, q, esc. - `[global]` and `[overlay]` contexts, `"none"` to unbind, and `use_defaults = false` to start from an empty keymap. - Loading never fails. Bad entries are skipped with warnings: invalid TOML, unknown table or action (with a "did you mean"), bad keystroke, non-string value, duplicate spellings (`G` / `shift-g`), no-op unbinds, prefix conflicts (`g` vs `g g`), and bindings that take over the text-selection keys (ctrl-c, ctrl-a, shift+arrows). - `KeymapFile`: format-preserving edits via toml_edit (bind, remove, clear, set_action_keys, restore_action, merge, document), so CLI writes keep the user's comments and layout. Modal: - The root element holds a FocusHandle and the `Aura` / `overlay` key contexts; one GPUI action per KeyAction, wired exhaustively. - Escape clears a selection, then closes the open overlay, then closes the window. `q` closes the window. `quit` is available but unbound. - `?` opens a help overlay generated from the live keymap, with any keymap warnings at the top. A header chip shows the warning count, and settings gains a Keybindings entry. - The keymap is reloaded on every open and every refresh. - `[keybindings] enabled` in config.toml (default true) turns every shortcut off; the existing Escape observer covers that case. CLI (`aura keys`, alias `aura keybindings`): - path, list, describe (actions or a keystroke), get, set, unbind, reset (keys / --action / --all), wizard, merge (--prefer, --check, stdin), export, init (--full), document, validate, edit. - `aura doctor` reports the keybindings file and its warnings. - Restore default SIGPIPE for CLI runs, so `aura … | head` exits quietly instead of panicking. Docs: new docs/keybindings.md; configuration.md, cli.md and the README updated. --- .agent/memory/INDEX.md | 1 + .../facts/2026-09-23-gpui-keymap-dispatch.md | 33 + Cargo.lock | 2 + Cargo.toml | 3 + README.md | 26 +- assets/icons/keyboard.svg | 4 + crates/aura-core/Cargo.toml | 1 + crates/aura-core/src/config.rs | 29 + crates/aura-core/src/config_schema.rs | 27 +- crates/aura-core/src/keymap/file.rs | 683 +++++++++ crates/aura-core/src/keymap/mod.rs | 1336 +++++++++++++++++ crates/aura-core/src/lib.rs | 1 + crates/aura/src/app.rs | 604 +++++++- crates/aura/src/assets.rs | 1 + crates/aura/src/cli/doctor.rs | 40 +- crates/aura/src/cli/keys.rs | 863 +++++++++++ crates/aura/src/cli/mod.rs | 7 + crates/aura/src/keys.rs | 154 ++ crates/aura/src/main.rs | 41 +- crates/aura/src/runtime.rs | 17 +- docs/cli.md | 27 +- docs/configuration.md | 24 +- docs/keybindings.md | 278 ++++ 23 files changed, 4182 insertions(+), 20 deletions(-) create mode 100644 .agent/memory/facts/2026-09-23-gpui-keymap-dispatch.md create mode 100644 assets/icons/keyboard.svg create mode 100644 crates/aura-core/src/keymap/file.rs create mode 100644 crates/aura-core/src/keymap/mod.rs create mode 100644 crates/aura/src/cli/keys.rs create mode 100644 crates/aura/src/keys.rs create mode 100644 docs/keybindings.md 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> { + let canonical = parse_keys(keys).unwrap().canonical; + file.keymap().lookup(context, &canonical).map(|b| b.action) + } + + #[test] + fn bind_preserves_comments_and_replaces_other_spellings() { + let mut f = KeymapFile::parse( + r#"# my notes +[global] +"G" = "scroll_top" # keep me +"x" = "refresh" +"#, + ) + .unwrap(); + f.bind(G, "shift-g", Some(KeyAction::ScrollBottom)).unwrap(); + f.bind(G, "x", Some(KeyAction::ToggleHelp)).unwrap(); + let out = f.contents(); + assert!(out.contains("# my notes"), "{out}"); + assert!(!out.contains("\"G\""), "{out}"); + assert!(out.contains("\"shift-g\" = \"scroll_bottom\"") || out.contains("shift-g")); + assert_eq!(action(&f, G, "x"), Some(Some(KeyAction::ToggleHelp))); + assert_eq!(action(&f, G, "G"), Some(Some(KeyAction::ScrollBottom))); + } + + #[test] + fn bind_into_starter_file_and_new_context() { + let mut f = KeymapFile::default(); + f.bind(G, "g k", Some(KeyAction::OpenKeybindings)).unwrap(); + f.bind(O, "q", Some(KeyAction::CloseOverlay)).unwrap(); + assert!(f.keymap().warnings.is_empty(), "{:?}", f.keymap().warnings); + assert_eq!(action(&f, G, "g k"), Some(Some(KeyAction::OpenKeybindings))); + assert_eq!(action(&f, O, "q"), Some(Some(KeyAction::CloseOverlay))); + // The commented defaults reference is still there. + assert!(f.contents().contains("# Defaults:")); + } + + #[test] + fn bind_rejects_bad_keys() { + let mut f = KeymapFile::default(); + assert!(f.bind(G, "hyper-x", Some(KeyAction::Refresh)).is_err()); + } + + #[test] + fn remove_restores_the_default() { + let mut f = KeymapFile::parse("[global]\n\"j\" = \"refresh\"\n").unwrap(); + assert_eq!(f.remove(G, "j").unwrap(), 1); + assert_eq!(action(&f, G, "j"), Some(Some(KeyAction::ScrollDown))); + assert_eq!(f.remove(G, "j").unwrap(), 0); + } + + #[test] + fn clear_keeps_broken_entries() { + let mut f = + KeymapFile::parse("[global]\n\"j\" = \"refresh\"\n\"y\" = \"scrol_down\"\n").unwrap(); + assert_eq!(f.clear(None), 1); + assert!(f.contents().contains("scrol_down")); + } + + #[test] + fn set_action_keys_replaces_the_key_set() { + let mut f = KeymapFile::default(); + let notes = f + .set_action_keys( + G, + KeyAction::ScrollDown, + &["j".to_string(), "ctrl-n".to_string(), "x".to_string()], + ) + .unwrap(); + assert!(notes.is_empty(), "{notes:?}"); + let map = f.keymap(); + assert!(map.warnings.is_empty(), "{:?}", map.warnings); + assert_eq!( + map.keys_for(KeyAction::ScrollDown, G), + vec!["j", "ctrl-n", "x"] + ); + // `down` was a default of scroll_down, so it is now explicitly unbound. + assert_eq!(action(&f, G, "down"), Some(None)); + // `j` is still the default binding, so no entry was written for it. + assert!(f.entry(G, "j").is_none()); + } + + #[test] + fn set_action_keys_notes_stolen_keys_and_reverts_overrides() { + let mut f = KeymapFile::parse("[global]\n\"j\" = \"scroll_up\"\n").unwrap(); + let notes = f + .set_action_keys(G, KeyAction::ScrollUp, &["k".to_string(), "r".to_string()]) + .unwrap(); + // Dropping `j` from scroll_up hands it back to scroll_down. + assert!( + notes.iter().any(|n| n.contains("`j` goes back")), + "{notes:?}" + ); + assert!( + notes.iter().any(|n| n.contains("`r` was refresh")), + "{notes:?}" + ); + assert_eq!(action(&f, G, "j"), Some(Some(KeyAction::ScrollDown))); + assert_eq!(action(&f, G, "r"), Some(Some(KeyAction::ScrollUp))); + } + + #[test] + fn set_action_keys_to_nothing_unbinds_defaults() { + let mut f = KeymapFile::default(); + f.set_action_keys(G, KeyAction::Refresh, &[]).unwrap(); + assert!(f.keymap().keys_for(KeyAction::Refresh, G).is_empty()); + } + + #[test] + fn restore_action_undoes_everything_about_it() { + let mut f = KeymapFile::default(); + f.set_action_keys(G, KeyAction::Refresh, &["x".to_string()]) + .unwrap(); + assert_eq!(f.restore_action(G, KeyAction::Refresh), 3); + assert_eq!( + f.keymap().keys_for(KeyAction::Refresh, G), + Keymap::default_keys(KeyAction::Refresh, G) + ); + } + + #[test] + fn merge_adds_changes_and_reports() { + let mut ours = + KeymapFile::parse("[global]\n\"x\" = \"refresh\"\n\"y\" = \"quit\"\n").unwrap(); + let theirs = KeymapFile::parse( + "use_defaults = false\n[global]\n\"x\" = \"refresh\"\n\"y\" = \"toggle_help\"\n\"z\" = \"refresh\"\n\"w\" = \"nope\"\n", + ) + .unwrap(); + + let mut kept = ours.clone(); + let report = kept.merge(&theirs, MergePrefer::Ours); + assert_eq!(report.kept.len(), 1); + assert_eq!(report.added.len(), 1); + assert_eq!(report.unchanged, 1); + assert_eq!(report.use_defaults, Some((true, false))); + assert_eq!(kept.use_defaults(), None); + assert_eq!(action(&kept, G, "y"), Some(Some(KeyAction::Quit))); + + let report = ours.merge(&theirs, MergePrefer::Theirs); + assert_eq!(report.changed.len(), 1); + assert_eq!(report.added[0].keys, "z"); + assert!( + report.skipped.iter().any(|s| s.contains("nope")), + "{report:?}" + ); + assert_eq!(ours.use_defaults(), Some(false)); + assert_eq!(action(&ours, G, "y"), Some(Some(KeyAction::ToggleHelp))); + assert!(!ours.contents().contains("nope")); + } + + #[test] + fn document_keeps_values_and_reports_drops() { + let f = KeymapFile::parse( + "# gone\n[global]\n\"x\" = \"refresh\"\n\"G\" = \"scroll_top\"\n\"shift-g\" = \"quit\"\n\"y\" = \"scrol_down\"\n", + ) + .unwrap(); + let (doc, dropped) = f.document(); + let out = doc.contents(); + assert!(!out.contains("# gone")); + assert!(out.contains("# Refresh"), "{out}"); + assert!(!out.contains("scrol_down"), "{out}"); + assert!( + dropped.iter().any(|d| d.contains("scrol_down")), + "{dropped:?}" + ); + assert_eq!(action(&doc, G, "G"), Some(Some(KeyAction::Quit))); + assert!( + doc.keymap().warnings.is_empty(), + "{:?}", + doc.keymap().warnings + ); + assert_eq!(doc.keymap().bindings, f.keymap().bindings); + } + + #[test] + fn explicit_export_round_trips() { + let mut f = KeymapFile::default(); + f.bind(G, "x", Some(KeyAction::Refresh)).unwrap(); + f.bind(O, "q", None).unwrap(); + let map = f.keymap(); + let exported = Keymap::from_toml(&map.to_explicit_toml()); + assert!(exported.warnings.is_empty(), "{:?}", exported.warnings); + let key = |m: &Keymap| { + let mut v: Vec<_> = m + .bindings + .iter() + .map(|b| (b.context.name(), b.keys.clone(), b.action)) + .collect(); + v.sort_by(|a, b| (a.0, &a.1).cmp(&(b.0, &b.1))); + v + }; + assert_eq!(key(&exported), key(&map)); + } +} diff --git a/crates/aura-core/src/keymap/mod.rs b/crates/aura-core/src/keymap/mod.rs new file mode 100644 index 0000000..ba08d61 --- /dev/null +++ b/crates/aura-core/src/keymap/mod.rs @@ -0,0 +1,1336 @@ +//! Keyboard shortcuts for the modal: the action catalogue, the built-in +//! vim-style defaults, and the `keybindings.toml` override layer. +//! +//! This module is UI-toolkit agnostic. It resolves the effective keymap +//! (defaults merged with the user file) into plain [`Binding`]s and collects +//! every problem it finds as a [`KeymapWarning`]. The GUI turns the bindings +//! into toolkit key bindings; the CLI prints them and the warnings. +//! +//! # File format +//! +//! ```toml +//! # Start from the built-in defaults (true) or from an empty keymap (false). +//! use_defaults = true +//! +//! # One table per context. Keys are keystrokes, values are action names. +//! [global] +//! "ctrl-j" = "scroll_down" # add a binding +//! "x" = "refresh" # rebind +//! "t" = "none" # unbind a default +//! +//! # Checked before [global] while an overlay (menu, settings, help) is open. +//! [overlay] +//! "q" = "close_overlay" +//! ``` +//! +//! Keystrokes are `-`-joined modifiers followed by a key (`ctrl-d`, +//! `shift-tab`, `secondary-,`), and a space separates the strokes of a +//! sequence (`g g`). `secondary` is `cmd` on macOS and `ctrl` elsewhere. A +//! single uppercase letter means shift plus that letter, so `G` and +//! `shift-g` are the same binding. +//! +//! Loading never fails: an unreadable or malformed file falls back to the +//! defaults and says so in a warning, so a typo can never leave the modal +//! without its shortcuts. +//! +//! [`file::KeymapFile`] is the editing half: format-preserving changes to the +//! user file, used by the `aura keys` CLI. + +pub mod file; + +use std::{ + collections::HashSet, + fmt, fs, + path::{Path, PathBuf}, +}; + +use serde::Serialize; + +// ── Actions ────────────────────────────────────────────────────────────────── + +/// Something a keystroke can do. The snake_case [`name`](Self::name) is what +/// `keybindings.toml` spells it as. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum KeyAction { + ScrollDown, + ScrollUp, + HalfPageDown, + HalfPageUp, + PageDown, + PageUp, + ScrollTop, + ScrollBottom, + NextSection, + PrevSection, + Section1, + Section2, + Section3, + Section4, + Section5, + Section6, + Section7, + Section8, + Section9, + NextProfile, + PrevProfile, + ToggleMode, + NextPeriod, + PrevPeriod, + Refresh, + ToggleSettings, + ToggleMore, + ToggleHelp, + CloseOverlay, + Dismiss, + Quit, + OpenConfig, + OpenTheme, + OpenKeybindings, + OpenUpdate, + DismissUpdate, +} + +/// How the help overlay and `aura keys list` group actions. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ActionGroup { + Scroll, + Navigate, + Commands, +} + +impl ActionGroup { + pub const ALL: [Self; 3] = [Self::Scroll, Self::Navigate, Self::Commands]; + + pub fn label(self) -> &'static str { + match self { + Self::Scroll => "Scroll", + Self::Navigate => "Navigate", + Self::Commands => "Commands", + } + } +} + +/// One row of the action catalogue. +struct ActionInfo { + action: KeyAction, + name: &'static str, + group: ActionGroup, + description: &'static str, +} + +const fn info( + action: KeyAction, + name: &'static str, + group: ActionGroup, + description: &'static str, +) -> ActionInfo { + ActionInfo { + action, + name, + group, + description, + } +} + +use ActionGroup::{Commands, Navigate, Scroll}; +use KeyAction as A; + +/// Every action, in help-overlay order. +const ACTIONS: &[ActionInfo] = &[ + info(A::ScrollDown, "scroll_down", Scroll, "Scroll down one line"), + info(A::ScrollUp, "scroll_up", Scroll, "Scroll up one line"), + info( + A::HalfPageDown, + "half_page_down", + Scroll, + "Scroll down half a page", + ), + info( + A::HalfPageUp, + "half_page_up", + Scroll, + "Scroll up half a page", + ), + info(A::PageDown, "page_down", Scroll, "Scroll down a page"), + info(A::PageUp, "page_up", Scroll, "Scroll up a page"), + info(A::ScrollTop, "scroll_top", Scroll, "Jump to the top"), + info( + A::ScrollBottom, + "scroll_bottom", + Scroll, + "Jump to the bottom", + ), + info(A::NextSection, "next_section", Navigate, "Next section tab"), + info( + A::PrevSection, + "prev_section", + Navigate, + "Previous section tab", + ), + info(A::Section1, "section_1", Navigate, "Go to section 1"), + info(A::Section2, "section_2", Navigate, "Go to section 2"), + info(A::Section3, "section_3", Navigate, "Go to section 3"), + info(A::Section4, "section_4", Navigate, "Go to section 4"), + info(A::Section5, "section_5", Navigate, "Go to section 5"), + info(A::Section6, "section_6", Navigate, "Go to section 6"), + info(A::Section7, "section_7", Navigate, "Go to section 7"), + info(A::Section8, "section_8", Navigate, "Go to section 8"), + info(A::Section9, "section_9", Navigate, "Go to section 9"), + info( + A::NextProfile, + "next_profile", + Navigate, + "Next agent / plugin", + ), + info( + A::PrevProfile, + "prev_profile", + Navigate, + "Previous agent / plugin", + ), + info( + A::ToggleMode, + "toggle_mode", + Navigate, + "Switch between agents and plugins", + ), + info( + A::NextPeriod, + "next_period", + Navigate, + "Next period (all / 7d / 30d)", + ), + info(A::PrevPeriod, "prev_period", Navigate, "Previous period"), + info(A::Refresh, "refresh", Commands, "Refresh"), + info( + A::ToggleSettings, + "toggle_settings", + Commands, + "Open / close settings", + ), + info( + A::ToggleMore, + "toggle_more", + Commands, + "Open / close the more menu", + ), + info( + A::ToggleHelp, + "toggle_help", + Commands, + "Show / hide this help", + ), + info( + A::CloseOverlay, + "close_overlay", + Commands, + "Close the open menu or panel", + ), + info(A::Dismiss, "dismiss", Commands, "Close the window"), + info(A::Quit, "quit", Commands, "Quit Aura (tray icon included)"), + info(A::OpenConfig, "open_config", Commands, "Edit config.toml"), + info(A::OpenTheme, "open_theme", Commands, "Edit theme.toml"), + info( + A::OpenKeybindings, + "open_keybindings", + Commands, + "Edit keybindings.toml", + ), + info( + A::OpenUpdate, + "open_update", + Commands, + "Open the update instructions", + ), + info( + A::DismissUpdate, + "dismiss_update", + Commands, + "Hide the update button", + ), +]; + +impl KeyAction { + fn info(self) -> &'static ActionInfo { + ACTIONS + .iter() + .find(|i| i.action == self) + .expect("every KeyAction has an ACTIONS row") + } + + /// Every action, in help-overlay order. + pub fn all() -> impl Iterator { + ACTIONS.iter().map(|i| i.action) + } + + /// The name `keybindings.toml` uses, e.g. `"scroll_down"`. + pub fn name(self) -> &'static str { + self.info().name + } + + pub fn group(self) -> ActionGroup { + self.info().group + } + + /// One-line description for the help overlay. + pub fn description(self) -> &'static str { + self.info().description + } + + pub fn from_name(name: &str) -> Option { + ACTIONS.iter().find(|i| i.name == name).map(|i| i.action) + } +} + +impl Serialize for KeyAction { + fn serialize(&self, s: S) -> Result { + s.serialize_str(self.name()) + } +} + +impl fmt::Display for KeyAction { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.name()) + } +} + +// ── Contexts ───────────────────────────────────────────────────────────────── + +/// Where a binding applies. `Overlay` is layered over `Global`: while an +/// overlay is open, an overlay binding wins over a global one on the same +/// keys, and global bindings the overlay doesn't mention still work. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum BindingContext { + Global, + Overlay, +} + +impl BindingContext { + pub const ALL: [Self; 2] = [Self::Global, Self::Overlay]; + + /// The table name in `keybindings.toml`. + pub fn name(self) -> &'static str { + match self { + Self::Global => "global", + Self::Overlay => "overlay", + } + } + + pub fn from_name(name: &str) -> Option { + Self::ALL.into_iter().find(|c| c.name() == name) + } +} + +// ── Keystrokes ─────────────────────────────────────────────────────────────── + +/// Named (non-character) keys the keymap accepts. +const NAMED_KEYS: &[&str] = &[ + "escape", + "enter", + "tab", + "space", + "backspace", + "delete", + "insert", + "home", + "end", + "pageup", + "pagedown", + "up", + "down", + "left", + "right", +]; + +/// Friendlier spellings, folded into the canonical key name. +const KEY_ALIASES: &[(&str, &str)] = &[ + ("esc", "escape"), + ("return", "enter"), + ("del", "delete"), + ("ins", "insert"), + ("pgup", "pageup"), + ("pgdn", "pagedown"), + ("pgdown", "pagedown"), +]; + +/// One keystroke: modifiers plus a key, in canonical form. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct Stroke { + ctrl: bool, + alt: bool, + shift: bool, + cmd: bool, + func: bool, + key: String, +} + +impl Stroke { + fn parse(source: &str) -> Result { + let mut stroke = Stroke { + ctrl: false, + alt: false, + shift: false, + cmd: false, + func: false, + key: String::new(), + }; + + // A trailing `-` is the minus key itself (`ctrl--`, or bare `-`). + let (mods, key) = if source == "-" { + ("", "-") + } else if let Some(mods) = source.strip_suffix("--") { + (mods, "-") + } else { + match source.rsplit_once('-') { + Some((mods, key)) => (mods, key), + None => ("", source), + } + }; + + if !mods.is_empty() { + for m in mods.split('-') { + match m.to_ascii_lowercase().as_str() { + "ctrl" | "control" => stroke.ctrl = true, + "alt" | "option" | "opt" => stroke.alt = true, + "shift" => stroke.shift = true, + "cmd" | "command" | "super" | "win" => stroke.cmd = true, + "fn" => stroke.func = true, + "secondary" => { + if cfg!(target_os = "macos") { + stroke.cmd = true; + } else { + stroke.ctrl = true; + } + } + "" => return Err(format!("`{source}` has an empty modifier")), + other => { + return Err(format!( + "`{source}`: `{other}` is not a modifier \ + (expected ctrl, alt, shift, cmd, fn or secondary)" + )) + } + } + } + } + + if key.is_empty() { + return Err(format!("`{source}` has no key after its modifiers")); + } + + let mut chars = key.chars(); + let single = matches!((chars.next(), chars.next()), (Some(_), None)); + stroke.key = if single { + let c = key.chars().next().unwrap_or_default(); + if c.is_whitespace() { + return Err(format!("`{source}`: write a space as `space`")); + } + if c.is_ascii_uppercase() { + stroke.shift = true; + c.to_ascii_lowercase().to_string() + } else { + c.to_string() + } + } else { + let lower = key.to_ascii_lowercase(); + let lower = KEY_ALIASES + .iter() + .find(|(alias, _)| *alias == lower) + .map(|(_, canonical)| canonical.to_string()) + .unwrap_or(lower); + if !is_named_key(&lower) { + return Err(format!("`{source}`: `{key}` is not a key name")); + } + lower + }; + Ok(stroke) + } + + /// The form the toolkit's parser reads back: `ctrl-alt-shift-cmd-fn-key`. + fn canonical(&self) -> String { + let mut out = String::new(); + for (on, name) in [ + (self.ctrl, "ctrl-"), + (self.alt, "alt-"), + (self.shift, "shift-"), + (self.cmd, "cmd-"), + (self.func, "fn-"), + ] { + if on { + out.push_str(name); + } + } + out.push_str(&self.key); + out + } + + /// Short form for humans: `G` rather than `shift-g`, `esc` for `escape`. + fn display(&self) -> String { + let bare_shift = self.shift && !(self.ctrl || self.alt || self.cmd || self.func); + if bare_shift && self.key.len() == 1 && self.key.as_bytes()[0].is_ascii_lowercase() { + return self.key.to_ascii_uppercase(); + } + let key = if self.key == "escape" { + "esc" + } else { + self.key.as_str() + }; + let mut s = self.clone(); + s.key = key.to_string(); + s.canonical() + } +} + +fn is_named_key(key: &str) -> bool { + if NAMED_KEYS.contains(&key) { + return true; + } + key.strip_prefix('f') + .and_then(|n| n.parse::().ok()) + .is_some_and(|n| (1..=24).contains(&n)) +} + +/// A whitespace-separated keystroke sequence, e.g. `g g`. +fn parse_sequence(source: &str) -> Result, String> { + let strokes = source + .split_whitespace() + .map(Stroke::parse) + .collect::, _>>()?; + if strokes.is_empty() { + return Err("an empty keystroke".to_string()); + } + Ok(strokes) +} + +fn join(strokes: &[Stroke], f: impl Fn(&Stroke) -> String) -> String { + strokes.iter().map(f).collect::>().join(" ") +} + +/// A validated keystroke sequence in both of its spellings. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ParsedKeys { + /// What the toolkit parses and what two spellings compare equal on, + /// e.g. `"shift-g"`. + pub canonical: String, + /// The short human form, e.g. `"G"`. + pub display: String, +} + +/// Validate a keystroke sequence such as `"ctrl-d"` or `"g g"`. The error +/// says what is wrong with it. +pub fn parse_keys(source: &str) -> Result { + let strokes = parse_sequence(source).map_err(|e| format!("invalid keystroke {e}"))?; + Ok(ParsedKeys { + canonical: join(&strokes, Stroke::canonical), + display: join(&strokes, Stroke::display), + }) +} + +/// Resolve an action name as written in `keybindings.toml`. `"none"` (or an +/// empty string) is an unbind, `Ok(None)`; an unknown name is an error with a +/// "did you mean" when one is close. +pub fn parse_action(name: &str) -> Result, String> { + if is_unbind(name) { + return Ok(None); + } + KeyAction::from_name(name).map(Some).ok_or_else(|| { + let names: Vec<&str> = ACTIONS.iter().map(|i| i.name).collect(); + format!( + "unknown action `{name}`{} (run `aura keys describe` for the list)", + did_you_mean(name, &names) + ) + }) +} + +/// A TOML item as the user wrote it, for messages. +fn item_text(item: &toml_edit::Item) -> String { + one_line(item.to_string().trim()) +} + +fn one_line(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Keys the text-selection bridge already owns: copy, select-all, and +/// shift+arrow selection extension. Binding one of these takes it away. +fn reserved_reason(stroke: &Stroke) -> Option<&'static str> { + let primary = stroke.ctrl || stroke.cmd; + if primary && !stroke.alt && !stroke.shift { + match stroke.key.as_str() { + "c" => return Some("copies selected text"), + "a" => return Some("selects all text"), + _ => {} + } + } + if stroke.shift && matches!(stroke.key.as_str(), "up" | "down" | "left" | "right") { + return Some("extends a text selection"); + } + None +} + +// ── Keymap ─────────────────────────────────────────────────────────────────── + +/// Where a binding came from. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum BindingSource { + Default, + User, +} + +/// One resolved binding. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Binding { + pub context: BindingContext, + /// Canonical keystrokes, e.g. `"shift-g"` or `"g g"`, in the form the + /// toolkit's keystroke parser accepts. + pub keys: String, + /// The same keystrokes written for humans, e.g. `"G"`. + pub display: String, + /// `None` is an explicit unbind (`"x" = "none"`): the keys do nothing in + /// this context, even where a global binding would otherwise apply. + pub action: Option, + pub source: BindingSource, +} + +/// A problem found while loading `keybindings.toml`. The offending entry is +/// skipped; everything else still applies. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct KeymapWarning { + pub message: String, +} + +impl fmt::Display for KeymapWarning { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } +} + +/// The effective keymap: defaults merged with the user file. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Keymap { + pub bindings: Vec, + pub warnings: Vec, +} + +/// The built-in bindings, as `(context, keys, action)`. +const DEFAULTS: &[(BindingContext, &str, KeyAction)] = { + use BindingContext::{Global as G, Overlay as O}; + &[ + (G, "j", A::ScrollDown), + (G, "down", A::ScrollDown), + (G, "k", A::ScrollUp), + (G, "up", A::ScrollUp), + (G, "ctrl-d", A::HalfPageDown), + (G, "ctrl-u", A::HalfPageUp), + (G, "ctrl-f", A::PageDown), + (G, "pagedown", A::PageDown), + (G, "ctrl-b", A::PageUp), + (G, "pageup", A::PageUp), + (G, "g g", A::ScrollTop), + (G, "home", A::ScrollTop), + (G, "G", A::ScrollBottom), + (G, "end", A::ScrollBottom), + (G, "l", A::NextSection), + (G, "tab", A::NextSection), + (G, "g t", A::NextSection), + (G, "h", A::PrevSection), + (G, "shift-tab", A::PrevSection), + (G, "g T", A::PrevSection), + (G, "1", A::Section1), + (G, "2", A::Section2), + (G, "3", A::Section3), + (G, "4", A::Section4), + (G, "5", A::Section5), + (G, "6", A::Section6), + (G, "7", A::Section7), + (G, "8", A::Section8), + (G, "9", A::Section9), + (G, "L", A::NextProfile), + (G, "]", A::NextProfile), + (G, "H", A::PrevProfile), + (G, "[", A::PrevProfile), + (G, "m", A::ToggleMode), + (G, "p", A::NextPeriod), + (G, "P", A::PrevPeriod), + (G, "r", A::Refresh), + (G, "f5", A::Refresh), + (G, ",", A::ToggleSettings), + (G, "secondary-,", A::ToggleSettings), + (G, ".", A::ToggleMore), + (G, "?", A::ToggleHelp), + (G, "q", A::Dismiss), + (G, "escape", A::Dismiss), + (G, "e", A::OpenConfig), + (G, "t", A::OpenTheme), + (G, "u", A::OpenUpdate), + (G, "U", A::DismissUpdate), + (O, "escape", A::CloseOverlay), + ] +}; + +impl Keymap { + /// Default on-disk location: `$XDG_CONFIG_HOME/aura/keybindings.toml`. + pub fn default_path() -> PathBuf { + dirs::config_dir() + .unwrap_or_else(|| PathBuf::from("~/.config")) + .join("aura") + .join("keybindings.toml") + } + + /// The built-in keymap with no user overrides. + pub fn defaults() -> Self { + let bindings = DEFAULTS + .iter() + .map(|(context, keys, action)| { + let strokes = parse_sequence(keys).expect("built-in keystrokes parse"); + Binding { + context: *context, + keys: join(&strokes, Stroke::canonical), + display: join(&strokes, Stroke::display), + action: Some(*action), + source: BindingSource::Default, + } + }) + .collect(); + Self { + bindings, + warnings: Vec::new(), + } + } + + /// Load `path` over the defaults. A missing file is the defaults; an + /// unreadable or malformed one is the defaults plus a warning. + pub fn load(path: &Path) -> Self { + if !path.exists() { + return Self::defaults(); + } + match fs::read_to_string(path) { + Ok(content) => Self::from_toml(&content), + Err(e) => { + let mut map = Self::defaults(); + map.warn(format!( + "could not read {} ({e}); using the default keybindings", + path.display() + )); + map + } + } + } + + /// Parse a `keybindings.toml` body over the defaults. + pub fn from_toml(content: &str) -> Self { + // `toml_edit` rather than `toml`: it keeps the file's order, so + // warnings read top to bottom and "the later one wins" means what it + // says. + let doc: toml_edit::DocumentMut = match content.parse() { + Ok(d) => d, + Err(e) => { + let mut map = Self::defaults(); + map.warn(format!( + "keybindings.toml is not valid TOML, so none of it was applied; \ + using the default keybindings. {}", + one_line(&e.to_string()) + )); + return map; + } + }; + let root = doc.as_table(); + + let mut warnings = Vec::new(); + let use_defaults = match root.get("use_defaults") { + None => true, + Some(item) => match item.as_bool() { + Some(b) => b, + None => { + warnings.push(format!( + "use_defaults must be true or false, not {}; keeping the defaults", + item_text(item) + )); + true + } + }, + }; + + let mut map = if use_defaults { + Self::defaults() + } else { + Self { + bindings: Vec::new(), + warnings: Vec::new(), + } + }; + for w in warnings { + map.warn(w); + } + + for (key, value) in root.iter() { + if key == "use_defaults" { + continue; + } + let Some(context) = BindingContext::from_name(key) else { + let names: Vec<&str> = BindingContext::ALL.iter().map(|c| c.name()).collect(); + map.warn(format!( + "unknown table [{key}]{}; ignoring it (contexts are [global] and [overlay])", + did_you_mean(key, &names) + )); + continue; + }; + let Some(table) = value.as_table_like() else { + map.warn(format!( + "`{key}` must be a table of \"keys\" = \"action\" pairs; ignoring it" + )); + continue; + }; + map.apply_user_table(context, table); + } + + map.check_prefixes(); + map + } + + fn apply_user_table(&mut self, context: BindingContext, table: &dyn toml_edit::TableLike) { + let section = context.name(); + // Canonical keys this table has already bound, to catch two spellings + // of the same keystroke (`G` and `shift-g`). + let mut seen: HashSet = HashSet::new(); + + for (raw_keys, value) in table.iter() { + let strokes = match parse_sequence(raw_keys) { + Ok(s) => s, + Err(e) => { + self.warn(format!("[{section}] invalid keystroke {e}; ignoring it")); + continue; + } + }; + let keys = join(&strokes, Stroke::canonical); + + let Some(name) = value.as_str() else { + self.warn(format!( + "[{section}] \"{raw_keys}\": expected an action name in quotes \ + (or \"none\" to unbind), found {}; ignoring it", + item_text(value) + )); + continue; + }; + let action = match parse_action(name) { + Ok(a) => a, + Err(e) => { + self.warn(format!("[{section}] \"{raw_keys}\": {e}; ignoring it")); + continue; + } + }; + + if !seen.insert(keys.clone()) { + self.warn(format!( + "[{section}] \"{raw_keys}\" is the same keystroke as another entry in this \ + table; the later one wins" + )); + } + + if let (Some(action), Some(reason)) = (action, reserved_reason(&strokes[0])) { + self.warn(format!( + "[{section}] \"{raw_keys}\" = \"{action}\" takes over {} (it {reason})", + strokes[0].display() + )); + } + + let replaced = self.remove(context, &keys); + if action.is_none() && !replaced && !self.bound_below(context, &keys) { + self.warn(format!( + "[{section}] \"{raw_keys}\" = \"none\" unbinds nothing: that keystroke has \ + no binding here" + )); + } + + self.bindings.push(Binding { + context, + display: join(&strokes, Stroke::display), + keys, + action, + source: BindingSource::User, + }); + } + } + + /// Drop any binding for `keys` in `context`. Returns whether one existed. + fn remove(&mut self, context: BindingContext, keys: &str) -> bool { + let before = self.bindings.len(); + self.bindings + .retain(|b| !(b.context == context && b.keys == keys)); + self.bindings.len() != before + } + + /// Whether a context layered under `context` binds `keys` — the one case + /// where an unbind in `context` does something without replacing an entry. + fn bound_below(&self, context: BindingContext, keys: &str) -> bool { + context == BindingContext::Overlay + && self.bindings.iter().any(|b| { + b.context == BindingContext::Global && b.keys == keys && b.action.is_some() + }) + } + + /// Warn when one binding's keys are a prefix of another's in the same + /// effective context. The shorter one still works, but only after the + /// toolkit gives up waiting for the rest of the sequence (about a second). + fn check_prefixes(&mut self) { + let mut found = Vec::new(); + for context in BindingContext::ALL { + let active = self.active_in(context); + for short in &active { + for long in &active { + // In the overlay, only report pairs the overlay table is + // part of; purely global pairs were reported already. + if context == BindingContext::Overlay + && short.context == BindingContext::Global + && long.context == BindingContext::Global + { + continue; + } + let long_prefix = format!("{} ", short.keys); + if long.keys.starts_with(&long_prefix) { + found.push(format!( + "[{}] \"{}\" ({}) is the start of \"{}\" ({}): pressing {} waits about \ + a second for the next key before running {}", + short.context.name(), + short.display, + fmt_action(short.action), + long.display, + fmt_action(long.action), + short.display, + fmt_action(short.action), + )); + } + } + } + } + for w in found { + self.warn(w); + } + } + + /// Bindings that fire in `context`: its own, plus global ones it doesn't + /// override. Unbinds are left out. + fn active_in(&self, context: BindingContext) -> Vec<&Binding> { + let own: Vec<&Binding> = self + .bindings + .iter() + .filter(|b| b.context == context) + .collect(); + let mut out: Vec<&Binding> = own.iter().copied().filter(|b| b.action.is_some()).collect(); + if context == BindingContext::Overlay { + out.extend(self.bindings.iter().filter(|b| { + b.context == BindingContext::Global + && b.action.is_some() + && !own.iter().any(|o| o.keys == b.keys) + })); + } + out + } + + fn warn(&mut self, message: String) { + self.warnings.push(KeymapWarning { message }); + } + + /// Display strings for every binding that runs `action` in `context`. + pub fn keys_for(&self, action: KeyAction, context: BindingContext) -> Vec<&str> { + self.bindings + .iter() + .filter(|b| b.context == context && b.action == Some(action)) + .map(|b| b.display.as_str()) + .collect() + } + + /// Default display keys for `action` in `context` (empty when the + /// built-in keymap leaves it unbound). + pub fn default_keys(action: KeyAction, context: BindingContext) -> Vec { + DEFAULTS + .iter() + .filter(|(c, _, a)| *c == context && *a == action) + .filter_map(|(_, keys, _)| parse_keys(keys).ok()) + .map(|k| k.display) + .collect() + } + + /// What pressing `canonical` does in `context`: the context's own binding, + /// or — in the overlay — the global one it doesn't override. `None` when + /// nothing is bound; `Some(b)` with `b.action == None` for an unbind. + pub fn lookup(&self, context: BindingContext, canonical: &str) -> Option<&Binding> { + let own = |c: BindingContext| { + self.bindings + .iter() + .rev() + .find(|b| b.context == c && b.keys == canonical) + }; + own(context).or_else(|| match context { + BindingContext::Overlay => own(BindingContext::Global), + BindingContext::Global => None, + }) + } + + /// Starter `keybindings.toml`: how the file works, and every default + /// commented out so it reads as a reference without overriding anything. + /// Generated from [`DEFAULTS`] so it can never drift from them. + pub fn default_file_contents() -> String { + render_file(None, true, &[], true) + } + + /// This keymap as a self-contained `keybindings.toml`: `use_defaults = + /// false` and every binding written out, so the file means the same thing + /// whatever a later release changes in the defaults. + pub fn to_explicit_toml(&self) -> String { + let entries: Vec = self + .bindings + .iter() + // With no defaults underneath, a global unbind has nothing to mask. + .filter(|b| b.action.is_some() || b.context == BindingContext::Overlay) + .map(|b| RenderEntry { + context: b.context, + keys: b.display.clone(), + action: b.action, + }) + .collect(); + render_file( + Some("The complete keymap, written by `aura keys export` / `aura keys init --full`."), + false, + &entries, + false, + ) + } +} + +/// One `"keys" = "action"` line for [`render_file`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RenderEntry { + pub context: BindingContext, + /// Keystrokes as they should be written. + pub keys: String, + pub action: Option, +} + +const FILE_HEADER: &str = "\ +# ~/.config/aura/keybindings.toml — keyboard shortcuts for the Aura modal. +# +# Entries here are layered over the built-in defaults: bind a key to add or +# replace a shortcut, or bind it to \"none\" to remove one. Set +# `use_defaults = false` to start from an empty keymap instead. +# +# Keystrokes: modifiers joined with `-`, then the key (`ctrl-d`, `shift-tab`, +# `secondary-,` — secondary is cmd on macOS, ctrl elsewhere). A space starts +# the next stroke of a sequence (`g g`). `G` means `shift-g`. +# +# [global] applies everywhere; [overlay] is checked first while a menu, +# the settings panel or the help overlay is open. +# +# Turn every shortcut off with `[keybindings] enabled = false` in config.toml. +# `aura keys describe` lists every action, `aura keys set` / `wizard` edit this +# file, and `aura keys validate` checks it. Changes apply the next time the +# window opens, or on refresh. +"; + +/// Render a `keybindings.toml`: the explanatory header, `use_defaults`, then +/// one table per context with an aligned `# description` on every entry and, +/// when `reference` is set, the defaults listed as comments. +pub(crate) fn render_file( + note: Option<&str>, + use_defaults: bool, + entries: &[RenderEntry], + reference: bool, +) -> String { + let mut out = String::from(FILE_HEADER); + if let Some(note) = note { + out.push_str(&format!("#\n# {note}\n")); + } + out.push_str(&format!("\nuse_defaults = {use_defaults}\n")); + + for context in BindingContext::ALL { + out.push_str(&format!("\n[{}]\n", context.name())); + + let rows: Vec<(String, String, &str)> = entries + .iter() + .filter(|e| e.context == context) + .map(|e| { + ( + toml_key(&e.keys), + format!("\"{}\"", fmt_action(e.action)), + e.action.map(KeyAction::description).unwrap_or("unbound"), + ) + }) + .collect(); + let kw = rows.iter().map(|r| r.0.len()).max().unwrap_or(0); + let aw = rows.iter().map(|r| r.1.len()).max().unwrap_or(0); + for (key, action, description) in &rows { + out.push_str(&format!("{key: = DEFAULTS.iter().filter(|(c, _, _)| *c == context).collect(); + if !rows.is_empty() { + out.push('\n'); + } + out.push_str("# Defaults:\n"); + let width = defaults + .iter() + .map(|(_, keys, _)| toml_key(keys).len()) + .max() + .unwrap_or(0); + for (_, keys, action) in defaults { + out.push_str(&format!("# {: String { + format!("\"{}\"", keys.replace('\\', "\\\\").replace('"', "\\\"")) +} + +fn is_unbind(s: &str) -> bool { + s.is_empty() || s.eq_ignore_ascii_case("none") +} + +fn fmt_action(action: Option) -> &'static str { + action.map(KeyAction::name).unwrap_or("none") +} + +/// ` (did you mean `x`?)` for the closest candidate within a small edit +/// distance, or an empty string. +fn did_you_mean(input: &str, candidates: &[&str]) -> String { + candidates + .iter() + .map(|c| (levenshtein(input, c), *c)) + .filter(|(d, c)| *d <= 2.max(c.len() / 4)) + .min_by_key(|(d, _)| *d) + .map(|(_, c)| format!(" (did you mean `{c}`?)")) + .unwrap_or_default() +} + +fn levenshtein(a: &str, b: &str) -> usize { + let b: Vec = b.chars().collect(); + let mut prev: Vec = (0..=b.len()).collect(); + for (i, ca) in a.chars().enumerate() { + let mut cur = vec![i + 1; b.len() + 1]; + for (j, cb) in b.iter().enumerate() { + let cost = usize::from(ca != *cb); + cur[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(cur[j] + 1); + } + prev = cur; + } + prev[b.len()] +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn action_at(map: &Keymap, context: BindingContext, keys: &str) -> Option> { + map.bindings + .iter() + .find(|b| b.context == context && b.keys == keys) + .map(|b| b.action) + } + + fn messages(map: &Keymap) -> String { + map.warnings + .iter() + .map(|w| w.message.clone()) + .collect::>() + .join("\n") + } + + #[test] + fn every_action_has_a_unique_name() { + let names: HashSet<&str> = ACTIONS.iter().map(|i| i.name).collect(); + assert_eq!(names.len(), ACTIONS.len()); + for a in KeyAction::all() { + assert_eq!(KeyAction::from_name(a.name()), Some(a)); + } + } + + #[test] + fn defaults_are_clean() { + let map = Keymap::defaults(); + assert!(map.warnings.is_empty()); + // No accidental duplicates or prefix clashes in the shipped keymap. + let mut seen = HashSet::new(); + for b in &map.bindings { + assert!(seen.insert((b.context, b.keys.clone())), "dup {}", b.keys); + } + let mut checked = map.clone(); + checked.check_prefixes(); + assert!(checked.warnings.is_empty(), "{}", messages(&checked)); + } + + #[test] + fn default_file_is_a_no_op() { + let map = Keymap::from_toml(&Keymap::default_file_contents()); + assert!(map.warnings.is_empty(), "{}", messages(&map)); + assert_eq!(map.bindings, Keymap::defaults().bindings); + } + + #[test] + fn uppercase_is_shift() { + let map = Keymap::defaults(); + assert_eq!( + action_at(&map, BindingContext::Global, "shift-g"), + Some(Some(KeyAction::ScrollBottom)) + ); + let b = map.bindings.iter().find(|b| b.keys == "shift-g").unwrap(); + assert_eq!(b.display, "G"); + assert_eq!( + action_at(&map, BindingContext::Global, "g shift-t"), + Some(Some(KeyAction::PrevSection)) + ); + } + + #[test] + fn keystroke_parsing() { + let c = |s: &str| parse_sequence(s).map(|v| join(&v, Stroke::canonical)); + assert_eq!(c("Ctrl-Shift-X").unwrap(), "ctrl-shift-x"); + assert_eq!(c("shift-ctrl-x").unwrap(), "ctrl-shift-x"); + assert_eq!(c("esc").unwrap(), "escape"); + assert_eq!(c("ctrl--").unwrap(), "ctrl--"); + assert_eq!(c("-").unwrap(), "-"); + assert_eq!(c("f12").unwrap(), "f12"); + assert_eq!(c(" g g ").unwrap(), "g g"); + assert!(c("hyper-x").is_err()); + assert!(c("ctrl-").is_err()); + assert!(c("ctrl-escapee").is_err()); + assert!(c("f99").is_err()); + assert!(c("").is_err()); + let secondary = c("secondary-s").unwrap(); + if cfg!(target_os = "macos") { + assert_eq!(secondary, "cmd-s"); + } else { + assert_eq!(secondary, "ctrl-s"); + } + } + + #[test] + fn user_rebinds_adds_and_unbinds() { + let map = Keymap::from_toml( + r#" +[global] +"x" = "refresh" +"j" = "scroll_up" +"t" = "none" +"ctrl-j" = "scroll_down" +"#, + ); + assert!(map.warnings.is_empty(), "{}", messages(&map)); + let g = BindingContext::Global; + assert_eq!(action_at(&map, g, "x"), Some(Some(KeyAction::Refresh))); + assert_eq!(action_at(&map, g, "j"), Some(Some(KeyAction::ScrollUp))); + assert_eq!(action_at(&map, g, "t"), Some(None)); + assert_eq!( + action_at(&map, g, "ctrl-j"), + Some(Some(KeyAction::ScrollDown)) + ); + // Untouched defaults survive, and `r` still refreshes alongside `x`. + assert_eq!(action_at(&map, g, "r"), Some(Some(KeyAction::Refresh))); + assert_eq!(map.keys_for(KeyAction::Refresh, g), vec!["r", "f5", "x"]); + } + + #[test] + fn use_defaults_false_starts_empty() { + let map = Keymap::from_toml( + r#" +use_defaults = false +[global] +"j" = "scroll_down" +"#, + ); + assert!(map.warnings.is_empty(), "{}", messages(&map)); + assert_eq!(map.bindings.len(), 1); + } + + #[test] + fn overlay_unbind_masks_a_global_binding() { + let map = Keymap::from_toml( + r#" +[overlay] +"q" = "none" +"#, + ); + assert!(map.warnings.is_empty(), "{}", messages(&map)); + assert_eq!(action_at(&map, BindingContext::Overlay, "q"), Some(None)); + } + + #[test] + fn warns_on_bad_entries_and_keeps_the_rest() { + let map = Keymap::from_toml( + r#" +use_defaults = "yes" + +[globl] +"x" = "refresh" + +[global] +"ctrl-hyper-x" = "refresh" +"y" = "scrol_down" +"z" = 3 +"G" = "scroll_top" +"shift-g" = "scroll_bottom" +"ctrl-c" = "refresh" +"F9" = "none" +"n" = "refresh" +"#, + ); + let msg = messages(&map); + assert!(msg.contains("use_defaults must be true or false"), "{msg}"); + assert!( + msg.contains("unknown table [globl] (did you mean `global`?)"), + "{msg}" + ); + assert!(msg.contains("`hyper` is not a modifier"), "{msg}"); + assert!( + msg.contains("unknown action `scrol_down` (did you mean `scroll_down`?)"), + "{msg}" + ); + assert!(msg.contains("expected an action name"), "{msg}"); + assert!(msg.contains("same keystroke as another entry"), "{msg}"); + assert!(msg.contains("copies selected text"), "{msg}"); + assert!(msg.contains("unbinds nothing"), "{msg}"); + // The valid entry still landed. + assert_eq!( + action_at(&map, BindingContext::Global, "n"), + Some(Some(KeyAction::Refresh)) + ); + } + + #[test] + fn warns_on_prefix_conflicts() { + let map = Keymap::from_toml( + r#" +[global] +"g" = "refresh" +"#, + ); + let msg = messages(&map); + assert!( + msg.contains("\"g\" (refresh) is the start of \"g g\""), + "{msg}" + ); + } + + #[test] + fn invalid_toml_falls_back_to_defaults() { + let map = Keymap::from_toml("[global\n\"j\" = "); + assert_eq!(map.bindings, Keymap::defaults().bindings); + assert_eq!(map.warnings.len(), 1); + assert!(map.warnings[0].message.contains("not valid TOML")); + } + + #[test] + fn missing_file_is_defaults() { + let dir = tempfile::tempdir().unwrap(); + let map = Keymap::load(&dir.path().join("absent.toml")); + assert_eq!(map, Keymap::defaults()); + } +} diff --git a/crates/aura-core/src/lib.rs b/crates/aura-core/src/lib.rs index b89f0dd..99421f7 100644 --- a/crates/aura-core/src/lib.rs +++ b/crates/aura-core/src/lib.rs @@ -2,6 +2,7 @@ pub mod bin_path; pub mod config; pub mod config_migrate; pub mod config_schema; +pub mod keymap; pub mod lexicon; pub mod plugin; pub mod quota; diff --git a/crates/aura/src/app.rs b/crates/aura/src/app.rs index 8304f41..2b137a3 100644 --- a/crates/aura/src/app.rs +++ b/crates/aura/src/app.rs @@ -2,6 +2,7 @@ use std::{cell::Cell, path::PathBuf, rc::Rc, time::Duration}; use aura_core::{ config::{AgentConfig, AgentKind, AppConfig, PluginConfig}, + keymap::{ActionGroup, BindingContext, KeyAction, Keymap}, lexicon::{self, Lexicon}, plugin::{PluginContent, PluginControl, PluginPanel, PluginRunner, PluginSection}, quota::{ @@ -14,8 +15,8 @@ use aura_core::{ }; use chrono::{DateTime, Local, Timelike, Utc}; use gpui::{ - div, prelude::*, px, rgb, size, svg, AnyElement, ClickEvent, Context, ElementId, Pixels, - ScrollHandle, SharedString, Window, + div, prelude::*, px, rgb, size, svg, AnyElement, ClickEvent, Context, ElementId, FocusHandle, + Pixels, ScrollHandle, SharedString, Window, }; use gpui_selectable_text::{set_selection_theme, SelectableText, SelectionScope, SelectionStyle}; @@ -122,6 +123,42 @@ fn effective_section(active: AgentSection, visible: &[AgentSection]) -> AgentSec visible.first().copied().unwrap_or(AgentSection::Quota) } +/// The overlays a key binding can toggle. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Overlay { + More, + Settings, + Help, +} + +/// How far a scroll binding moves. +#[derive(Debug, Clone, Copy)] +enum Scroll { + /// Signed number of lines (positive is down). + Lines(f32), + /// Signed fraction of the visible height. + Pages(f32), + Top, + Bottom, +} + +/// One `j` / `k` step, in logical pixels — roughly one row of body text. +const SCROLL_LINE_PX: f32 = 40.0; + +/// Index `delta` steps from `current` in a list of `len`, wrapping around. +/// Starts from the first item when nothing is selected yet. `None` for an +/// empty list. +fn cycle(len: usize, current: Option, delta: isize) -> Option { + if len == 0 { + return None; + } + let Some(current) = current else { + return Some(0); + }; + let len = len as isize; + Some((current as isize + delta).rem_euclid(len) as usize) +} + pub struct AuraView { config: AppConfig, config_path: PathBuf, @@ -173,6 +210,16 @@ pub struct AuraView { show_more_modal: bool, show_settings_panel: bool, + /// The keyboard-shortcut cheat sheet (`?`). + show_help: bool, + /// Effective keymap (defaults + `keybindings.toml`), kept for the help + /// overlay and the warning chip. Installing it into GPUI is + /// `keys::install`'s job; this copy is only read for display. + keymap: Keymap, + keymap_path: PathBuf, + /// Held by the root element for the window's lifetime: key bindings + /// dispatch from the focused element, and nothing else here is focusable. + pub(crate) focus_handle: FocusHandle, is_loading: bool, /// The current refresh task. Replacing it cancels the foreground task and /// prevents an obsolete result from being delivered after a newer refresh @@ -222,6 +269,9 @@ pub struct AuraView { /// allowed to shrink below its content when the window hits the screen /// cap; without this we couldn't tell capped layouts from natural ones). body_scroll: ScrollHandle, + /// Scroll state of the help overlay's list, which the scroll bindings + /// drive instead of the body while it is open. + help_scroll: ScrollHandle, /// On Windows the modal is DWM-cloaked on open to hide the first-frame /// resize (open height → content height). This flag drives the uncloak that /// fires in `on_next_frame` after the first resize, so the window becomes @@ -238,6 +288,8 @@ struct RefreshResult { config: Option, /// Reloaded theme — `None` means "keep the old one". theme: Option, + /// Reloaded keymap. Always present: loading falls back to the defaults. + keymap: Keymap, /// `Some` if the active profile had to fall back to the first agent. fallback_profile: Option, snapshot: Option, @@ -252,6 +304,7 @@ impl AuraView { config: AppConfig, config_path: PathBuf, state: AppState, + keymap: Keymap, display_id: Option, tray_anchor: Option, cx: &mut Context, @@ -295,6 +348,10 @@ impl AuraView { update: None, show_more_modal: false, show_settings_panel: false, + show_help: false, + keymap, + keymap_path: Keymap::default_path(), + focus_handle: cx.focus_handle(), is_loading: false, refresh_task: None, refresh_generation: 0, @@ -306,6 +363,7 @@ impl AuraView { last_origin_request: Rc::new(Cell::new(None)), frame_extents: Rc::new(Cell::new(0.0)), body_scroll: ScrollHandle::new(), + help_scroll: ScrollHandle::new(), needs_uncloak: Rc::new(Cell::new(cfg!(target_os = "windows"))), }; // Initial load: kick off the async refresh now so the spinner can @@ -376,6 +434,7 @@ impl AuraView { let config_path = self.config_path.clone(); let theme_path = self.theme_path.clone(); + let keymap_path = self.keymap_path.clone(); let active_profile = self.active_profile.clone(); let period = self.active_period; let cached_panels = if period_only { @@ -391,6 +450,7 @@ impl AuraView { do_refresh( config_path, theme_path, + keymap_path, active_profile, period, cached_panels, @@ -419,6 +479,10 @@ impl AuraView { if let Some(theme) = result.theme { self.theme = theme; } + // Reinstall from the (possibly reloaded) config and keymap, so a + // Refresh picks up edits to either file, like it does for the theme. + crate::keys::install(cx, &result.keymap, self.config.keybindings.enabled); + self.keymap = result.keymap; if let Some(fallback) = result.fallback_profile { self.active_profile = fallback; } @@ -583,6 +647,7 @@ fn reusable_panel<'a>(cached: &'a [(String, PluginPanel)], name: &str) -> Option fn do_refresh( config_path: PathBuf, theme_path: PathBuf, + keymap_path: PathBuf, active_profile: String, period: Period, cached_panels: Vec<(String, PluginPanel)>, @@ -596,6 +661,7 @@ fn do_refresh( eprintln!("aura: theme.toml reload failed ({e}); using defaults"); Theme::default() })); + let keymap = Keymap::load(&keymap_path); // Reload config so edits made via the settings button take effect. // `load_with_discovery` also picks up any binaries added to the user @@ -607,6 +673,7 @@ fn do_refresh( return RefreshResult { config: None, theme, + keymap, fallback_profile: None, snapshot: None, quota: None, @@ -637,6 +704,7 @@ fn do_refresh( return RefreshResult { config: Some(config), theme, + keymap, fallback_profile, snapshot: None, quota: None, @@ -689,6 +757,7 @@ fn do_refresh( RefreshResult { config: Some(config), theme, + keymap, fallback_profile, snapshot, quota, @@ -844,6 +913,248 @@ impl AuraView { cx.notify(); } + fn close_help(&mut self, cx: &mut Context) { + self.show_help = false; + cx.notify(); + } + + /// Whether any overlay is up — what puts the root in the `overlay` key + /// context. + fn overlay_open(&self) -> bool { + self.show_more_modal || self.show_settings_panel || self.show_help + } + + /// Open `keybindings.toml` in the user's editor, seeding it with the + /// commented reference of every default on first use. Mirrors + /// `open_theme`. + fn open_keybindings(&mut self, cx: &mut Context) { + if !self.keymap_path.exists() { + if let Some(parent) = self.keymap_path.parent() { + if let Err(e) = std::fs::create_dir_all(parent) { + self.error = Some(format!("Could not create config dir: {e}")); + cx.notify(); + return; + } + } + if let Err(e) = std::fs::write(&self.keymap_path, Keymap::default_file_contents()) { + self.error = Some(format!("Could not create keybindings.toml: {e}")); + cx.notify(); + return; + } + } + self.open_in_editor(&self.keymap_path.clone(), cx); + } + + /// Show exactly one overlay, or none if `which` is already the one up. + /// Keyboard toggles are exclusive so Escape never has a stack to unwind. + fn toggle_overlay(&mut self, which: Overlay, cx: &mut Context) { + let was_open = match which { + Overlay::More => self.show_more_modal, + Overlay::Settings => self.show_settings_panel, + Overlay::Help => self.show_help, + }; + self.show_more_modal = false; + self.show_settings_panel = false; + self.show_help = false; + if !was_open { + match which { + Overlay::More => self.show_more_modal = true, + Overlay::Settings => self.show_settings_panel = true, + Overlay::Help => { + self.show_help = true; + self.help_scroll.set_offset(gpui::point(px(0.), px(0.))); + } + } + } + cx.notify(); + } + + /// Dispatch target for every keymap action (see `keys::listen`). + pub(crate) fn run_key_action( + &mut self, + action: KeyAction, + window: &mut Window, + cx: &mut Context, + ) { + use KeyAction as K; + match action { + K::ScrollDown => self.scroll(Scroll::Lines(1.0), cx), + K::ScrollUp => self.scroll(Scroll::Lines(-1.0), cx), + K::HalfPageDown => self.scroll(Scroll::Pages(0.5), cx), + K::HalfPageUp => self.scroll(Scroll::Pages(-0.5), cx), + K::PageDown => self.scroll(Scroll::Pages(1.0), cx), + K::PageUp => self.scroll(Scroll::Pages(-1.0), cx), + K::ScrollTop => self.scroll(Scroll::Top, cx), + K::ScrollBottom => self.scroll(Scroll::Bottom, cx), + K::NextSection => self.step_section(1, cx), + K::PrevSection => self.step_section(-1, cx), + K::Section1 => self.go_to_section(0, cx), + K::Section2 => self.go_to_section(1, cx), + K::Section3 => self.go_to_section(2, cx), + K::Section4 => self.go_to_section(3, cx), + K::Section5 => self.go_to_section(4, cx), + K::Section6 => self.go_to_section(5, cx), + K::Section7 => self.go_to_section(6, cx), + K::Section8 => self.go_to_section(7, cx), + K::Section9 => self.go_to_section(8, cx), + K::NextProfile => self.step_profile(1, cx), + K::PrevProfile => self.step_profile(-1, cx), + K::ToggleMode => { + let other = match self.mode { + Mode::Agent => Mode::Plugin, + Mode::Plugin => Mode::Agent, + }; + self.set_mode(other, cx); + } + K::NextPeriod => self.step_period(1, cx), + K::PrevPeriod => self.step_period(-1, cx), + K::Refresh => { + if !self.is_loading { + self.refresh(cx); + } + } + K::ToggleSettings => self.toggle_overlay(Overlay::Settings, cx), + K::ToggleMore => self.toggle_overlay(Overlay::More, cx), + K::ToggleHelp => self.toggle_overlay(Overlay::Help, cx), + // Both keep Escape's layering: a live text selection is cleared + // before anything closes. + K::CloseOverlay => { + if !gpui_selectable_text::registry::clear_active_selection(window, cx) { + self.show_more_modal = false; + self.show_settings_panel = false; + self.show_help = false; + cx.notify(); + } + } + K::Dismiss => { + if !gpui_selectable_text::registry::clear_active_selection(window, cx) { + crate::runtime::request_dismiss(); + } + } + K::Quit => cx.quit(), + K::OpenConfig => self.open_config(cx), + K::OpenTheme => self.open_theme(cx), + K::OpenKeybindings => self.open_keybindings(cx), + K::OpenUpdate => { + if self.show_update_button() { + self.open_update_instructions(cx); + } + } + K::DismissUpdate => { + if self.show_update_button() { + self.dismiss_update(cx); + } + } + } + } + + /// Scroll the help list while it is open, the body otherwise. + fn scroll(&mut self, by: Scroll, cx: &mut Context) { + let handle = if self.show_help { + &self.help_scroll + } else { + &self.body_scroll + }; + let max = f32::from(handle.max_offset().height).max(0.0); + let viewport = f32::from(handle.bounds().size.height); + let mut offset = handle.offset(); + // Offsets grow negative as the content moves up. + let current = -f32::from(offset.y); + let target = match by { + Scroll::Lines(n) => current + n * SCROLL_LINE_PX, + // Keep one line of overlap so a full page never skips content. + Scroll::Pages(n) => current + n * (viewport - SCROLL_LINE_PX).max(SCROLL_LINE_PX), + Scroll::Top => 0.0, + Scroll::Bottom => max, + }; + offset.y = px(-target.clamp(0.0, max)); + handle.set_offset(offset); + cx.notify(); + } + + fn step_section(&mut self, delta: isize, cx: &mut Context) { + match self.mode { + Mode::Agent => { + let visible = self.visible_agent_sections(); + let current = self.effective_agent_section(); + let i = visible.iter().position(|s| *s == current); + if let Some(next) = cycle(visible.len(), i, delta) { + self.set_agent_section(visible[next], cx); + } + } + Mode::Plugin => { + let ids = self.plugin_section_ids(); + let i = self + .active_plugin_section + .as_deref() + .and_then(|id| ids.iter().position(|s| s == id)); + if let Some(next) = cycle(ids.len(), i, delta) { + self.set_plugin_section(ids[next].clone(), cx); + } + } + } + } + + fn go_to_section(&mut self, index: usize, cx: &mut Context) { + match self.mode { + Mode::Agent => { + if let Some(s) = self.visible_agent_sections().get(index) { + self.set_agent_section(*s, cx); + } + } + Mode::Plugin => { + if let Some(id) = self.plugin_section_ids().get(index) { + self.set_plugin_section(id.clone(), cx); + } + } + } + } + + fn plugin_section_ids(&self) -> Vec { + self.current_plugin_panel() + .map(|p| p.sections.iter().map(|s| s.id.clone()).collect()) + .unwrap_or_default() + } + + /// Next / previous pill in the selector row: agent profiles in Agent + /// mode, plugins in Plugin mode. + fn step_profile(&mut self, delta: isize, cx: &mut Context) { + match self.mode { + Mode::Agent => { + let names: Vec = + self.config.agents.iter().map(|a| a.name.clone()).collect(); + let i = names.iter().position(|n| *n == self.active_profile); + if let Some(next) = cycle(names.len(), i, delta) { + self.set_profile(names[next].clone(), cx); + } + } + Mode::Plugin => { + let names: Vec = + self.config.plugins.iter().map(|p| p.name.clone()).collect(); + let i = self + .active_plugin + .as_deref() + .and_then(|a| names.iter().position(|n| n == a)); + if let Some(next) = cycle(names.len(), i, delta) { + self.set_plugin(names[next].clone(), cx); + } + } + } + } + + /// Cycle the period pills. A no-op while they are hidden, since the + /// section on screen doesn't filter by period. + fn step_period(&mut self, delta: isize, cx: &mut Context) { + if !self.current_section_uses_period() { + return; + } + const PERIODS: [Period; 3] = [Period::AllTime, Period::Last7Days, Period::Last30Days]; + let i = PERIODS.iter().position(|p| *p == self.active_period); + if let Some(next) = cycle(PERIODS.len(), i, delta) { + self.set_period(PERIODS[next], cx); + } + } + /// Sections the active agent actually has something to show in. fn visible_agent_sections(&self) -> Vec { visible_sections( @@ -918,7 +1229,7 @@ impl AuraView { // ── Render ──────────────────────────────────────────────────────────────────── impl Render for AuraView { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { // Publish the themed accent at ~20% alpha. The crate resolves this // during paint, so runtime theme changes retint live selections. set_selection_theme( @@ -948,7 +1259,12 @@ impl Render for AuraView { let tray_anchor = self.tray_anchor; #[cfg(target_os = "windows")] let needs_uncloak = self.needs_uncloak.clone(); - let mut root = div() + // The root holds focus so the keymap dispatches here, and carries the + // `overlay` key context while one is open (see `keys`). + let focus_root = div() + .track_focus(&self.focus_handle) + .key_context(crate::keys::root_context(self.overlay_open())); + let mut root = crate::keys::listen(focus_root, cx) .flex() .flex_col() .w_full() @@ -1216,6 +1532,10 @@ impl Render for AuraView { if self.show_settings_panel { root = root.child(self.render_settings_panel(cx)); } + if self.show_help { + let max_h = window.viewport_size().height - px(32.0); + root = root.child(self.render_help_overlay(max_h, cx)); + } root } } @@ -1269,6 +1589,9 @@ impl AuraView { if self.show_update_button() { actions = actions.child(self.render_update_button(cx)); } + if self.config.keybindings.enabled && !self.keymap.warnings.is_empty() { + actions = actions.child(self.render_keymap_warning_chip(cx)); + } actions = actions .child( icon_button("act-refresh", "icons/rotate_cw.svg", &self.theme).on_click( @@ -3007,6 +3330,279 @@ impl AuraView { })), ); + // ── Keybindings ────────────────────────────────────────────────────── + card = card.child( + div() + .id("settings-keybindings") + .flex() + .flex_row() + .items_center() + .gap_2() + .px_2() + .py_2() + .rounded_md() + .text_xs() + .text_color(rgb(text)) + .hover(move |d| d.bg(rgb(surface_hi))) + .child(svg_icon("icons/keyboard.svg", text_dim, 14.0)) + .child("Keybindings") + .on_click(cx.listener(|view, _: &ClickEvent, _, cx| { + view.close_settings_panel(cx); + view.toggle_overlay(Overlay::Help, cx); + })), + ); + + backdrop.child(card).into_any_element() + } + + /// Header chip shown while `keybindings.toml` has problems. Clicking it + /// opens the help overlay, which lists them. + fn render_keymap_warning_chip(&self, cx: &mut Context) -> AnyElement { + let warning = self.theme.colors.warning; + let surface_hi = self.theme.colors.surface_hi; + div() + .id("keymap-warnings") + .flex() + .flex_row() + .items_center() + .gap_1() + .px_1p5() + .py_0p5() + .rounded_md() + .border_1() + .border_color(rgb(warning)) + .text_xs() + .text_color(rgb(warning)) + .hover(move |d| d.bg(rgb(surface_hi))) + .child(svg_icon("icons/keyboard.svg", warning, 12.0)) + .child(SharedString::from(self.keymap.warnings.len().to_string())) + .on_click( + cx.listener(|view, _: &ClickEvent, _, cx| view.toggle_overlay(Overlay::Help, cx)), + ) + .into_any_element() + } + + /// Rows of the help overlay for one group: `(keys, description)`, with + /// actions that have no binding left out. + fn help_rows(&self, group: ActionGroup) -> Vec<(Vec, &'static str)> { + let keys_of = |a: KeyAction| { + let mut keys: Vec = Vec::new(); + for context in BindingContext::ALL { + for k in self.keymap.keys_for(a, context) { + if !keys.iter().any(|seen| seen == k) { + keys.push(k.to_string()); + } + } + } + keys + }; + + // `section_1` … `section_9` on the digit keys read better as one row. + let sections = [ + KeyAction::Section1, + KeyAction::Section2, + KeyAction::Section3, + KeyAction::Section4, + KeyAction::Section5, + KeyAction::Section6, + KeyAction::Section7, + KeyAction::Section8, + KeyAction::Section9, + ]; + let on_digits = sections + .iter() + .enumerate() + .all(|(i, a)| keys_of(*a) == [(i + 1).to_string()]); + + let mut rows = Vec::new(); + for action in KeyAction::all().filter(|a| a.group() == group) { + if on_digits && sections.contains(&action) { + if action == KeyAction::Section1 { + rows.push((vec!["1…9".to_string()], "Go to section N")); + } + continue; + } + let keys = keys_of(action); + if !keys.is_empty() { + rows.push((keys, action.description())); + } + } + rows + } + + /// The keyboard-shortcut cheat sheet, generated from the live keymap, with + /// any `keybindings.toml` warnings on top. + fn render_help_overlay(&self, max_h: Pixels, cx: &mut Context) -> AnyElement { + let colors = self.theme.colors; + + let backdrop = div() + .id("help-backdrop") + .absolute() + .inset_0() + .bg(rgba(0x000000a0)) + .flex() + .flex_col() + .items_center() + .justify_center() + .on_click(cx.listener(|view, _: &ClickEvent, _, cx| view.close_help(cx))); + + let close_hint = self + .keymap + .keys_for(KeyAction::CloseOverlay, BindingContext::Overlay) + .first() + .map(|k| format!("{k} to close")) + .unwrap_or_default(); + let title = div() + .flex() + .flex_row() + .flex_shrink_0() + .items_center() + .justify_between() + .px_3() + .py_2() + .border_b_1() + .border_color(rgb(colors.border)) + .child( + div() + .flex() + .flex_row() + .items_center() + .gap_2() + .text_sm() + .child(svg_icon("icons/keyboard.svg", colors.text_dim, 14.0)) + .child("Keyboard shortcuts"), + ) + .child( + div() + .text_xs() + .text_color(rgb(colors.text_dim)) + .child(SharedString::from(close_hint)), + ); + + let mut list = div() + .id("help-scroll") + .flex() + .flex_col() + .flex_1() + .min_h_0() + .gap_3() + .px_3() + .py_2() + .overflow_y_scroll() + .track_scroll(&self.help_scroll); + + if !self.config.keybindings.enabled { + list = list.child(div().text_xs().text_color(rgb(colors.text_dim)).child(sel( + "help-disabled", + "Keyboard shortcuts are off. Set `enabled = true` under \ + [keybindings] in config.toml to turn them on.", + ))); + } else { + if !self.keymap.warnings.is_empty() { + let mut block = div() + .flex() + .flex_col() + .gap_1() + .p_2() + .rounded_md() + .border_1() + .border_color(rgb(colors.warning)) + .text_xs() + .text_color(rgb(colors.warning)) + .child(sel( + "help-warnings-title", + "keybindings.toml has problems (skipped entries):", + )); + for (i, w) in self.keymap.warnings.iter().enumerate() { + block = block.child(sel( + sid(format!("help-warning-{i}")), + format!("• {}", w.message), + )); + } + list = list.child(block); + } + + for group in ActionGroup::ALL { + let rows = self.help_rows(group); + if rows.is_empty() { + continue; + } + let mut section = div().flex().flex_col().gap_1().child( + div() + .text_xs() + .text_color(rgb(colors.text_dim)) + .child(group.label()), + ); + for (keys, description) in rows { + let mut chips = div() + .flex() + .flex_row() + .flex_wrap() + .gap_1() + .w(px(132.0)) + .flex_shrink_0(); + for k in keys { + chips = chips.child( + div() + .px_1() + .rounded_sm() + .bg(rgb(colors.surface_hi)) + .text_color(rgb(colors.text)) + .child(SharedString::from(k)), + ); + } + section = section.child( + div() + .flex() + .flex_row() + .items_start() + .gap_2() + .text_xs() + .child(chips) + .child(div().text_color(rgb(colors.text_dim)).child(description)), + ); + } + list = list.child(section); + } + } + + let surface_hi = colors.surface_hi; + let footer = div() + .id("help-edit-keybindings") + .flex() + .flex_row() + .flex_shrink_0() + .items_center() + .gap_2() + .px_3() + .py_2() + .border_t_1() + .border_color(rgb(colors.border)) + .text_xs() + .text_color(rgb(colors.text)) + .hover(move |d| d.bg(rgb(surface_hi))) + .child(svg_icon("icons/arrow_up_right.svg", colors.text_dim, 14.0)) + .child("Edit keybindings.toml") + .on_click(cx.listener(|view, _: &ClickEvent, _, cx| { + view.open_keybindings(cx); + view.close_help(cx); + })); + + let card = div() + .id("help-card") + .flex() + .flex_col() + .w(px(WINDOW_WIDTH - 32.0)) + .max_h(max_h) + .bg(rgb(colors.surface)) + .rounded_md() + .border_1() + .border_color(rgb(colors.border)) + .on_click(cx.listener(|_, _: &ClickEvent, _, _| {})) + .child(title) + .child(list) + .child(footer); + backdrop.child(card).into_any_element() } diff --git a/crates/aura/src/assets.rs b/crates/aura/src/assets.rs index c793652..4be2e88 100644 --- a/crates/aura/src/assets.rs +++ b/crates/aura/src/assets.rs @@ -32,6 +32,7 @@ icon_assets! { (CHEVRON_DOWN, "chevron_down.svg"), (CHEVRON_UP, "chevron_up.svg"), (INFO, "info.svg"), + (KEYBOARD, "keyboard.svg"), (RTK, "rtk.svg"), } diff --git a/crates/aura/src/cli/doctor.rs b/crates/aura/src/cli/doctor.rs index fac0a5f..11eca6e 100644 --- a/crates/aura/src/cli/doctor.rs +++ b/crates/aura/src/cli/doctor.rs @@ -2,7 +2,9 @@ //! theme load result. Pure local introspection; no network calls. use anyhow::Result; -use aura_core::{config::AppConfig, config_migrate, plugin, state::AppState, theme::Theme}; +use aura_core::{ + config::AppConfig, config_migrate, keymap::Keymap, plugin, state::AppState, theme::Theme, +}; use clap::Args; use serde::Serialize; @@ -21,6 +23,7 @@ struct DoctorReport { config: ConfigStatus, state: StateStatus, theme: ThemeStatus, + keybindings: KeybindingsStatus, agents: Vec, plugins: PluginStatus, } @@ -30,6 +33,7 @@ struct Paths { config: String, state: String, theme: String, + keybindings: String, plugins_dir: String, } @@ -57,6 +61,15 @@ struct ThemeStatus { error: Option, } +#[derive(Debug, Serialize)] +struct KeybindingsStatus { + /// `[keybindings] enabled` in config.toml (true when it can't be read). + enabled: bool, + exists: bool, + /// Problems in keybindings.toml; the offending entries are skipped. + warnings: Vec, +} + #[derive(Debug, Serialize)] struct AgentRow { name: String, @@ -89,10 +102,13 @@ fn collect() -> DoctorReport { let config_path = AppConfig::default_path(); let state_path = AppState::state_path(); let theme_path = Theme::default_path(); + let keymap_path = Keymap::default_path(); let plugins_dir = plugin::user_plugins_dir(); + let mut keybindings_enabled = true; let (config_status, agents, plugins) = match AppConfig::load_with_discovery(&config_path) { Ok(cfg) => { + keybindings_enabled = cfg.keybindings.enabled; let agents: Vec = cfg .agents .iter() @@ -180,16 +196,28 @@ fn collect() -> DoctorReport { } }; + let keybindings = KeybindingsStatus { + enabled: keybindings_enabled, + exists: keymap_path.exists(), + warnings: Keymap::load(&keymap_path) + .warnings + .into_iter() + .map(|w| w.message) + .collect(), + }; + DoctorReport { paths: Paths { config: config_path.to_string_lossy().into_owned(), state: state_path.to_string_lossy().into_owned(), theme: theme_path.to_string_lossy().into_owned(), + keybindings: keymap_path.to_string_lossy().into_owned(), plugins_dir: plugins_dir.to_string_lossy().into_owned(), }, config: config_status, state: state_status, theme: theme_status, + keybindings, agents, plugins, } @@ -217,6 +245,7 @@ fn render_text(r: &DoctorReport) { println!(" config {}", r.paths.config); println!(" state {}", r.paths.state); println!(" theme {}", r.paths.theme); + println!(" keybindings {}", r.paths.keybindings); println!(" plugins dir {}", r.paths.plugins_dir); println!(); println!( @@ -247,6 +276,15 @@ fn render_text(r: &DoctorReport) { if let Some(err) = &r.theme.error { println!(" error: {err}"); } + println!( + "Keys: enabled={} exists={} warnings={}", + r.keybindings.enabled, + r.keybindings.exists, + r.keybindings.warnings.len() + ); + for w in &r.keybindings.warnings { + println!(" warning: {w}"); + } println!(); println!("Agents:"); if r.agents.is_empty() { diff --git a/crates/aura/src/cli/keys.rs b/crates/aura/src/cli/keys.rs new file mode 100644 index 0000000..04cb614 --- /dev/null +++ b/crates/aura/src/cli/keys.rs @@ -0,0 +1,863 @@ +//! `aura keys …` subcommands for the modal's keyboard shortcuts. +//! +//! The read side mirrors `aura config`: `describe` explains actions (or what +//! a keystroke does), `get` looks one keystroke up, `list` prints the +//! effective keymap, `validate` reports problems. The write side edits +//! `keybindings.toml` in place through `aura_core::keymap::file::KeymapFile`, +//! which keeps the user's comments and layout: `set`, `unbind`, `reset`, +//! `wizard`, `merge`. `init`, `export` and `document` generate whole files. + +use std::io::{self, BufRead, Read, Write}; +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; +use aura_core::{ + config::AppConfig, + keymap::{ + file::{KeymapFile, MergePrefer, MergeReport}, + parse_action, parse_keys, ActionGroup, Binding, BindingContext, BindingSource, KeyAction, + Keymap, KeymapWarning, + }, +}; +use clap::{Args, Subcommand, ValueEnum}; +use serde::Serialize; + +use super::format::{print_json, OutputFormat}; +use super::theme::open_in_editor; + +#[derive(Debug, Args)] +pub struct KeysCli { + #[command(subcommand)] + command: KeysCommand, +} + +/// `--context` values. +#[derive(Debug, Clone, Copy, ValueEnum, Default)] +enum ContextArg { + /// Applies everywhere. + #[default] + Global, + /// Checked first while a menu, the settings panel or the help is open. + Overlay, +} + +impl From for BindingContext { + fn from(c: ContextArg) -> Self { + match c { + ContextArg::Global => BindingContext::Global, + ContextArg::Overlay => BindingContext::Overlay, + } + } +} + +/// `merge --prefer` values. +#[derive(Debug, Clone, Copy, ValueEnum, Default)] +enum PreferArg { + /// The incoming file wins conflicts. + #[default] + Theirs, + /// Your file wins conflicts. + Ours, +} + +#[derive(Debug, Subcommand)] +enum KeysCommand { + /// Print the keybindings file path. + Path, + /// Print the effective keymap: defaults merged with keybindings.toml. + List { + /// Only this context. + #[arg(long, value_enum)] + context: Option, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, + }, + /// List every action with its keys — or explain one action (`scroll_down`) + /// or keystroke (`G`, `"g g"`). + #[command(alias = "actions")] + Describe { + /// An action name or a keystroke to explain (omit to list everything). + target: Option, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, + }, + /// Print what a keystroke does (e.g. `get G`, `get "g g"`). + Get { + keys: String, + #[arg(long, value_enum, default_value_t = ContextArg::Global)] + context: ContextArg, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, + }, + /// Bind a keystroke to an action and save (e.g. `set ctrl-j scroll_down`). + Set { + keys: String, + /// Action name, or `none` to unbind. + action: String, + #[arg(long, value_enum, default_value_t = ContextArg::Global)] + context: ContextArg, + }, + /// Remove a keystroke's binding, including a default (`= "none"`). + Unbind { + keys: String, + #[arg(long, value_enum, default_value_t = ContextArg::Global)] + context: ContextArg, + }, + /// Drop your overrides so the defaults apply again: for one keystroke, + /// one action (`--action`), or everything (`--all`). + Reset { + /// Keystroke whose entry to remove. + #[arg(conflicts_with_all = ["action", "all"])] + keys: Option, + /// Restore this action's default keys. + #[arg(long, conflicts_with = "all")] + action: Option, + /// Remove every entry (broken ones are left for you to fix). + #[arg(long)] + all: bool, + /// Context to reset. Defaults to global, or to both with `--all`. + #[arg(long, value_enum)] + context: Option, + }, + /// Interactively walk every action, keeping its keys on blank input. + Wizard { + #[arg(long, value_enum, default_value_t = ContextArg::Global)] + context: ContextArg, + }, + /// Fold another keybindings file into yours (`-` reads stdin). + Merge { + file: PathBuf, + /// Who wins when both files bind the same keys differently. + #[arg(long, value_enum, default_value_t = PreferArg::Theirs)] + prefer: PreferArg, + /// Report what would change and exit non-zero if anything would, + /// without touching your file. + #[arg(long)] + check: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, + }, + /// Print the effective keymap as a self-contained keybindings.toml + /// (`use_defaults = false`, every binding written out). + Export, + /// Write a starter keybindings.toml listing every default as a comment. + Init { + /// Overwrite an existing keybindings file. + #[arg(long)] + force: bool, + /// Write every default as a live binding with `use_defaults = false`, + /// instead of a commented reference. + #[arg(long)] + full: bool, + }, + /// Rewrite keybindings.toml in the generated layout, keeping every valid + /// entry and adding a description to each. + Document { + /// Rewrite even if some entries can't be carried over (they are dropped). + #[arg(long)] + force: bool, + }, + /// Check keybindings.toml and report problems. Exits 1 if there are any. + Validate { + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, + }, + /// Open keybindings.toml in `$EDITOR` (seeds it if missing). + Edit, +} + +impl KeysCli { + pub fn run(self) -> Result<()> { + let path = Keymap::default_path(); + match self.command { + KeysCommand::Path => { + println!("{}", path.display()); + Ok(()) + } + KeysCommand::List { context, format } => { + run_list(&path, context.map(Into::into), format) + } + KeysCommand::Describe { target, format } => { + run_describe(&path, target.as_deref(), format) + } + KeysCommand::Get { + keys, + context, + format, + } => run_get(&path, &keys, context.into(), format), + KeysCommand::Set { + keys, + action, + context, + } => run_set(&path, &keys, &action, context.into()), + KeysCommand::Unbind { keys, context } => run_set(&path, &keys, "none", context.into()), + KeysCommand::Reset { + keys, + action, + all, + context, + } => run_reset(&path, keys, action, all, context.map(Into::into)), + KeysCommand::Wizard { context } => run_wizard(&path, context.into()), + KeysCommand::Merge { + file, + prefer, + check, + format, + } => run_merge(&path, &file, prefer, check, format), + KeysCommand::Export => { + let file = KeymapFile::load(&path)?; + let keymap = file.keymap(); + print_warnings_stderr(&keymap.warnings); + print!("{}", keymap.to_explicit_toml()); + Ok(()) + } + KeysCommand::Init { force, full } => run_init(&path, force, full), + KeysCommand::Document { force } => run_document(&path, force), + KeysCommand::Validate { format } => run_validate(&path, format), + KeysCommand::Edit => { + if !path.exists() { + KeymapFile::default().save(&path)?; + } + open_in_editor(&path)?; + print_warnings_stderr(&Keymap::load(&path).warnings); + Ok(()) + } + } + } +} + +// ── read ───────────────────────────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +struct ListReport<'a> { + /// `[keybindings] enabled` from config.toml. + enabled: bool, + path: String, + bindings: Vec<&'a Binding>, + warnings: &'a [KeymapWarning], +} + +fn run_list(path: &Path, context: Option, format: OutputFormat) -> Result<()> { + let keymap = Keymap::load(path); + let report = ListReport { + enabled: keybindings_enabled(), + path: path.to_string_lossy().into_owned(), + bindings: keymap + .bindings + .iter() + .filter(|b| context.is_none_or(|c| c == b.context)) + .collect(), + warnings: &keymap.warnings, + }; + if let OutputFormat::Json = format { + return print_json(&report); + } + + note_if_disabled_for("This is the keymap they would use."); + for context in BindingContext::ALL { + let rows: Vec<_> = report + .bindings + .iter() + .filter(|b| b.context == context) + .collect(); + if rows.is_empty() { + continue; + } + println!("[{}]", context.name()); + let width = rows.iter().map(|b| b.display.len()).max().unwrap_or(0); + for b in rows { + let action = b.action.map(KeyAction::name).unwrap_or("none (unbound)"); + let origin = match b.source { + BindingSource::Default => "", + BindingSource::User => " (keybindings.toml)", + }; + println!(" {:, + overlay: Vec, +} + +fn action_info(keymap: &Keymap, action: KeyAction) -> ActionInfo { + let keys_in = |c: BindingContext| -> Vec { + keymap + .keys_for(action, c) + .into_iter() + .map(str::to_string) + .collect() + }; + let default_keys = ContextKeys { + global: Keymap::default_keys(action, BindingContext::Global), + overlay: Keymap::default_keys(action, BindingContext::Overlay), + }; + let keys = ContextKeys { + global: keys_in(BindingContext::Global), + overlay: keys_in(BindingContext::Overlay), + }; + ActionInfo { + name: action.name(), + group: action.group(), + description: action.description(), + customized: default_keys != keys, + default_keys, + keys, + } +} + +fn run_describe(path: &Path, target: Option<&str>, format: OutputFormat) -> Result<()> { + let keymap = Keymap::load(path); + let Some(target) = target else { + let infos: Vec = KeyAction::all().map(|a| action_info(&keymap, a)).collect(); + if let OutputFormat::Json = format { + return print_json(&infos); + } + describe_all(&infos); + return Ok(()); + }; + + if let Some(action) = KeyAction::from_name(target) { + let info = action_info(&keymap, action); + if let OutputFormat::Json = format { + return print_json(&info); + } + describe_action(&info); + return Ok(()); + } + // Not an action: maybe a keystroke. Otherwise explain the action typo, + // which is the likelier intent for a word. + match parse_keys(target) { + Ok(_) => run_get(path, target, BindingContext::Global, format), + Err(_) => match parse_action(target) { + Err(e) => bail!("`{target}` is neither an action nor a keystroke: {e}"), + Ok(_) => bail!("`{target}` is not an action"), + }, + } +} + +fn describe_all(infos: &[ActionInfo]) { + note_if_disabled_for(""); + println!("Actions — bind with `aura keys set `:\n"); + let name_w = infos.iter().map(|i| i.name.len()).max().unwrap_or(0); + let keys_of = |i: &ActionInfo| { + let mut all = i.keys.global.clone(); + for k in &i.keys.overlay { + all.push(format!("{k} (overlay)")); + } + if all.is_empty() { + "—".to_string() + } else { + all.join(", ") + } + }; + let keys_w = infos + .iter() + .map(|i| keys_of(i).chars().count()) + .max() + .unwrap_or(0) + .min(28); + for group in ActionGroup::ALL { + println!("{}", group.label()); + for i in infos.iter().filter(|i| i.group == group) { + let mark = if i.customized { "*" } else { " " }; + println!( + " {mark}{:` or `aura keys describe `."); +} + +fn describe_action(info: &ActionInfo) { + let fmt = |keys: &[String]| { + if keys.is_empty() { + "(unbound)".to_string() + } else { + keys.join(", ") + } + }; + println!("{} ({})", info.name, info.group.label()); + println!(" {}", info.description); + println!(); + println!(" default: {}", fmt(&info.default_keys.global)); + println!(" current: {}", fmt(&info.keys.global)); + if !info.default_keys.overlay.is_empty() || !info.keys.overlay.is_empty() { + println!( + " overlay: {} (default: {})", + fmt(&info.keys.overlay), + fmt(&info.default_keys.overlay) + ); + } + println!(); + println!(" bind: aura keys set {}", info.name); + if info.customized { + println!(" restore: aura keys reset --action {}", info.name); + } +} + +#[derive(Debug, Serialize)] +struct Lookup { + context: BindingContext, + keys: String, + display: String, + /// `None` when nothing is bound (or it is explicitly unbound). + action: Option, + source: Option, + /// True when an overlay lookup fell through to the global binding. + inherited: bool, +} + +fn run_get(path: &Path, keys: &str, context: BindingContext, format: OutputFormat) -> Result<()> { + let parsed = parse_keys(keys).map_err(anyhow::Error::msg)?; + let keymap = Keymap::load(path); + let found = keymap.lookup(context, &parsed.canonical); + let lookup = Lookup { + context, + keys: parsed.canonical.clone(), + display: parsed.display.clone(), + action: found.and_then(|b| b.action), + source: found.map(|b| b.source), + inherited: found.is_some_and(|b| b.context != context), + }; + if let OutputFormat::Json = format { + return print_json(&lookup); + } + let what = match found { + None => "(unbound)".to_string(), + Some(b) => { + let action = b.action.map(KeyAction::name).unwrap_or("(unbound)"); + let source = match b.source { + BindingSource::Default => "default", + BindingSource::User => "keybindings.toml", + }; + let via = if lookup.inherited { + ", from [global]" + } else { + "" + }; + format!("{action} ({source}{via})") + } + }; + println!("[{}] {} → {what}", context.name(), parsed.display); + if let Some(a) = lookup.action { + println!(" {}", a.description()); + } + Ok(()) +} + +fn run_validate(path: &Path, format: OutputFormat) -> Result<()> { + let keymap = Keymap::load(path); + match format { + OutputFormat::Json => print_json(&keymap.warnings)?, + OutputFormat::Text => { + if !path.exists() { + println!("{} does not exist; the defaults apply.", path.display()); + } else if keymap.warnings.is_empty() { + println!("{}: OK", path.display()); + } else { + println!("{}:", path.display()); + for w in &keymap.warnings { + println!(" warning: {w}"); + } + } + } + } + if !keymap.warnings.is_empty() { + std::process::exit(1); + } + Ok(()) +} + +// ── write ──────────────────────────────────────────────────────────────────── + +fn run_set(path: &Path, keys: &str, action: &str, context: BindingContext) -> Result<()> { + let action = parse_action(action).map_err(anyhow::Error::msg)?; + let parsed = parse_keys(keys).map_err(anyhow::Error::msg)?; + let mut file = KeymapFile::load(path)?; + let before = file.keymap(); + let previous = before + .lookup(context, &parsed.canonical) + .and_then(|b| b.action); + + file.bind(context, keys, action) + .map_err(anyhow::Error::msg)?; + save(&file, path, &before)?; + + let was = match previous { + Some(p) if Some(p) != action => format!(" (was {p})"), + _ => String::new(), + }; + match action { + Some(a) => println!( + "set [{}] \"{}\" = \"{a}\"{was}", + context.name(), + parsed.display + ), + None => println!("unbound [{}] \"{}\"{was}", context.name(), parsed.display), + } + Ok(()) +} + +fn run_reset( + path: &Path, + keys: Option, + action: Option, + all: bool, + context: Option, +) -> Result<()> { + if !path.exists() { + println!( + "{} does not exist; the defaults already apply.", + path.display() + ); + return Ok(()); + } + let mut file = KeymapFile::load(path)?; + let before = file.keymap(); + + let removed = if all { + file.clear(context) + } else if let Some(name) = action { + let Some(action) = parse_action(&name).map_err(anyhow::Error::msg)? else { + bail!("`--action none` is not an action"); + }; + file.restore_action(context.unwrap_or(BindingContext::Global), action) + } else if let Some(keys) = keys { + file.remove(context.unwrap_or(BindingContext::Global), &keys) + .map_err(anyhow::Error::msg)? + } else { + bail!("say what to reset: a keystroke, `--action `, or `--all`"); + }; + + if removed == 0 { + println!("Nothing to reset: keybindings.toml has no matching entry."); + return Ok(()); + } + save(&file, path, &before)?; + println!( + "Removed {removed} entr{} from {}; the defaults apply there again.", + if removed == 1 { "y" } else { "ies" }, + path.display() + ); + Ok(()) +} + +fn run_wizard(path: &Path, context: BindingContext) -> Result<()> { + let mut file = KeymapFile::load(path)?; + let before = file.keymap(); + + println!("Editing {} — [{}]", path.display(), context.name()); + note_if_disabled_for("Changes apply once they are turned on."); + println!( + "For each action: Enter keeps its keys; type keys separated by commas to replace them\n\ + (e.g. `j, ctrl-n`); `none` unbinds it; `default` restores its defaults; `stop` ends early.\n" + ); + + let stdin = io::stdin(); + let mut changed = false; + 'groups: for group in ActionGroup::ALL { + println!("── {} ──", group.label()); + for action in KeyAction::all().filter(|a| a.group() == group) { + loop { + let map = file.keymap(); + let current = map.keys_for(action, context); + let custom = current.iter().map(|k| k.to_string()).collect::>() + != Keymap::default_keys(action, context); + print!( + "{}{} — {} [{}]: ", + if custom { "*" } else { "" }, + action.name(), + action.description(), + if current.is_empty() { + "unbound".to_string() + } else { + current.join(", ") + } + ); + io::stdout().flush().ok(); + + let mut line = String::new(); + if stdin.lock().read_line(&mut line)? == 0 { + // EOF: keep what we have. + println!(); + break 'groups; + } + let input = line.trim(); + let result = match input { + "" => break, + "stop" => break 'groups, + "default" => { + file.restore_action(context, action); + Ok(Vec::new()) + } + "none" => file.set_action_keys(context, action, &[]), + list => { + let keys: Vec = list + .split(',') + .map(str::trim) + .filter(|k| !k.is_empty()) + .map(str::to_string) + .collect(); + file.set_action_keys(context, action, &keys) + } + }; + match result { + Ok(notes) => { + for n in notes { + println!(" note: {n}"); + } + changed = true; + break; + } + Err(e) => println!(" {e}\n"), + } + } + } + println!(); + } + + if changed { + save(&file, path, &before)?; + println!("Saved {}", path.display()); + } else { + println!("No changes."); + } + Ok(()) +} + +fn run_merge( + path: &Path, + source: &Path, + prefer: PreferArg, + check: bool, + format: OutputFormat, +) -> Result<()> { + let content = if source == Path::new("-") { + let mut s = String::new(); + io::stdin().read_to_string(&mut s).context("read stdin")?; + s + } else { + std::fs::read_to_string(source).with_context(|| format!("read {}", source.display()))? + }; + let theirs = + KeymapFile::parse(&content).with_context(|| format!("parse {}", source.display()))?; + let prefer = match prefer { + PreferArg::Theirs => MergePrefer::Theirs, + PreferArg::Ours => MergePrefer::Ours, + }; + + let mut file = KeymapFile::load(path)?; + let before = file.keymap(); + let report = file.merge(&theirs, prefer); + let changes = report.changes_anything(prefer); + + match format { + OutputFormat::Json => print_json(&report)?, + OutputFormat::Text => print_merge(&report, source, path, prefer), + } + + if check { + if changes { + if let OutputFormat::Text = format { + println!( + "\nWould change {} — run without --check to apply.", + path.display() + ); + } + std::process::exit(1); + } + return Ok(()); + } + if changes { + save(&file, path, &before)?; + if let OutputFormat::Text = format { + println!("\nSaved {}", path.display()); + } + } else if let OutputFormat::Text = format { + println!("\nNothing to merge."); + } + Ok(()) +} + +fn print_merge(report: &MergeReport, source: &Path, path: &Path, prefer: MergePrefer) { + let side = match prefer { + MergePrefer::Theirs => "theirs", + MergePrefer::Ours => "ours", + }; + println!( + "Merging {} into {} (conflicts: {side} win)", + source.display(), + path.display() + ); + for c in &report.added { + println!( + " + [{}] \"{}\" = \"{}\"", + c.context.name(), + c.keys, + c.theirs + ); + } + for c in &report.changed { + println!( + " ~ [{}] \"{}\": {} → {}", + c.context.name(), + c.keys, + c.ours.as_deref().unwrap_or("—"), + c.theirs + ); + } + for c in &report.kept { + println!( + " ! [{}] \"{}\": kept {} (theirs: {})", + c.context.name(), + c.keys, + c.ours.as_deref().unwrap_or("—"), + c.theirs + ); + } + if let Some((ours, theirs)) = report.use_defaults { + match prefer { + MergePrefer::Theirs => println!(" ~ use_defaults: {ours} → {theirs}"), + MergePrefer::Ours => println!(" ! use_defaults: kept {ours} (theirs: {theirs})"), + } + } + if report.unchanged > 0 { + println!(" = {} already the same", report.unchanged); + } + if !report.skipped.is_empty() { + println!("\nNot merged — problems in {}:", source.display()); + for s in &report.skipped { + println!(" {s}"); + } + } +} + +fn run_init(path: &Path, force: bool, full: bool) -> Result<()> { + if path.exists() && !force { + println!( + "{} already exists (pass --force to overwrite).", + path.display() + ); + return Ok(()); + } + let contents = if full { + Keymap::defaults().to_explicit_toml() + } else { + Keymap::default_file_contents() + }; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("create config dir {}", parent.display()))?; + } + std::fs::write(path, contents).with_context(|| format!("write {}", path.display()))?; + println!("Wrote {}", path.display()); + Ok(()) +} + +fn run_document(path: &Path, force: bool) -> Result<()> { + let existed = path.exists(); + let file = KeymapFile::load(path)?; + let (documented, dropped) = file.document(); + if !dropped.is_empty() { + println!("These can't be carried over and would be dropped:"); + for d in &dropped { + println!(" {d}"); + } + if !force { + println!("\nFix them first, or pass --force to drop them."); + std::process::exit(1); + } + println!(); + } + documented.save(path)?; + if existed { + println!( + "Rewrote {} with inline documentation ({} entr{} kept).", + path.display(), + documented.entries().len(), + if documented.entries().len() == 1 { + "y" + } else { + "ies" + } + ); + } else { + println!("Created {} with inline documentation.", path.display()); + } + Ok(()) +} + +// ── helpers ────────────────────────────────────────────────────────────────── + +/// Save `file`, then point out any warning the change introduced. +fn save(file: &KeymapFile, path: &Path, before: &Keymap) -> Result<()> { + file.save(path)?; + let after = file.keymap(); + for w in after + .warnings + .iter() + .filter(|w| !before.warnings.contains(w)) + { + eprintln!("warning: {w}"); + } + Ok(()) +} + +/// `[keybindings] enabled`, defaulting to on when config.toml can't be read +/// (the modal would fall back the same way). +fn keybindings_enabled() -> bool { + let path = AppConfig::default_path(); + std::fs::read_to_string(&path) + .ok() + .and_then(|c| AppConfig::parse(&c).ok()) + .map(|(cfg, _)| cfg.keybindings.enabled) + .unwrap_or(true) +} + +fn note_if_disabled_for(extra: &str) { + if !keybindings_enabled() { + println!( + "note: keyboard shortcuts are off ([keybindings] enabled = false in config.toml). {extra}\n" + ); + } +} + +fn print_warnings_block(warnings: &[KeymapWarning]) { + if warnings.is_empty() { + return; + } + println!("Warnings:"); + for w in warnings { + println!(" {w}"); + } +} + +fn print_warnings_stderr(warnings: &[KeymapWarning]) { + for w in warnings { + eprintln!("warning: {w}"); + } +} diff --git a/crates/aura/src/cli/mod.rs b/crates/aura/src/cli/mod.rs index f7ad05e..3004ec0 100644 --- a/crates/aura/src/cli/mod.rs +++ b/crates/aura/src/cli/mod.rs @@ -15,6 +15,7 @@ mod completions; mod config; mod doctor; mod format; +mod keys; mod plugin; mod quota; mod resolve; @@ -53,6 +54,11 @@ pub enum Command { /// Inspect and seed the user theme (`~/.config/aura/theme.toml`). Theme(theme::ThemeCli), + /// Inspect, check and seed the modal's keyboard shortcuts + /// (`~/.config/aura/keybindings.toml`). + #[command(alias = "keybindings")] + Keys(keys::KeysCli), + /// List configured agent profiles and their detection status. Agents(agents::AgentsCli), @@ -90,6 +96,7 @@ pub fn dispatch(command: Command) -> Result<()> { Command::Config(args) => args.run(), Command::State(args) => args.run(), Command::Theme(args) => args.run(), + Command::Keys(args) => args.run(), Command::Agents(args) => args.run(), Command::Plugin(args) => args.run(), Command::Usage(args) => args.run(), diff --git a/crates/aura/src/keys.rs b/crates/aura/src/keys.rs new file mode 100644 index 0000000..deff1b1 --- /dev/null +++ b/crates/aura/src/keys.rs @@ -0,0 +1,154 @@ +//! Bridges the toolkit-agnostic keymap in `aura_core::keymap` to GPUI: one +//! GPUI action per [`KeyAction`], the listeners that route them to +//! [`AuraView::run_key_action`], and installing the resolved bindings. +//! +//! Contexts map onto GPUI key contexts set on the modal's root element: +//! `[global]` bindings match `Aura`, `[overlay]` bindings match `overlay`, +//! which the root adds while a menu, panel or the help overlay is open. Both +//! live on the same element, so they tie on depth and GPUI falls back to +//! insertion order — which is why overlay bindings are installed last. + +use std::{rc::Rc, sync::Mutex}; + +use aura_core::keymap::{BindingContext, KeyAction, Keymap, KeymapWarning}; +use gpui::{ + actions, App, Context, DummyKeyboardMapper, InteractiveElement, KeyBinding, + KeyBindingContextPredicate, KeyContext, NoAction, +}; + +use crate::app::AuraView; + +/// Key context identifier for the modal as a whole. +pub const CONTEXT_ROOT: &str = "Aura"; +/// Key context identifier added while an overlay is open. +pub const CONTEXT_OVERLAY: &str = "overlay"; + +/// Declares a GPUI action per [`KeyAction`] variant (same name) plus the two +/// exhaustive mappings between them, so a new variant can't be forgotten. +macro_rules! key_actions { + ($($name:ident),* $(,)?) => { + actions!(aura, [$($name),*]); + + fn boxed(action: KeyAction) -> Box { + match action { + $(KeyAction::$name => Box::new($name),)* + } + } + + /// Attach a listener for every keymap action to `el`. + pub fn listen(el: E, cx: &mut Context) -> E { + el $(.on_action(cx.listener(|view: &mut AuraView, _: &$name, window, cx| { + view.run_key_action(KeyAction::$name, window, cx) + })))* + } + }; +} + +key_actions!( + ScrollDown, + ScrollUp, + HalfPageDown, + HalfPageUp, + PageDown, + PageUp, + ScrollTop, + ScrollBottom, + NextSection, + PrevSection, + Section1, + Section2, + Section3, + Section4, + Section5, + Section6, + Section7, + Section8, + Section9, + NextProfile, + PrevProfile, + ToggleMode, + NextPeriod, + PrevPeriod, + Refresh, + ToggleSettings, + ToggleMore, + ToggleHelp, + CloseOverlay, + Dismiss, + Quit, + OpenConfig, + OpenTheme, + OpenKeybindings, + OpenUpdate, + DismissUpdate, +); + +/// The key context the modal's root element carries. +pub fn root_context(overlay_open: bool) -> KeyContext { + let mut context = KeyContext::new_with_defaults(); + context.add(CONTEXT_ROOT); + if overlay_open { + context.add(CONTEXT_OVERLAY); + } + context +} + +fn predicate(context: BindingContext) -> &'static str { + match context { + BindingContext::Global => CONTEXT_ROOT, + BindingContext::Overlay => CONTEXT_OVERLAY, + } +} + +/// Replace the app's key bindings with `keymap`, or with nothing when +/// `enabled` is false. Warnings go to stderr, once per distinct set, since +/// this runs on every open and every refresh. +pub fn install(cx: &mut App, keymap: &Keymap, enabled: bool) { + cx.clear_key_bindings(); + crate::runtime::set_keybindings_active(enabled); + if !enabled { + return; + } + report(&keymap.warnings); + + let mut bindings = Vec::with_capacity(keymap.bindings.len()); + // Global first: overlay bindings must come later to win their ties. + for context in BindingContext::ALL { + let predicate = KeyBindingContextPredicate::parse(predicate(context)) + .ok() + .map(Rc::new); + for binding in keymap.bindings.iter().filter(|b| b.context == context) { + // An unbind masks whatever a lower context binds to these keys. + let action = binding + .action + .map(boxed) + .unwrap_or_else(|| Box::new(NoAction)); + match KeyBinding::load( + &binding.keys, + action, + predicate.clone(), + false, + None, + &DummyKeyboardMapper, + ) { + Ok(b) => bindings.push(b), + Err(e) => eprintln!("aura: keybindings.toml: {e}"), + } + } + } + cx.bind_keys(bindings); +} + +fn report(warnings: &[KeymapWarning]) { + static LAST: Mutex> = Mutex::new(Vec::new()); + let Ok(mut last) = LAST.lock() else { + return; + }; + if last.as_slice() == warnings { + return; + } + for w in warnings { + eprintln!("aura: keybindings.toml: {w}"); + } + *last = warnings.to_vec(); +} diff --git a/crates/aura/src/main.rs b/crates/aura/src/main.rs index 7e9d3d2..5ef643f 100644 --- a/crates/aura/src/main.rs +++ b/crates/aura/src/main.rs @@ -6,6 +6,7 @@ mod app; mod assets; mod cli; mod format; +mod keys; mod placement; mod platform; mod runtime; @@ -79,6 +80,15 @@ fn main() -> Result<()> { // through to the tray entry point below. let cli = cli::Cli::parse(); if let Some(command) = cli.command { + // Rust ignores SIGPIPE, which turns `aura keys describe | head` into + // a "failed printing to stdout" panic. A CLI should just stop quietly + // when its reader goes away, as every Unix tool does. + #[cfg(unix)] + // SAFETY: restoring the default disposition of a signal, before any + // other thread exists. + unsafe { + libc::signal(libc::SIGPIPE, libc::SIG_DFL); + } return cli::dispatch(command); } @@ -191,9 +201,14 @@ fn main() -> Result<()> { }, ) .detach(); + // + // With the keymap installed (`keybindings.enabled`), Escape is an + // ordinary binding (`dismiss` / `close_overlay`) that does the same + // ordering itself, and that the user may remap or unbind — so this + // observer only covers the keymap-off case. cx.observe_keystrokes(|event, window, cx| { // Something with focus already claimed this keystroke. - if event.action.is_some() { + if event.action.is_some() || runtime::keybindings_active() { return; } let keystroke = &event.keystroke; @@ -635,6 +650,11 @@ fn toggle_window( AppState::default() }); + // Same reasoning for the keymap: re-read `keybindings.toml` on every open + // so an edit applies without a restart. + let keymap = aura_core::keymap::Keymap::load(&aura_core::keymap::Keymap::default_path()); + keys::install(cx, &keymap, config.keybindings.enabled); + let anchor = placement::Anchor::from_config(&config.window.anchor); // `display_id` rides along to `AuraView` so the auto-fit callback caps the // modal's height against the screen it actually opened on. Reading @@ -745,8 +765,23 @@ fn toggle_window( #[cfg(target_os = "windows")] let cloak = config.window.auto_resize(); - match cx.open_window(opts, |_window, cx| { - cx.new(|cx| AuraView::new(config, config_path, state, display_id, tray_anchor, cx)) + match cx.open_window(opts, |window, cx| { + cx.new(|cx| { + let view = AuraView::new( + config, + config_path, + state, + keymap, + display_id, + tray_anchor, + cx, + ); + // Key bindings dispatch from the focused element, and nothing + // else in the modal takes focus, so the root holds it for the + // window's lifetime (a click anywhere re-focuses it). + window.focus(&view.focus_handle); + view + }) }) { Ok(handle) => { // On macOS, if we are running as NSApplicationActivationPolicyAccessory diff --git a/crates/aura/src/runtime.rs b/crates/aura/src/runtime.rs index 54cb0bc..a14031e 100644 --- a/crates/aura/src/runtime.rs +++ b/crates/aura/src/runtime.rs @@ -105,7 +105,7 @@ pub fn dismiss_on_focus_loss() -> bool { DISMISS_ON_FOCUS_LOSS.load(Ordering::Relaxed) } -/// Ask the poll loop to close the modal (Escape was pressed). +/// Ask the poll loop to close the modal (Escape, or the `dismiss` binding). pub fn request_dismiss() { DISMISS_REQUESTED.store(true, Ordering::Relaxed); } @@ -115,6 +115,21 @@ pub fn take_dismiss_request() -> bool { DISMISS_REQUESTED.swap(false, Ordering::Relaxed) } +/// Whether the keymap is installed (`keybindings.enabled`). While it is, Escape +/// is an ordinary binding the user can remap or unbind; while it isn't, the +/// fallback Escape observer in `main.rs` keeps Escape closing the modal. +static KEYBINDINGS_ACTIVE: AtomicBool = AtomicBool::new(false); + +/// See [`KEYBINDINGS_ACTIVE`]. +pub fn keybindings_active() -> bool { + KEYBINDINGS_ACTIVE.load(Ordering::Relaxed) +} + +/// Set by `keys::install` whenever the keymap is (re)installed. +pub fn set_keybindings_active(active: bool) { + KEYBINDINGS_ACTIVE.store(active, Ordering::Relaxed); +} + /// See [`PLUGIN_ACTION_INFLIGHT`]. pub fn plugin_action_inflight() -> bool { PLUGIN_ACTION_INFLIGHT.load(Ordering::Relaxed) diff --git a/docs/cli.md b/docs/cli.md index 7354443..6c3ba90 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -2,8 +2,8 @@ title: CLI reference status: current version: 0.1.0 -last_updated: 2026-05-24 -last_verified: 2026-05-24 +last_updated: 2026-09-23 +last_verified: 2026-09-23 source_refs: ["crates/aura/src/cli/"] owner: "@rfluid" tags: [cli, docs] @@ -31,6 +31,7 @@ consume aura's data without scraping human output. | `aura config <…>` | Manage `~/.config/aura/config.toml`. | | `aura state <…>` | Inspect / modify `~/.local/share/aura/state.json`. | | `aura theme <…>` | Inspect / seed `~/.config/aura/theme.toml`. | +| `aura keys <…>` | List, check and seed `~/.config/aura/keybindings.toml`. Alias: `aura keybindings`. | | `aura agents list` | Configured profiles + detection status. | | `aura plugin <…>` | Manage user plugins. Alias: `aura plugins`. | | `aura usage` | Token-usage snapshot for an agent profile. | @@ -107,6 +108,28 @@ aura theme edit # opens in $EDITOR; seeds defaults if missin aura theme init [--force] # writes the bundled defaults to disk ``` +## `aura keys` + +```text +aura keys path +aura keys list [--context global|overlay] [--format text|json] +aura keys describe [|] [--format text|json] # alias: aura keys actions +aura keys get [--context global|overlay] [--format text|json] +aura keys set [--context …] # `none` as the action unbinds +aura keys unbind [--context …] +aura keys reset | --action | --all [--context …] +aura keys wizard [--context …] +aura keys merge [--prefer theirs|ours] [--check] [--format text|json] +aura keys export # complete keymap as TOML on stdout +aura keys init [--force] [--full] # starter file; --full = every default live +aura keys document [--force] # rewrite with inline docs +aura keys validate [--format text|json] # exit 1 on problems +aura keys edit +``` + +Write commands edit `keybindings.toml` in place and keep your comments. +See [keybindings.md](keybindings.md#cli) for the full semantics. + ## `aura agents` ```text diff --git a/docs/configuration.md b/docs/configuration.md index 4c5aa84..fd7eadc 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2,11 +2,12 @@ title: Configuration status: current version: 0.2.0 -last_updated: 2026-09-16 -last_verified: 2026-09-16 +last_updated: 2026-09-23 +last_verified: 2026-09-23 source_refs: - crates/aura-core/src/config.rs - crates/aura-core/src/config_schema.rs + - crates/aura-core/src/keymap.rs - crates/aura-core/src/state.rs - crates/aura/src/cli/config.rs - crates/aura/src/runtime.rs @@ -68,14 +69,15 @@ for fine tuning. The generated `config.toml` starts with a link back to this tutorial and then documents each field above the value it controls. Repeatable `[[agents]]` and `[[plugins]]` blocks are ordinary TOML arrays of tables; scalar settings live -under `[window]`, `[tray]`, `[content]`, and `[update]`. +under `[window]`, `[tray]`, `[content]`, `[update]`, and `[keybindings]`. ## File locations | File | Path | What it holds | Edited by | |---|---|---|---| -| Config | `~/.config/aura/config.toml` | Agents, plugins, `[window]`, `[tray]`, `[content]`, `[update]` | You (CLI / editor) | +| Config | `~/.config/aura/config.toml` | Agents, plugins, `[window]`, `[tray]`, `[content]`, `[update]`, `[keybindings]` | You (CLI / editor) | | Theme | `~/.config/aura/theme.toml` | Color / font / spinner overrides | You (CLI / editor) | +| Keybindings | `~/.config/aura/keybindings.toml` | Keyboard-shortcut overrides — see [keybindings.md](keybindings.md) | You (CLI / editor) | | State | `~/.local/share/aura/state.json` | Active profile selection | Aura (do not hand-edit) | | Plugins dir | `~/.config/aura/plugins/` | Auto-discovered plugin binaries | `aura plugin add` | @@ -88,7 +90,7 @@ writes a fully-commented default `config.toml` on first run if none exists. Config flows through five layers, top (authoring) to bottom (consumption): 1. **Typed structs** — `crates/aura-core/src/config.rs`. `AppConfig` is the - root (`agents`, `plugins`, `window`, `tray`, `content`, `update`); each sub-struct derives + root (`agents`, `plugins`, `window`, `tray`, `content`, `update`, `keybindings`); each sub-struct derives `Serialize`/`Deserialize` and a `Default`, so the whole tree round-trips through TOML and an empty/partial file still parses (missing fields fall back to `Default`). This is the **source of truth** — the shape of a config is @@ -139,6 +141,9 @@ the config (and `theme.toml`) reloaded, at three moments: thread; a malformed `theme.toml` logs a warning and falls back to defaults rather than blanking the UI. +`keybindings.toml` follows the same schedule: it is re-read, and the keymap +reinstalled, on every open and every refresh. + A failed reload falls back to the last good in-memory snapshot, so a transient I/O error never breaks the toggle. @@ -235,6 +240,15 @@ Controls the "Update available" header button. | `dismissed_version` | string? | — | unset | Last release dismissed via the button's ×; a newer release re-shows it. | | `dismiss_all` | bool | `true` \| `false` | `false` | Master mute: never render the button or fire the GitHub check. | +### `[keybindings]` + +Master switch for the modal's keyboard shortcuts. The bindings themselves live +in `keybindings.toml` — see [keybindings.md](keybindings.md). + +| Key | Type | Allowed | Default | Summary | +|---|---|---|---|---| +| `enabled` | bool | `true` \| `false` | `true` | Install the keymap (vim-style defaults + `keybindings.toml`). `false` leaves the modal mouse-only; Escape still closes it. | + ### `[[agents]]` (repeatable) | Field | Type | Allowed | Summary | diff --git a/docs/keybindings.md b/docs/keybindings.md new file mode 100644 index 0000000..70ce2e1 --- /dev/null +++ b/docs/keybindings.md @@ -0,0 +1,278 @@ +--- +title: Keybindings +status: current +version: 0.2.0 +last_updated: 2026-09-23 +last_verified: 2026-09-23 +source_refs: + - crates/aura-core/src/keymap/mod.rs + - crates/aura-core/src/keymap/file.rs + - crates/aura/src/keys.rs + - crates/aura/src/app.rs + - crates/aura/src/main.rs + - crates/aura/src/cli/keys.rs +owner: "@rfluid" +tags: [keybindings, configuration, docs] +--- + +# Keybindings + +The Aura modal can be driven entirely from the keyboard. The default keys are +vim-style: `j`/`k` scroll, `h`/`l` switch sections, `q` closes the window and +`?` shows every shortcut. Arrow keys, Page Up/Down, Home/End and Tab work too. + +Press `?` in the modal to see the live keymap, including anything you've +changed. + +## Defaults + +### Scroll + +| Keys | Action | What it does | +|---|---|---| +| `j` · `down` | `scroll_down` | Scroll down one line | +| `k` · `up` | `scroll_up` | Scroll up one line | +| `ctrl-d` | `half_page_down` | Scroll down half a page | +| `ctrl-u` | `half_page_up` | Scroll up half a page | +| `ctrl-f` · `pagedown` | `page_down` | Scroll down a page | +| `ctrl-b` · `pageup` | `page_up` | Scroll up a page | +| `g g` · `home` | `scroll_top` | Jump to the top | +| `G` · `end` | `scroll_bottom` | Jump to the bottom | + +While the help overlay is open, the scroll keys scroll the help list instead of +the body. + +### Navigate + +| Keys | Action | What it does | +|---|---|---| +| `l` · `tab` · `g t` | `next_section` | Next section tab (wraps around) | +| `h` · `shift-tab` · `g T` | `prev_section` | Previous section tab | +| `1` … `9` | `section_1` … `section_9` | Go to section N | +| `L` · `]` | `next_profile` | Next agent pill, or next plugin in plugin mode | +| `H` · `[` | `prev_profile` | Previous agent / plugin | +| `m` | `toggle_mode` | Switch between agents and plugins | +| `p` | `next_period` | Next period (all → 7d → 30d), when the section uses one | +| `P` | `prev_period` | Previous period | + +### Commands + +| Keys | Action | What it does | +|---|---|---| +| `r` · `f5` | `refresh` | Refresh (also reloads config, theme and keybindings) | +| `,` · `secondary-,` | `toggle_settings` | Open / close the settings panel | +| `.` | `toggle_more` | Open / close the more menu | +| `?` | `toggle_help` | Show / hide the shortcut list | +| `esc` (in an overlay) | `close_overlay` | Close the open menu, panel or help | +| `q` · `esc` | `dismiss` | Close the window | +| `e` | `open_config` | Edit `config.toml` | +| `t` | `open_theme` | Edit `theme.toml` | +| `u` | `open_update` | Open the update instructions (when an update is shown) | +| `U` | `dismiss_update` | Hide the update button (when shown) | +| — | `open_keybindings` | Edit `keybindings.toml` (unbound by default) | +| — | `quit` | Quit Aura, tray icon included (unbound by default) | + +Escape works in layers. First it clears any selected text. If nothing is +selected, it closes the open overlay. If no overlay is open, it closes the +window. `q` always closes the window, even when an overlay is open. + +## Customizing: `keybindings.toml` + +Your bindings go in `~/.config/aura/keybindings.toml`, next to `config.toml`. +Aura layers them over the defaults. You can create the file in three ways: + +- Run `aura keys init`. +- Run `aura keys edit`, which also opens it in `$EDITOR`. +- Choose **Settings → Keybindings → Edit keybindings.toml** in the modal. + +The starter file explains the format and lists every default as a comment. + +```toml +# Start from the built-in defaults (true) or from an empty keymap (false). +use_defaults = true + +[global] +"ctrl-j" = "scroll_down" # add a binding +"x" = "refresh" # bind another key to an action +"t" = "none" # remove a default +"g k" = "open_keybindings" + +# Checked before [global] while a menu, the settings panel or the help is open. +[overlay] +"q" = "close_overlay" # make q close overlays instead of the window +``` + +- **Tables are contexts.** `[global]` applies everywhere. `[overlay]` applies + while an overlay is open and takes precedence over `[global]` for the same + keys. Global keys that the overlay table doesn't mention keep working. +- **Each entry maps keys to one action.** To give an action more keys, add more + lines. Run `aura keys actions` to see every action name. +- **`"none"` unbinds.** In `[overlay]` it also blocks the `[global]` binding + for those keys while an overlay is open. +- **`use_defaults = false`** starts from an empty keymap, so only your file + applies. + +### Keystroke syntax + +- Write modifiers joined with `-`, then the key: `ctrl-d`, `alt-shift-x`, + `cmd-k`. The modifiers are `ctrl`, `alt`, `shift`, `cmd`, `fn` and + `secondary`. `secondary` means `cmd` on macOS and `ctrl` elsewhere, which + helps when you share one file across machines. +- A space separates the strokes of a sequence, as in `g g`. After the first + stroke, Aura waits about a second for the next one. +- A single uppercase letter means shift plus that letter, so `G` and `shift-g` + are the same binding. Punctuation uses the character it types, e.g. `?`, + `[`, `,`. +- Named keys are `escape` (or `esc`), `enter` (or `return`), `tab`, `space`, + `backspace`, `delete`, `insert`, `home`, `end`, `pageup`, `pagedown`, `up`, + `down`, `left`, `right`, and `f1`–`f24`. + +### Warnings + +A mistake in `keybindings.toml` never costs you your shortcuts. Aura skips the +bad entry, keeps everything else, and reports the problem in three places: + +- `aura keys validate`, which exits 1 when there are problems (and also takes + `--format json`). +- `aura doctor`. +- In the modal, a warning chip in the header shows the problem count. Click it + or press `?` to see the full list at the top of the help overlay. Each warning + is also printed once to stderr. + +The checks: + +| Problem | Example | What happens | +|---|---|---| +| File is not valid TOML | missing `]` | The whole file is ignored and the defaults apply | +| Unknown table | `[globl]` | The table is ignored; Aura suggests `global` | +| Unknown action | `"y" = "scrol_down"` | The entry is skipped; Aura suggests `scroll_down` | +| Invalid keystroke | `"hyper-x"`, `"ctrl-"`, `"f99"` | The entry is skipped | +| Value is not a string | `"z" = 3` | The entry is skipped | +| Same keystroke twice in one table | `"G"` and `"shift-g"` | The later one wins | +| Unbinding a key that has no binding | `"F9" = "none"` | No effect | +| One binding is the start of a longer one | `"g"` alongside `"g g"` | `g` still works, but only after a short wait | +| Binding a key text selection uses | `ctrl-c`, `ctrl-a`, `shift-up` | The binding works, but copy, select-all or selection extension on that key stops working | +| `use_defaults` is not a boolean | `use_defaults = "yes"` | The defaults are kept | + +## Turning shortcuts off + +Shortcuts are on by default. To turn them all off: + +```toml +# config.toml +[keybindings] +enabled = false +``` + +You can also run `aura config set keybindings.enabled false`. With shortcuts +off, the modal is mouse-only, except that Escape still clears a selection or +closes the window. + +## When changes apply + +Aura reads `keybindings.toml` and `[keybindings] enabled` again every time the +window opens, and on every refresh (`r`, or the refresh button). You never need +to restart. + +## CLI + +`aura keys` works like `aura config`. Its read commands take +`--format text|json`. Its write commands edit `keybindings.toml` in place +without touching your comments, layout, or entries they can't parse. Every +write reports any warning it introduces. + +| Command | What it does | +|---|---| +| `aura keys path` | Print the `keybindings.toml` path | +| `aura keys list [--context global\|overlay]` | Print the effective keymap, where each binding came from, and warnings | +| `aura keys describe` | List every action with its current keys. `*` marks actions you changed. Alias: `aura keys actions` | +| `aura keys describe ` | Explain one action: its default keys, its current keys, and how to bind or restore it | +| `aura keys describe ` · `aura keys get [--context …]` | Show what a keystroke does and where that binding comes from. In the overlay context, a key the overlay doesn't bind shows its `[global]` binding | +| `aura keys set [--context …]` | Bind the keystroke and save. Any other spelling of the same keystroke is replaced (`G` replaces `shift-g`) | +| `aura keys unbind [--context …]` | Remove the keystroke's binding, even if it's a default (writes `"none"`) | +| `aura keys reset ` · `--action ` · `--all [--context …]` | Remove your overrides so the defaults apply again: for one keystroke, for one action, or for the whole file | +| `aura keys wizard [--context …]` | Step through every action, one prompt each: Enter keeps its keys, `j, ctrl-n` replaces them, `none` unbinds it, `default` restores it, and `stop` ends the wizard | +| `aura keys merge [--prefer theirs\|ours] [--check]` | Merge another keymap into yours. See [Merging](#merging) | +| `aura keys export` | Print the effective keymap as a complete file: `use_defaults = false`, with every binding written out | +| `aura keys init [--force] [--full]` | Write a starter file with every default as a comment. `--full` writes every default as a live binding instead, with `use_defaults = false` | +| `aura keys document [--force]` | Rewrite the file in the generated layout: your entries in order, a description on each, and the defaults as a commented reference | +| `aura keys validate` | Report problems; exit 1 if there are any | +| `aura keys edit` | Open the file in `$EDITOR`, creating it first if missing, then report any warnings | + +`aura keybindings` is an alias for `aura keys`. + +### Examples + +```console +$ aura keys set ctrl-j scroll_down +set [global] "ctrl-j" = "scroll_down" + +$ aura keys set r toggle_help +set [global] "r" = "toggle_help" (was refresh) + +$ aura keys set x scrol_down +Error: unknown action `scrol_down` (did you mean `scroll_down`?) … + +$ aura keys get j --context overlay +[overlay] j → scroll_down (default, from [global]) + +$ aura keys reset --action toggle_help +Removed 1 entry from …/keybindings.toml; the defaults apply there again. +``` + +When the wizard changes an action's keys, it keeps the change as small as +possible: + +- A default key the action loses becomes `"none"`. +- Any other key it loses has its entry deleted, so that key's own default, if + it has one, applies again. The wizard notes when this happens. +- A key taken from another action is noted too. + +### Merging + +`merge` compares entries by keystroke, so `G` and `shift-g` count as the same +entry. + +| Case | What happens | +|---|---| +| New keystroke | Added | +| Same action in both files | Left alone | +| Different action | The incoming file wins (`--prefer theirs`, the default), or yours is kept and the conflict is reported (`--prefer ours`) | +| Broken entry in the incoming file | Listed and never copied | +| `use_defaults` differs | Follows the same `--prefer` rule | + +`--check` prints the plan and exits 1 when the merge would change anything. +`-` reads the incoming file from stdin. `--format json` prints the report. + +### `document` and your comments + +`document` rebuilds the whole file, so it can't keep your comments. It keeps +every valid entry. If any entry can't be carried over, it lists them and stops +unless you pass `--force`. Every other write command keeps the file as you +wrote it. + +## How it works + +- `aura-core/src/keymap/mod.rs` has no UI code. It holds the action catalogue, the + defaults table, the keystroke parser, the merge of defaults and user file, + and every warning check. The CLI and the modal share it. It reads the file + with `toml_edit`, which keeps file order, so warnings list entries top to + bottom and "the later one wins" means the entry lower in the file. +- `aura-core/src/keymap/file.rs` (`KeymapFile`) is the editing side. It makes + format-preserving edits through `toml_edit`. The CLI commands and their + tests all build on its operations: `bind`, `remove`, `clear`, + `set_action_keys`, `restore_action`, `merge` and `document`. +- `aura/src/keys.rs` defines one GPUI action per `KeyAction`. The mapping is an + exhaustive `match`, so an unwired action won't compile. It also installs the + resolved bindings: + - `[global]` uses key context `Aura`, and `[overlay]` uses `overlay`. + - The modal's root element carries both contexts. Because the two sit on the + same element, GPUI can't break ties by depth and uses insertion order + instead, so overlay bindings are installed last. + - An unbind becomes GPUI's `NoAction`, which also masks a lower context. +- The root element keeps a `FocusHandle` focused for the window's lifetime, + because GPUI dispatches key bindings from the focused element. No other + element in the modal is focusable, and a click anywhere puts focus back on + the root. +- When shortcuts are on, Escape is an ordinary binding. The fallback Escape + observer in `main.rs` only runs when they're off.