Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .agent/memory/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/

Expand Down
33 changes: 33 additions & 0 deletions .agent/memory/facts/2026-09-23-gpui-keymap-dispatch.md
Original file line number Diff line number Diff line change
@@ -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`.
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.

Expand Down
4 changes: 4 additions & 0 deletions assets/icons/keyboard.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions crates/aura-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions crates/aura-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -457,6 +478,8 @@ pub struct AppConfig {
pub content: ContentConfig,
#[serde(default)]
pub update: UpdateConfig,
#[serde(default)]
pub keybindings: KeybindingsConfig,
}

impl AppConfig {
Expand Down Expand Up @@ -526,6 +549,7 @@ impl AppConfig {
tray: TrayConfig::default(),
content: ContentConfig::default(),
update: UpdateConfig::default(),
keybindings: KeybindingsConfig::default(),
}
}

Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand All @@ -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();
Expand Down Expand Up @@ -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![
Expand Down Expand Up @@ -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 {
Expand Down
27 changes: 24 additions & 3 deletions crates/aura-core/src/config_schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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] {
Expand Down Expand Up @@ -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",
},
]
}

Expand Down Expand Up @@ -497,6 +512,7 @@ pub fn get_value(cfg: &AppConfig, key: &str) -> Result<String, SchemaError> {
.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)
Expand Down Expand Up @@ -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(())
Expand Down Expand Up @@ -704,6 +721,7 @@ fn toml_rhs(cfg: &AppConfig, key: &str) -> Option<String> {
"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,
})
}
Expand Down Expand Up @@ -764,7 +782,8 @@ fn wrap_text(text: &str, width: usize) -> Vec<String> {
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
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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);
}
Expand Down
Loading
Loading