From ae00f0cd373a1e47796b5b19dd33cea65daae686 Mon Sep 17 00:00:00 2001 From: Rfluid Date: Fri, 25 Sep 2026 01:37:56 -0300 Subject: [PATCH 1/2] fix(plugin): link `plugin add --link` to the absolute source A symlink target resolves against the link's own directory, so a relative source such as target/release/foo dangled inside the plugins dir and discovery silently skipped the plugin. --- crates/aura-core/src/plugin/discovery.rs | 35 +++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/crates/aura-core/src/plugin/discovery.rs b/crates/aura-core/src/plugin/discovery.rs index 418c455..aa6debb 100644 --- a/crates/aura-core/src/plugin/discovery.rs +++ b/crates/aura-core/src/plugin/discovery.rs @@ -195,7 +195,12 @@ pub fn add_plugin(plugins_dir: &Path, opts: AddOptions) -> Result { } if opts.symlink { - symlink_file(&opts.source, &dest)?; + // A symlink target resolves against the link's own directory, so a + // relative source (`target/release/foo`, as typed from a checkout) + // would dangle inside the plugins dir. Link to the absolute path. + let target = fs::canonicalize(&opts.source) + .with_context(|| format!("resolve {}", opts.source.display()))?; + symlink_file(&target, &dest)?; } else { fs::copy(&opts.source, &dest) .with_context(|| format!("copy {} -> {}", opts.source.display(), dest.display()))?; @@ -556,6 +561,34 @@ icon = "icons/demo.svg" assert!(outcome.sidecar.is_none()); } + #[cfg(unix)] + #[test] + fn add_plugin_symlinks_to_the_absolute_source() { + let src_dir = tempdir().unwrap(); + let plugins_dir = tempdir().unwrap(); + let src = write_exec(src_dir.path(), "aura-plugin-linked", "echo {}\n"); + fs::create_dir(src_dir.path().join("sub")).unwrap(); + // A non-canonical spelling of the source must not leak into the link. + let indirect = src_dir.path().join("sub/../aura-plugin-linked"); + + let outcome = add_plugin( + plugins_dir.path(), + AddOptions { + source: indirect, + dest_name: None, + symlink: true, + name: None, + color: None, + icon: None, + }, + ) + .unwrap(); + let target = fs::read_link(&outcome.installed).unwrap(); + assert!(target.is_absolute()); + assert_eq!(target, fs::canonicalize(&src).unwrap()); + assert!(outcome.installed.is_file()); + } + #[cfg(unix)] #[test] fn remove_plugin_deletes_binary_and_sidecar() { From be24c6097441650b16fc0df092cb8bbfe4aab571 Mon Sep 17 00:00:00 2001 From: Rfluid Date: Fri, 25 Sep 2026 01:37:56 -0300 Subject: [PATCH 2/2] feat(keys): hint mode and plugin keyboard shortcuts Hint mode (`f`) labels every button in a plugin's controls section; typing a label presses it, confirm buttons need two presses. Plugins can declare `keys` per section. Each sits behind a leader (`[keybindings] plugin_leader`, default `space`) so it can never shadow an Aura shortcut. Pressing the leader opens leader mode: a floating panel lists the section's keys and narrows as strokes are typed. Aura reads the strokes itself instead of binding whole GPUI sequences, so it waits until the shortcut completes or Escape, or for `[keybindings] leader_timeout_ms` when set. Clicks, hint labels and plugin keys share one press path. The help overlay lists the plugin's keys; `aura plugin run` prints them and any problems; `aura keys validate` and `aura doctor` warn when the leader collides with a global binding. `keys validate` now prints its warnings when keybindings.toml doesn't exist. --- .design/components.md | 35 ++ crates/aura-cli/src/doctor.rs | 15 +- crates/aura-cli/src/keys.rs | 18 +- crates/aura-cli/src/plugin.rs | 27 ++ crates/aura-core/src/config.rs | 20 +- crates/aura-core/src/config_schema.rs | 57 ++- crates/aura-core/src/keymap/mod.rs | 8 + crates/aura-core/src/plugin/keys.rs | 338 ++++++++++++++ crates/aura-core/src/plugin/mod.rs | 53 +++ crates/aura-ui/src/app.rs | 620 +++++++++++++++++++++++++- crates/aura-ui/src/hints.rs | 124 ++++++ crates/aura-ui/src/keys.rs | 74 ++- crates/aura-ui/src/lib.rs | 3 +- docs/configuration.md | 2 + docs/keybindings.md | 90 +++- docs/plans/plugin-keymaps.md | 272 +++++++++++ docs/plugin-authoring.md | 49 +- 17 files changed, 1755 insertions(+), 50 deletions(-) create mode 100644 crates/aura-core/src/plugin/keys.rs create mode 100644 crates/aura-ui/src/hints.rs create mode 100644 docs/plans/plugin-keymaps.md diff --git a/.design/components.md b/.design/components.md index 447a7d4..5773fa6 100644 --- a/.design/components.md +++ b/.design/components.md @@ -221,6 +221,41 @@ Optional bottom section. Hidden entirely when no plugins are configured. | Loading | **Proposed**: title with trailing spinner (see `loading.md`), no rows. | | Error | Title + single `text_xs` row in `#ff6b6b` containing `panel.error`. (`app.rs:815-821`) | +### Hint labels + +**Renderer**: `AuraView::render_plugin_controls` (hint mode, `f`). + +While hint mode is on, each button in a `controls` section gets a label +badge before its content, and a status line sits above the rows. + +- Badge: `px_1`, `rounded_sm`, bg `COLOR_ACCENT`, text `COLOR_BG`, bold. + Characters already typed render at 50% opacity before the rest. +- Buttons the typed characters rule out: whole pill at 35% opacity. +- Status line: `text_xs` / `COLOR_TEXT_DIM`. + +### Plugin key panel + +**Renderer**: `AuraView::render_leader_panel`. + +Floating card for plugin shortcuts, drawn over the content at the bottom +right while leader mode is on (the plugin leader, default `space`, was +pressed). + +- Position: `absolute`, `right 12px`, `bottom 12px`, width `210px`. +- Card: bg `COLOR_SURFACE`, border `COLOR_BORDER`, `rounded_md`, `p_2`, + `gap_1`, `shadow_lg`, `text_xs`. +- Header: the leader and the keys typed so far as chips, then `…`; + border-bottom `COLOR_BORDER`. +- Rows: a chip with the strokes still to press, then the label in + `COLOR_TEXT_DIM`. +- Chip: `px_1`, `rounded_sm`, bg `COLOR_SURFACE_HI`, text `COLOR_TEXT`. +- Footer: "esc to cancel", `COLOR_TEXT_DIM` at 70% opacity. + +| State | Rendering | +| ------- | ------------------------------------------------------------------------- | +| Leader | Header, one row per key still reachable, footer. | +| Confirm | A key armed an action whose button isn't on screen: border and prompt in `COLOR_ERROR`, then "press … again · esc to cancel" in `COLOR_TEXT_DIM`. | + ## Loading body **Renderer**: `render_loading` — `app.rs:426-435`. diff --git a/crates/aura-cli/src/doctor.rs b/crates/aura-cli/src/doctor.rs index 11eca6e..d28d4b0 100644 --- a/crates/aura-cli/src/doctor.rs +++ b/crates/aura-cli/src/doctor.rs @@ -105,10 +105,12 @@ fn collect() -> DoctorReport { let keymap_path = Keymap::default_path(); let plugins_dir = plugin::user_plugins_dir(); let mut keybindings_enabled = true; + let mut plugin_leader = aura_core::plugin::keys::DEFAULT_LEADER.to_string(); let (config_status, agents, plugins) = match AppConfig::load_with_discovery(&config_path) { Ok(cfg) => { keybindings_enabled = cfg.keybindings.enabled; + plugin_leader = cfg.keybindings.plugin_leader.clone(); let agents: Vec = cfg .agents .iter() @@ -196,14 +198,17 @@ fn collect() -> DoctorReport { } }; + let keymap = Keymap::load(&keymap_path); + let mut keymap_warnings: Vec = + keymap.warnings.iter().map(|w| w.message.clone()).collect(); + keymap_warnings.extend(aura_core::plugin::keys::leader_warnings( + &plugin_leader, + &keymap, + )); let keybindings = KeybindingsStatus { enabled: keybindings_enabled, exists: keymap_path.exists(), - warnings: Keymap::load(&keymap_path) - .warnings - .into_iter() - .map(|w| w.message) - .collect(), + warnings: keymap_warnings, }; DoctorReport { diff --git a/crates/aura-cli/src/keys.rs b/crates/aura-cli/src/keys.rs index 04cb614..fb35679 100644 --- a/crates/aura-cli/src/keys.rs +++ b/crates/aura-cli/src/keys.rs @@ -470,7 +470,17 @@ fn run_get(path: &Path, keys: &str, context: BindingContext, format: OutputForma } fn run_validate(path: &Path, format: OutputFormat) -> Result<()> { - let keymap = Keymap::load(path); + let mut keymap = Keymap::load(path); + // `plugin_leader` lives in config.toml but only matters against the + // keymap, so it's checked here too. + let leader = aura_core::config::AppConfig::load(&aura_core::config::AppConfig::default_path()) + .map(|c| c.keybindings.plugin_leader) + .unwrap_or_else(|_| aura_core::plugin::keys::DEFAULT_LEADER.to_string()); + keymap.warnings.extend( + aura_core::plugin::keys::leader_warnings(&leader, &keymap) + .into_iter() + .map(|message| aura_core::keymap::KeymapWarning { message }), + ); match format { OutputFormat::Json => print_json(&keymap.warnings)?, OutputFormat::Text => { @@ -480,9 +490,9 @@ fn run_validate(path: &Path, format: OutputFormat) -> Result<()> { println!("{}: OK", path.display()); } else { println!("{}:", path.display()); - for w in &keymap.warnings { - println!(" warning: {w}"); - } + } + for w in &keymap.warnings { + println!(" warning: {w}"); } } } diff --git a/crates/aura-cli/src/plugin.rs b/crates/aura-cli/src/plugin.rs index 6413294..01448b3 100644 --- a/crates/aura-cli/src/plugin.rs +++ b/crates/aura-cli/src/plugin.rs @@ -202,5 +202,32 @@ fn run_plugin(name: &str, period: aura_core::reader::Period, action: Option<&str Some(id) => PluginRunner::run_action(plugin_cfg, id, period), None => PluginRunner::run_with_period(plugin_cfg, period), }; + report_keys(&panel, &config.keybindings.plugin_leader); print_json(&panel) } + +/// Each section's keys as the modal would install them, and any problems +/// with them, on stderr so stdout stays the panel JSON. +fn report_keys(panel: &aura_core::plugin::PluginPanel, raw_leader: &str) { + use aura_core::plugin::keys; + let (leader, error) = keys::effective_leader(raw_leader); + if let Some(e) = error { + eprintln!("warning: {e}"); + } + let Some(leader) = leader else { + if panel.sections.iter().any(|s| !s.keys.is_empty()) { + eprintln!("plugin keys are off (keybindings.plugin_leader = \"none\")"); + } + return; + }; + for section in panel.sections.iter().filter(|s| !s.keys.is_empty()) { + let (bindings, warnings) = keys::resolve(section, &leader); + eprintln!("keys in section `{}`:", section.id); + for b in &bindings { + eprintln!(" {:<14} {} ({})", b.display, b.label, b.action); + } + for w in &warnings { + eprintln!(" warning: {w}"); + } + } +} diff --git a/crates/aura-core/src/config.rs b/crates/aura-core/src/config.rs index cd6f25d..d18f7a0 100644 --- a/crates/aura-core/src/config.rs +++ b/crates/aura-core/src/config.rs @@ -454,11 +454,29 @@ pub struct KeybindingsConfig { /// mouse-only, apart from Escape closing it, which works either way. #[serde(default = "default_true")] pub enabled: bool, + /// Keystroke(s) in front of every plugin-declared key (see + /// [`crate::plugin::keys`]): with the default `"space"`, a plugin's `s` + /// key is pressed as `space s`. `"none"` turns plugin keys off. + #[serde(default = "default_plugin_leader")] + pub plugin_leader: String, + /// How long, in milliseconds, the host waits for the next plugin key + /// after the leader (or after a key that starts a longer one) before + /// giving up. `None` (the default) waits until Escape. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub leader_timeout_ms: Option, +} + +fn default_plugin_leader() -> String { + crate::plugin::keys::DEFAULT_LEADER.to_string() } impl Default for KeybindingsConfig { fn default() -> Self { - Self { enabled: true } + Self { + enabled: true, + plugin_leader: default_plugin_leader(), + leader_timeout_ms: None, + } } } diff --git a/crates/aura-core/src/config_schema.rs b/crates/aura-core/src/config_schema.rs index bdbd743..2650851 100644 --- a/crates/aura-core/src/config_schema.rs +++ b/crates/aura-core/src/config_schema.rs @@ -310,6 +310,31 @@ pub fn fields() -> &'static [FieldDescriptor] { keybindings.toml.", example: "true", }, + FieldDescriptor { + key: "keybindings.plugin_leader", + type_label: "string", + allowed: &[], + default: "\"space\"", + summary: "Key pressed before a plugin's own shortcuts.", + description: "Plugins can declare shortcuts for their buttons and actions. Every one \ + sits behind this leader, so with the default \"space\" a plugin's `s` key is \ + pressed as `space s`, and no plugin key can take over one of Aura's own \ + shortcuts. Use keybindings.toml syntax (`space`, `ctrl-p`, `g p`). Set \"none\" \ + to turn plugin shortcuts off. An invalid value falls back to \"space\".", + example: "space", + }, + FieldDescriptor { + key: "keybindings.leader_timeout_ms", + type_label: "u32?", + allowed: &[], + default: "unset (wait until Escape)", + summary: "How long to wait for a plugin key after the leader.", + description: "After the plugin leader, the key panel waits for the rest of a \ + plugin shortcut. Unset (the default), it waits until you finish the shortcut \ + or press Escape. Set a number of milliseconds to give up after that long \ + without a key instead; each key restarts the wait. `none` unsets it.", + example: "3000", + }, // ── [sponsor] ── FieldDescriptor { key: "sponsor.nudge", @@ -533,6 +558,12 @@ pub fn get_value(cfg: &AppConfig, key: &str) -> Result { .unwrap_or_else(|| "(unset)".to_string()), "update.dismiss_all" => cfg.update.dismiss_all.to_string(), "keybindings.enabled" => cfg.keybindings.enabled.to_string(), + "keybindings.plugin_leader" => cfg.keybindings.plugin_leader.clone(), + "keybindings.leader_timeout_ms" => cfg + .keybindings + .leader_timeout_ms + .map(|n| n.to_string()) + .unwrap_or_else(|| "(unset)".to_string()), "sponsor.nudge" => cfg.sponsor.nudge.to_string(), _ => return Err(unknown_key(key)), }; @@ -568,6 +599,10 @@ pub fn set_value(cfg: &mut AppConfig, key: &str, raw: &str) -> Result<(), Schema "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)?, + "keybindings.plugin_leader" => cfg.keybindings.plugin_leader = parse_leader(key, raw)?, + "keybindings.leader_timeout_ms" => { + cfg.keybindings.leader_timeout_ms = parse_opt_u32(key, raw)? + } "sponsor.nudge" => cfg.sponsor.nudge = parse_bool(key, raw)?, _ => return Err(unknown_key(key)), } @@ -589,6 +624,18 @@ fn parse_enum( } } +fn parse_leader(key: &str, raw: &str) -> Result { + match crate::plugin::keys::parse_leader(raw) { + Ok(Some(keys)) => Ok(keys.display), + Ok(None) => Ok("none".to_string()), + Err(_) => Err(SchemaError::InvalidType { + key: key.to_string(), + expected: "a keystroke such as `space` or `ctrl-p`, or `none`", + value: raw.to_string(), + }), + } +} + fn parse_bool(key: &str, raw: &str) -> Result { match raw.to_ascii_lowercase().as_str() { "true" | "yes" | "on" | "1" => Ok(true), @@ -744,6 +791,10 @@ fn toml_rhs(cfg: &AppConfig, key: &str) -> Option { "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(), + "keybindings.plugin_leader" => quote(&cfg.keybindings.plugin_leader), + "keybindings.leader_timeout_ms" => { + return cfg.keybindings.leader_timeout_ms.map(|n| n.to_string()) + } "sponsor.nudge" => cfg.sponsor.nudge.to_string(), _ => return None, }) @@ -819,6 +870,7 @@ mod tests { cfg.window.auto_resize = Some(false); cfg.window.max_height = Some(500); cfg.update.dismissed_version = Some("0.0.0".to_string()); + cfg.keybindings.leader_timeout_ms = Some(3000); cfg } @@ -1003,7 +1055,10 @@ mod tests { dismissed_version: Some("0.1.18".to_string()), dismiss_all: true, }, - keybindings: KeybindingsConfig { enabled: false }, + keybindings: KeybindingsConfig { + enabled: false, + ..Default::default() + }, sponsor: SponsorConfig { nudge: false }, }; assert_round_trips(&cfg); diff --git a/crates/aura-core/src/keymap/mod.rs b/crates/aura-core/src/keymap/mod.rs index ba08d61..c468b15 100644 --- a/crates/aura-core/src/keymap/mod.rs +++ b/crates/aura-core/src/keymap/mod.rs @@ -88,6 +88,7 @@ pub enum KeyAction { OpenKeybindings, OpenUpdate, DismissUpdate, + HintMode, } /// How the help overlay and `aura keys list` group actions. @@ -249,6 +250,12 @@ const ACTIONS: &[ActionInfo] = &[ Commands, "Hide the update button", ), + info( + A::HintMode, + "hint_mode", + Commands, + "Label plugin buttons to press them by key", + ), ]; impl KeyAction { @@ -666,6 +673,7 @@ const DEFAULTS: &[(BindingContext, &str, KeyAction)] = { (G, "t", A::OpenTheme), (G, "u", A::OpenUpdate), (G, "U", A::DismissUpdate), + (G, "f", A::HintMode), (O, "escape", A::CloseOverlay), ] }; diff --git a/crates/aura-core/src/plugin/keys.rs b/crates/aura-core/src/plugin/keys.rs new file mode 100644 index 0000000..bb124d6 --- /dev/null +++ b/crates/aura-core/src/plugin/keys.rs @@ -0,0 +1,338 @@ +//! Plugin-declared keyboard shortcuts, resolved against the leader. +//! +//! A section lists [`PluginKey`]s (`{"keys": "s", "action": "mute:toggle"}`). +//! The host puts the leader (`[keybindings] plugin_leader`, default `space`) +//! in front of each, so the plugin's `s` is pressed as `space s`. Nothing a +//! plugin declares can shadow one of Aura's own bindings: Aura binds no key +//! sequence that starts with the leader. +//! +//! Like the keymap, resolving never fails: a bad entry is skipped and +//! reported as a [`PluginKeyWarning`], everything else applies. Remapping is +//! the plugin's business — Aura installs whatever the panel declares. + +use std::fmt; + +use serde::Serialize; + +use super::{PluginContent, PluginKey, PluginSection}; +use crate::keymap::{parse_keys, BindingContext, Keymap, ParsedKeys}; + +/// The leader when `plugin_leader` is unset or invalid. +pub const DEFAULT_LEADER: &str = "space"; + +/// Parse a `plugin_leader` value. `"none"` (or empty) turns plugin keys off, +/// `Ok(None)`. +pub fn parse_leader(raw: &str) -> Result, String> { + let raw = raw.trim(); + if raw.is_empty() || raw.eq_ignore_ascii_case("none") { + return Ok(None); + } + parse_keys(raw).map(Some) +} + +/// The leader to use for `raw`, falling back to [`DEFAULT_LEADER`] when it +/// doesn't parse (the error is returned alongside so it can be reported). +pub fn effective_leader(raw: &str) -> (Option, Option) { + match parse_leader(raw) { + Ok(leader) => (leader, None), + Err(e) => ( + parse_keys(DEFAULT_LEADER).ok(), + Some(format!( + "keybindings.plugin_leader: {e}; using `{DEFAULT_LEADER}`" + )), + ), + } +} + +/// One plugin key, ready to install. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PluginBinding { + /// Canonical sequence, leader included (`"space s"`), in the form the + /// toolkit's keystroke parser accepts. + pub keys: String, + /// The same, written for humans (`"space S"`). + pub display: String, + /// Only the plugin's part, canonical (`"shift-s"`): what the host + /// matches the strokes typed after the leader against. + pub own_keys: String, + /// Only the plugin's part, for humans (`"S"`). + pub key_display: String, + pub action: String, + pub label: String, + pub confirm: Option, +} + +/// A problem with one declared key. The key is skipped (or, for the prefix +/// case, only delayed). +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PluginKeyWarning { + pub message: String, +} + +impl fmt::Display for PluginKeyWarning { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } +} + +fn warn(section: &PluginSection, message: String) -> PluginKeyWarning { + PluginKeyWarning { + message: format!("section `{}`: {message}", section.id), + } +} + +/// Resolve `section`'s keys behind `leader`, in declaration order. +/// +/// - An invalid keystroke or an empty `action` skips the entry. +/// - The same keys twice: the later entry wins (spellings compare +/// canonically, so `S` and `shift-s` are the same keys). +/// - One key starting another (`d` and `d d`): both stay, but the host +/// fires the shorter one as soon as it's typed, so the longer one can +/// never be pressed. +pub fn resolve( + section: &PluginSection, + leader: &ParsedKeys, +) -> (Vec, Vec) { + let mut bindings: Vec = Vec::new(); + let mut warnings = Vec::new(); + + for key in §ion.keys { + if key.action.trim().is_empty() { + warnings.push(warn( + section, + format!("key `{}` has an empty action; skipped", key.keys), + )); + continue; + } + let own = match parse_keys(&key.keys) { + Ok(own) => own, + Err(e) => { + warnings.push(warn(section, format!("{e}; skipped"))); + continue; + } + }; + if let Some(i) = bindings.iter().position(|b| b.keys == join(leader, &own)) { + warnings.push(warn( + section, + format!( + "`{}` is declared twice; `{}` wins over `{}`", + own.display, key.action, bindings[i].action + ), + )); + bindings.remove(i); + } + bindings.push(binding(key, leader, &own)); + } + + for a in &bindings { + for b in &bindings { + if b.keys.starts_with(&format!("{} ", a.keys)) { + warnings.push(warn( + section, + format!( + "`{}` starts `{}`, so `{}` can never be pressed", + a.key_display, b.key_display, b.key_display + ), + )); + } + } + } + + (bindings, warnings) +} + +fn join(leader: &ParsedKeys, own: &ParsedKeys) -> String { + format!("{} {}", leader.canonical, own.canonical) +} + +fn binding(key: &PluginKey, leader: &ParsedKeys, own: &ParsedKeys) -> PluginBinding { + PluginBinding { + keys: join(leader, own), + display: format!("{} {}", leader.display, own.display), + own_keys: own.canonical.clone(), + key_display: own.display.clone(), + action: key.action.clone(), + label: key.label.clone(), + confirm: key.confirm.clone(), + } +} + +/// Whether some button in `section` has `action` as its id and asks for +/// confirmation, i.e. whether a key for it needs two presses. +pub fn button_confirms(section: &PluginSection, action: &str) -> bool { + match §ion.content { + PluginContent::Controls { controls } => controls + .iter() + .flat_map(|c| &c.buttons) + .any(|b| b.id == action && b.confirm.is_some()), + _ => false, + } +} + +/// Keymap bindings the leader collides with: a `[global]` binding on the +/// leader itself, one the leader starts, or one that starts the leader. The +/// toolkit still resolves these (it waits for the next stroke), but one of +/// the two only fires after a timeout, so it is worth a warning. +pub fn leader_conflicts(keymap: &Keymap, leader: &ParsedKeys) -> Vec { + let l = &leader.canonical; + keymap + .bindings + .iter() + .filter(|b| b.context == BindingContext::Global && b.action.is_some()) + .filter(|b| { + b.keys == *l + || b.keys.starts_with(&format!("{l} ")) + || l.starts_with(&format!("{} ", b.keys)) + }) + .map(|b| { + format!( + "keybindings.plugin_leader `{}` collides with `{}` ({}) in [global]; \ + one of them fires only after a short wait", + leader.display, + b.display, + b.action.map(|a| a.name()).unwrap_or("none"), + ) + }) + .collect() +} + +/// Everything wrong with the `plugin_leader` value `raw` against `keymap`: +/// an invalid value (which falls back to [`DEFAULT_LEADER`]) and +/// [`leader_conflicts`]. For `aura keys validate`, `aura doctor` and the +/// help overlay. +pub fn leader_warnings(raw: &str, keymap: &Keymap) -> Vec { + let (leader, error) = effective_leader(raw); + let mut warnings: Vec = error.into_iter().collect(); + if let Some(leader) = leader { + warnings.extend(leader_conflicts(keymap, &leader)); + } + warnings +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key(keys: &str, action: &str) -> PluginKey { + PluginKey { + keys: keys.to_string(), + action: action.to_string(), + label: action.to_string(), + confirm: None, + } + } + + fn section(keys: Vec) -> PluginSection { + PluginSection { + id: "s".to_string(), + label: "S".to_string(), + uses_period: false, + keys, + content: PluginContent::default(), + } + } + + fn space() -> ParsedKeys { + parse_leader("space").unwrap().unwrap() + } + + #[test] + fn leader_parses_and_none_disables() { + assert_eq!(parse_leader("space").unwrap().unwrap().canonical, "space"); + assert_eq!(parse_leader("ctrl-p").unwrap().unwrap().canonical, "ctrl-p"); + assert!(parse_leader("none").unwrap().is_none()); + assert!(parse_leader("").unwrap().is_none()); + assert!(parse_leader("hyper-x").is_err()); + } + + #[test] + fn invalid_leader_falls_back_to_space() { + let (leader, err) = effective_leader("hyper-x"); + assert_eq!(leader.unwrap().canonical, "space"); + assert!(err.unwrap().contains("plugin_leader")); + let (leader, err) = effective_leader("none"); + assert!(leader.is_none() && err.is_none()); + } + + #[test] + fn keys_get_the_leader_in_front() { + let (b, w) = resolve(§ion(vec![key("s", "mute"), key("D", "rm")]), &space()); + assert!(w.is_empty(), "{w:?}"); + assert_eq!(b[0].keys, "space s"); + assert_eq!(b[0].display, "space s"); + assert_eq!(b[0].key_display, "s"); + assert_eq!(b[1].keys, "space shift-d"); + assert_eq!(b[1].own_keys, "shift-d"); + assert_eq!(b[1].key_display, "D"); + } + + #[test] + fn bad_entries_are_skipped_with_a_warning() { + let (b, w) = resolve( + §ion(vec![key("hyper-x", "a"), key("s", " "), key("m", "ok")]), + &space(), + ); + assert_eq!(b.len(), 1); + assert_eq!(b[0].action, "ok"); + assert_eq!(w.len(), 2); + assert!(w.iter().all(|w| w.message.starts_with("section `s`"))); + } + + #[test] + fn later_duplicate_wins() { + let (b, w) = resolve( + §ion(vec![key("S", "first"), key("shift-s", "second")]), + &space(), + ); + assert_eq!(b.len(), 1); + assert_eq!(b[0].action, "second"); + assert_eq!(w.len(), 1); + } + + #[test] + fn prefix_is_warned_but_kept() { + let (b, w) = resolve(§ion(vec![key("d", "a"), key("d d", "b")]), &space()); + assert_eq!(b.len(), 2); + assert_eq!(w.len(), 1); + assert!(w[0].message.contains("`d d` can never be pressed")); + } + + #[test] + fn button_confirm_is_found_by_id() { + let mut s = section(Vec::new()); + s.content = serde_json::from_str( + r#"{"type": "controls", "controls": [{"label": "x", "buttons": [ + {"id": "rm", "label": "Remove", "confirm": "Sure?"}, + {"id": "ok", "label": "Ok"} + ]}]}"#, + ) + .unwrap(); + assert!(button_confirms(&s, "rm")); + assert!(!button_confirms(&s, "ok")); + assert!(!button_confirms(&s, "missing")); + } + + #[test] + fn leader_warnings_cover_invalid_and_conflicts() { + let keymap = Keymap::defaults(); + assert!(leader_warnings("space", &keymap).is_empty()); + assert!(leader_warnings("none", &keymap).is_empty()); + // Invalid: warned, then checked as `space`, which is free. + assert_eq!(leader_warnings("hyper-x", &keymap).len(), 1); + assert_eq!(leader_warnings("g", &keymap).len(), 3); + } + + #[test] + fn default_keymap_leaves_space_free() { + assert!(leader_conflicts(&Keymap::defaults(), &space()).is_empty()); + } + + #[test] + fn leader_conflicts_with_global_bindings() { + let keymap = Keymap::from_toml("[global]\n\"space\" = \"refresh\"\n"); + assert_eq!(leader_conflicts(&keymap, &space()).len(), 1); + // `g` is the start of `g g` / `g t` / `g T` in the defaults. + let g = parse_leader("g").unwrap().unwrap(); + assert_eq!(leader_conflicts(&Keymap::defaults(), &g).len(), 3); + } +} diff --git a/crates/aura-core/src/plugin/mod.rs b/crates/aura-core/src/plugin/mod.rs index 5b99b14..6df2aef 100644 --- a/crates/aura-core/src/plugin/mod.rs +++ b/crates/aura-core/src/plugin/mod.rs @@ -1,4 +1,5 @@ pub mod discovery; +pub mod keys; pub mod runner; pub use discovery::{ @@ -83,6 +84,26 @@ pub struct PluginControl { pub buttons: Vec, } +/// A keyboard shortcut a section declares. The host prefixes it with the +/// leader (`[keybindings] plugin_leader`, default `space`), so `"keys": "s"` +/// is pressed as `space s`. Pressing it does what clicking a button with +/// this `action` id does: the host re-invokes the plugin as +/// ` action --period

`. See [`keys::resolve`]. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct PluginKey { + /// Keystrokes after the leader, in `keybindings.toml` syntax (`s`, + /// `ctrl-x`, `d d`). + pub keys: String, + /// Action id sent back to the plugin, like a button `id`. + pub action: String, + /// What the key does, for the help overlay and the leader strip. + pub label: String, + /// Two-press confirmation, for actions with no button carrying one. A + /// visible button with the same id and its own `confirm` also counts. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub confirm: Option, +} + /// What kind of content a section holds. Tagged on the wire as /// `{"type": "lines", ...}` / `{"type": "table", ...}`. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -121,6 +142,9 @@ pub struct PluginSection { /// Defaults to `true` for backwards compatibility. #[serde(default = "default_true")] pub uses_period: bool, + /// Shortcuts active while this section is on screen. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub keys: Vec, #[serde(flatten)] pub content: PluginContent, } @@ -196,6 +220,7 @@ impl From for PluginPanel { id: "default".to_string(), label: "Overview".to_string(), uses_period: true, + keys: Vec::new(), content: PluginContent::Lines { lines: legacy.lines, }, @@ -214,6 +239,7 @@ mod tests { id: "s".to_string(), label: "S".to_string(), uses_period, + keys: Vec::new(), content: PluginContent::default(), } } @@ -255,6 +281,33 @@ mod tests { assert_eq!(section, again); } + #[test] + fn section_keys_roundtrip_and_default_empty() { + let json = r#"{ + "id": "s", "label": "S", "type": "text", "text": "", + "keys": [ + {"keys": "s", "action": "mute:toggle", "label": "Toggle sound"}, + {"keys": "d d", "action": "rm", "label": "Delete", "confirm": "Sure?"} + ] + }"#; + let section: PluginSection = serde_json::from_str(json).unwrap(); + assert_eq!(section.keys.len(), 2); + assert_eq!(section.keys[0].action, "mute:toggle"); + assert!(section.keys[0].confirm.is_none()); + assert_eq!(section.keys[1].confirm.as_deref(), Some("Sure?")); + let back = serde_json::to_string(§ion).unwrap(); + assert_eq!( + serde_json::from_str::(&back).unwrap(), + section + ); + + let bare: PluginSection = + serde_json::from_str(r#"{"id": "s", "label": "S", "type": "text", "text": ""}"#) + .unwrap(); + assert!(bare.keys.is_empty()); + assert!(!serde_json::to_string(&bare).unwrap().contains("keys")); + } + #[test] fn uses_period_true_for_error_panel() { let panel = PluginPanel::from_error("t", "boom"); diff --git a/crates/aura-ui/src/app.rs b/crates/aura-ui/src/app.rs index c5dbdee..08f02d5 100644 --- a/crates/aura-ui/src/app.rs +++ b/crates/aura-ui/src/app.rs @@ -4,7 +4,10 @@ use aura_core::{ config::{AgentConfig, AgentKind, AppConfig, PluginConfig}, keymap::{ActionGroup, BindingContext, KeyAction, Keymap}, lexicon::{self, Lexicon}, - plugin::{PluginContent, PluginControl, PluginPanel, PluginRunner, PluginSection}, + plugin::{ + keys::{self as plugin_keys, PluginBinding, PluginKeyWarning}, + PluginContent, PluginControl, PluginPanel, PluginRunner, PluginSection, + }, quota::{ forecast, AntigravityQuota, CodexQuota, ForecastSnapshot, ForecastStatus, ForecastWindow, GeminiQuota, QuotaApi, QuotaSnapshot, QuotaSource, QuotaWindow, @@ -16,8 +19,9 @@ use aura_core::{ }; use chrono::{DateTime, Local, Timelike, Utc}; use gpui::{ - div, prelude::*, px, rgb, size, svg, AnyElement, ClickEvent, Context, ElementId, FocusHandle, - Pixels, ScrollHandle, SharedString, Window, + div, prelude::*, px, rgb, size, svg, AnyElement, ClickEvent, Context, DummyKeyboardMapper, + ElementId, FocusHandle, KeyBinding, KeyDownEvent, MouseDownEvent, NoAction, Pixels, + ScrollHandle, SharedString, Window, }; use gpui_selectable_text::{set_selection_theme, SelectableText, SelectionScope, SelectionStyle}; @@ -240,6 +244,24 @@ pub struct AuraView { /// (buttons with a `confirm` label need two clicks). Cleared by any /// other button click, by firing an action, and on refresh. armed_action: Option, + /// Hint mode (`hint_mode`, see `crate::hints`): `Some` holds the label + /// characters typed so far. Only honoured while + /// [`Self::hints_available`]; anything that changes what's on screen + /// clears it. + hint_input: Option, + /// Keys the plugin section on screen declares, resolved behind the + /// leader, exactly as last installed (see [`Self::sync_plugin_keys`]). + plugin_bindings: Vec, + /// Problems with those keys (and with `plugin_leader`), for the help + /// overlay. Each distinct set also goes to stderr once. + plugin_key_warnings: Vec, + /// Leader mode (the plugin leader was pressed, see `keys::PluginLeader`): + /// `Some` holds the keystrokes typed after it so far. Only honoured while + /// [`Self::plugin_keys_active`]; cleared wherever `hint_input` is. + leader_input: Option>, + /// Bumped on every leader keystroke, so a `leader_timeout_ms` timer only + /// ends leader mode if no key came after it was started. + leader_generation: u64, /// Index into `SPINNER_FRAMES`, advanced by a timer while `is_loading`. spinner_frame: usize, error: Option, @@ -358,6 +380,11 @@ impl AuraView { refresh_generation: 0, action_inflight: false, armed_action: None, + hint_input: None, + plugin_bindings: Vec::new(), + plugin_key_warnings: Vec::new(), + leader_input: None, + leader_generation: 0, spinner_frame: 0, error: None, last_window_height: Rc::new(Cell::new(Pixels::ZERO)), @@ -480,9 +507,9 @@ 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); + // Reinstalled from the (possibly reloaded) config and keymap below, + // once the plugin panels are in, so a Refresh picks up edits to + // either file, like it does for the theme. self.keymap = result.keymap; if let Some(fallback) = result.fallback_profile { self.active_profile = fallback; @@ -493,6 +520,8 @@ impl AuraView { self.plugin_panels = result.plugin_panels; self.error = result.error; self.armed_action = None; + self.hint_input = None; + self.leader_input = None; // Keep the tray icon in step with what the modal is showing. This is // the cheap half of the indicator: the quota snapshot was just loaded @@ -537,6 +566,7 @@ impl AuraView { } self.is_loading = false; + self.sync_plugin_keys(true, cx); cx.notify(); } @@ -569,6 +599,8 @@ impl AuraView { return; } self.armed_action = None; + self.hint_input = None; + self.leader_input = None; let Some(name) = self.active_plugin.clone() else { return; }; @@ -599,12 +631,6 @@ impl AuraView { .detach(); } - /// First click on a `confirm` button: arm it and wait for the second. - fn arm_plugin_action(&mut self, action_id: String, cx: &mut Context) { - self.armed_action = Some(action_id); - cx.notify(); - } - fn apply_action_result(&mut self, name: String, panel: PluginPanel, cx: &mut Context) { self.action_inflight = false; self.armed_action = None; @@ -625,8 +651,345 @@ impl AuraView { .find(|(n, _)| n == &name) .and_then(|(_, panel)| panel.sections.first().map(|s| s.id.clone())); } + self.sync_plugin_keys(false, cx); + cx.notify(); + } + + /// The plugin section on screen: the selected one, or the panel's first. + /// `None` without a panel, or for an error panel. + fn current_plugin_section(&self) -> Option<&PluginSection> { + let panel = self.current_plugin_panel()?; + if panel.error.is_some() { + return None; + } + self.active_plugin_section + .as_deref() + .and_then(|id| panel.section(id)) + .or_else(|| panel.sections.first()) + } + + /// Resolve the keys of the plugin section on screen and install them + /// with the keymap when they differ from what's installed (always, with + /// `force`). Runs whenever the plugin, section, mode or panel changes. + fn sync_plugin_keys(&mut self, force: bool, cx: &mut Context) { + let (bindings, warnings) = self.resolve_plugin_keys(); + if !force && bindings == self.plugin_bindings { + return; + } + report_plugin_key_warnings(&warnings); + // Only the leader is a GPUI binding; leader mode matches the rest. + let leader = if bindings.is_empty() { + None + } else { + plugin_keys::effective_leader(&self.config.keybindings.plugin_leader) + .0 + .map(|l| l.canonical) + }; + crate::keys::install( + cx, + &self.keymap, + self.config.keybindings.enabled, + leader.as_deref(), + ); + self.plugin_bindings = bindings; + self.plugin_key_warnings = warnings; + } + + fn resolve_plugin_keys(&self) -> (Vec, Vec) { + if !self.config.keybindings.enabled || self.mode != Mode::Plugin { + return (Vec::new(), Vec::new()); + } + let raw_leader = &self.config.keybindings.plugin_leader; + let (leader, _) = plugin_keys::effective_leader(raw_leader); + let (Some(leader), Some(section)) = (leader, self.current_plugin_section()) else { + return (Vec::new(), Vec::new()); + }; + if section.keys.is_empty() { + return (Vec::new(), Vec::new()); + } + let mut warnings: Vec = + plugin_keys::leader_warnings(raw_leader, &self.keymap) + .into_iter() + .map(|message| PluginKeyWarning { message }) + .collect(); + let (bindings, section_warnings) = plugin_keys::resolve(section, &leader); + warnings.extend(section_warnings); + (bindings, warnings) + } + + /// Whether the root carries the `plugin` key context: the section on + /// screen has keys and nothing covers it. + fn plugin_keys_active(&self) -> bool { + self.mode == Mode::Plugin + && !self.plugin_bindings.is_empty() + && !self.is_loading + && !self.action_inflight + && !self.overlay_open() + && !self.hint_mode_active() + } + + /// A plugin key was completed in leader mode. + fn run_plugin_key(&mut self, action_id: String, cx: &mut Context) { + if !self.plugin_keys_active() { + return; + } + let confirm = self + .plugin_bindings + .iter() + .any(|b| b.action == action_id && b.confirm.is_some()) + || self + .current_plugin_section() + .is_some_and(|s| plugin_keys::button_confirms(s, &action_id)); + self.press_plugin_action(action_id, confirm, cx); + } + + /// Press a plugin action the way a button click does — the one path for + /// clicks, hint labels and plugin keys. With `confirm`, the first press + /// only arms it and a second press fires; returns whether it armed. + fn press_plugin_action(&mut self, id: String, confirm: bool, cx: &mut Context) -> bool { + if confirm && self.armed_action.as_deref() != Some(id.as_str()) { + self.armed_action = Some(id); + cx.notify(); + true + } else { + self.run_plugin_action(id, cx); + false + } + } + + fn leader_mode_active(&self) -> bool { + self.leader_input.is_some() && self.plugin_keys_active() + } + + /// The leader fired: start reading a plugin shortcut. + pub(crate) fn enter_leader_mode(&mut self, cx: &mut Context) { + if !self.plugin_keys_active() { + return; + } + self.leader_input = Some(Vec::new()); + self.arm_leader_timeout(cx); + cx.notify(); + } + + /// With `leader_timeout_ms` set, leave leader mode that long after the + /// latest keystroke. Unset, leader mode lasts until a shortcut completes + /// or Escape. + fn arm_leader_timeout(&mut self, cx: &mut Context) { + self.leader_generation += 1; + let Some(ms) = self.config.keybindings.leader_timeout_ms else { + return; + }; + let generation = self.leader_generation; + cx.spawn(async move |this, cx| { + cx.background_executor() + .timer(Duration::from_millis(u64::from(ms))) + .await; + let _ = this.update(cx, |view, cx| { + if view.leader_generation == generation && view.leader_input.take().is_some() { + cx.notify(); + } + }); + }) + .detach(); + } + + /// The section's keys still reachable after `typed`, each with whether + /// `typed` completes it. Matching goes through GPUI's own keystroke + /// matcher, so shifted and IME-produced characters behave as in the + /// keymap. + fn leader_matches(&self, typed: &[gpui::Keystroke]) -> Vec<(&PluginBinding, bool)> { + self.plugin_bindings + .iter() + .filter_map(|b| { + let binding = KeyBinding::load( + &b.own_keys, + Box::new(NoAction), + None, + false, + None, + &DummyKeyboardMapper, + ) + .ok()?; + binding.match_keystrokes(typed).map(|pending| (b, !pending)) + }) + .collect() + } + + /// Root `key_down` listener in leader mode, where the root carries no + /// keymap context. A key that completes a shortcut runs it (the shorter + /// one wins when it also starts a longer one); a key that starts one + /// waits for more; any other key is ignored. Escape cancels. + fn leader_key_down(&mut self, event: &KeyDownEvent, cx: &mut Context) { + if !self.leader_mode_active() { + return; + } + cx.stop_propagation(); + let keystroke = &event.keystroke; + match keystroke.key.as_str() { + "escape" => self.leader_input = None, + "backspace" => { + if let Some(input) = &mut self.leader_input { + input.pop(); + } + self.arm_leader_timeout(cx); + } + _ => { + let mut typed = self.leader_input.clone().unwrap_or_default(); + typed.push(keystroke.clone()); + let matches = self.leader_matches(&typed); + let complete = matches + .iter() + .find(|(_, complete)| *complete) + .map(|(b, _)| b.action.clone()); + let pending = !matches.is_empty(); + if let Some(action) = complete { + self.leader_input = None; + self.run_plugin_key(action, cx); + } else if pending { + self.leader_input = Some(typed); + self.arm_leader_timeout(cx); + } + } + } cx.notify(); } + + /// The floating key panel at the right: the section's keys while leader + /// mode waits for them, or the confirm prompt for an action a key armed + /// whose button isn't on screen (its pill shows the prompt otherwise). + fn render_leader_panel(&self) -> Option { + let colors = self.theme.colors; + let card = || { + div() + .absolute() + .right(px(12.0)) + .bottom(px(12.0)) + .w(px(210.0)) + .flex() + .flex_col() + .gap_1() + .p_2() + .bg(rgb(colors.surface)) + .rounded_md() + .border_1() + .border_color(rgb(colors.border)) + .shadow_lg() + .text_xs() + }; + let chip = |text: String| { + div() + .flex_shrink_0() + .px_1() + .rounded_sm() + .bg(rgb(colors.surface_hi)) + .text_color(rgb(colors.text)) + .child(SharedString::from(text)) + }; + + if self.leader_mode_active() { + let typed = self.leader_input.as_deref().unwrap_or_default(); + let leader = plugin_keys::effective_leader(&self.config.keybindings.plugin_leader) + .0 + .map(|l| l.display) + .unwrap_or_default(); + let mut path = vec![leader]; + path.extend(typed.iter().map(|k| k.unparse())); + + let mut panel = card().child( + div() + .flex() + .flex_row() + .items_center() + .gap_1() + .pb_1() + .border_b_1() + .border_color(rgb(colors.border)) + .text_color(rgb(colors.text_dim)) + .children(path.into_iter().map(|p| chip(p).into_any_element())) + .child("…"), + ); + for (b, _) in self.leader_matches(typed) { + // The strokes still to press. + let rest: Vec<&str> = b.key_display.split(' ').skip(typed.len()).collect(); + panel = panel.child( + div() + .flex() + .flex_row() + .items_center() + .gap_2() + .child(chip(rest.join(" "))) + .child( + div() + .min_w_0() + .text_color(rgb(colors.text_dim)) + .child(SharedString::from(b.label.clone())), + ), + ); + } + return Some( + panel + .child( + div() + .pt_1() + .text_color(rgb(colors.text_dim)) + .opacity(0.7) + .child("esc to cancel"), + ) + .into_any_element(), + ); + } + + let armed = self.armed_action.as_deref()?; + if self.mode != Mode::Plugin || self.action_inflight { + return None; + } + let on_screen = self + .hint_targets() + .iter() + .any(|(id, confirm)| id == armed && *confirm); + if on_screen { + return None; + } + let binding = self.plugin_bindings.iter().find(|b| b.action == armed)?; + let prompt = binding.confirm.as_deref().unwrap_or("Confirm?"); + Some( + card() + .border_color(rgb(colors.error)) + .child( + div() + .text_color(rgb(colors.error)) + .child(SharedString::from(prompt.to_string())), + ) + .child( + div() + .text_color(rgb(colors.text_dim)) + .child(SharedString::from(format!( + "press {} again · esc to cancel", + binding.display + ))), + ) + .into_any_element(), + ) + } +} + +/// Help overlay rows of one group: `(keys, description)`. +type HelpRows = Vec<(Vec, SharedString)>; + +/// Plugin key warnings go to stderr once per distinct set: syncing runs on +/// every section switch and panel refresh. +fn report_plugin_key_warnings(warnings: &[PluginKeyWarning]) { + static LAST: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + let Ok(mut last) = LAST.lock() else { + return; + }; + if last.as_slice() == warnings { + return; + } + for w in warnings { + eprintln!("aura: plugin keys: {w}"); + } + *last = warnings.to_vec(); } /// Pure background-thread refresh worker. All the heavy I/O (config reload, @@ -832,6 +1195,9 @@ impl AuraView { fn set_mode(&mut self, mode: Mode, cx: &mut Context) { if self.mode != mode { self.mode = mode; + self.hint_input = None; + self.leader_input = None; + self.sync_plugin_keys(false, cx); cx.notify(); } } @@ -846,10 +1212,13 @@ impl AuraView { fn set_plugin(&mut self, name: String, cx: &mut Context) { if self.active_plugin.as_deref() != Some(name.as_str()) { self.active_plugin = Some(name); + self.hint_input = None; + self.leader_input = None; // Reset the active section to the new plugin's first section. self.active_plugin_section = self .current_plugin_panel() .and_then(|p| p.sections.first().map(|s| s.id.clone())); + self.sync_plugin_keys(false, cx); cx.notify(); } } @@ -857,6 +1226,9 @@ impl AuraView { fn set_plugin_section(&mut self, id: String, cx: &mut Context) { if self.active_plugin_section.as_deref() != Some(id.as_str()) { self.active_plugin_section = Some(id); + self.hint_input = None; + self.leader_input = None; + self.sync_plugin_keys(false, cx); cx.notify(); } } @@ -982,6 +1354,8 @@ impl AuraView { self.show_more_modal = false; self.show_settings_panel = false; self.show_help = false; + self.hint_input = None; + self.leader_input = None; if !was_open { match which { Overlay::More => self.show_more_modal = true, @@ -1053,9 +1427,15 @@ impl AuraView { } } K::Dismiss => { - if !gpui_selectable_text::registry::clear_active_selection(window, cx) { - crate::runtime::request_dismiss(); + if gpui_selectable_text::registry::clear_active_selection(window, cx) { + return; } + // An armed confirm button is disarmed before anything closes. + if self.armed_action.take().is_some() { + cx.notify(); + return; + } + crate::runtime::request_dismiss(); } K::Quit => cx.quit(), K::OpenConfig => self.open_config(cx), @@ -1071,7 +1451,91 @@ impl AuraView { self.dismiss_update(cx); } } + K::HintMode => self.toggle_hint_mode(cx), + } + } + + /// Whether hint mode can run: a plugin `controls` section with at least + /// one button is on screen, and nothing (overlay, refresh, action) is + /// covering it. + fn hints_available(&self) -> bool { + self.mode == Mode::Plugin + && !self.is_loading + && !self.action_inflight + && !self.overlay_open() + && !self.hint_targets().is_empty() + } + + fn hint_mode_active(&self) -> bool { + self.hint_input.is_some() && self.hints_available() + } + + /// The buttons of the plugin section on screen, in render order, as + /// `(action id, needs confirm)`. Hint labels are assigned by position in + /// this list, so it must walk controls exactly as + /// [`Self::render_plugin_controls`] does. + fn hint_targets(&self) -> Vec<(String, bool)> { + match self.current_plugin_section().map(|s| &s.content) { + Some(PluginContent::Controls { controls }) => controls + .iter() + .flat_map(|c| &c.buttons) + .map(|b| (b.id.clone(), b.confirm.is_some())) + .collect(), + _ => Vec::new(), + } + } + + fn toggle_hint_mode(&mut self, cx: &mut Context) { + self.hint_input = if self.hint_mode_active() || !self.hints_available() { + None + } else { + Some(String::new()) + }; + cx.notify(); + } + + /// Root `key_down` listener. Only claims keystrokes in hint mode, where + /// the root carries no keymap context and nothing else would see them. + fn hint_key_down(&mut self, event: &KeyDownEvent, cx: &mut Context) { + if !self.hint_mode_active() { + return; + } + cx.stop_propagation(); + let keystroke = &event.keystroke; + match keystroke.key.as_str() { + "escape" => { + self.hint_input = None; + self.leader_input = None; + self.armed_action = None; + } + "backspace" => { + if let Some(input) = &mut self.hint_input { + input.pop(); + } + } + key => { + let Some(c) = crate::hints::hint_char(key, &keystroke.modifiers) else { + return; + }; + let targets = self.hint_targets(); + let labels = crate::hints::labels(targets.len()); + let mut input = self.hint_input.clone().unwrap_or_default(); + input.push(c); + match crate::hints::resolve(&labels, &input) { + crate::hints::Match::Exact(i) => { + let (id, confirm) = targets[i].clone(); + if self.press_plugin_action(id, confirm, cx) { + // Armed: stay in hint mode, the same label again fires. + self.hint_input = Some(String::new()); + } + } + crate::hints::Match::Prefix => self.hint_input = Some(input), + // A typo: ignore the character, keep what was typed. + crate::hints::Match::None => return, + } + } } + cx.notify(); } /// Scroll the help list while it is open, the body otherwise. @@ -1289,7 +1753,27 @@ impl Render for AuraView { // `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())); + .key_context(crate::keys::root_context( + self.overlay_open(), + self.hint_mode_active() || self.leader_mode_active(), + self.plugin_keys_active(), + )) + .on_key_down(cx.listener(|view, event: &KeyDownEvent, _, cx| { + if view.hint_mode_active() { + view.hint_key_down(event, cx) + } else { + view.leader_key_down(event, cx) + } + })) + // Any click leaves hint and leader mode; a click on a button + // still fires it. + .on_any_mouse_down(cx.listener(|view, _: &MouseDownEvent, _, cx| { + let hint = view.hint_input.take().is_some(); + let leader = view.leader_input.take().is_some(); + if hint || leader { + cx.notify(); + } + })); let mut root = crate::keys::listen(focus_root, cx) .flex() .flex_col() @@ -1314,6 +1798,7 @@ impl Render for AuraView { }) .child(self.render_tab_row(cx)) .child(self.render_body(cx)) + .children(self.render_leader_panel()) // After children have been laid out, sum their vertical extent // and resize the window so it tightly fits the content. The body // is allowed to shrink below its content (overflow_y_scroll), so @@ -2261,6 +2746,27 @@ impl AuraView { let accent = self.current_accent(); let mut col = div().flex().flex_col().px_4().py_3().gap_1p5(); + // Hint mode: one label per button, in the order `hint_targets` lists + // them, with the characters typed so far. + let hint_labels = self + .hint_mode_active() + .then(|| crate::hints::labels(controls.iter().map(|c| c.buttons.len()).sum())); + let typed = self.hint_input.as_deref().unwrap_or(""); + let mut hint_index = 0; + if hint_labels.is_some() { + let status = if self.armed_action.is_some() { + "Press the label again to confirm · esc to cancel" + } else { + "Type a label to press its button · esc to cancel" + }; + col = col.child( + div() + .text_xs() + .text_color(rgb(theme.colors.text_dim)) + .child(status), + ); + } + for (ci, control) in controls.iter().enumerate() { let mut label_col = div().flex().flex_col().gap_0p5().min_w_0().child( div() @@ -2323,6 +2829,30 @@ impl AuraView { d.bg(rgb(theme.colors.surface_hi)) }) .text_color(rgb(fg)); + if let Some(labels) = &hint_labels { + let label = &labels[hint_index]; + hint_index += 1; + match label.strip_prefix(typed) { + Some(rest) => { + pill = pill.child( + div() + .flex() + .flex_row() + .px_1() + .rounded_sm() + .bg(rgb(theme.colors.accent)) + .text_color(rgb(theme.colors.bg)) + .font_weight(gpui::FontWeight::BOLD) + .when(!typed.is_empty(), |d| { + d.child(div().opacity(0.5).child(typed.to_string())) + }) + .child(rest.to_string()), + ); + } + // Ruled out by what's been typed: fade it. + None => pill = pill.opacity(0.35), + } + } if let Some(icon) = &button.icon { pill = pill.child(svg_icon_dynamic(SharedString::from(icon.clone()), fg, 10.0)); } @@ -2331,14 +2861,10 @@ impl AuraView { } let action_id = button.id.clone(); - let needs_confirm = button.confirm.is_some() && !armed; + let confirm = button.confirm.is_some(); pills = pills.child(pill.on_click(cx.listener( move |view, _: &ClickEvent, _, cx| { - if needs_confirm { - view.arm_plugin_action(action_id.clone(), cx); - } else { - view.run_plugin_action(action_id.clone(), cx); - } + view.press_plugin_action(action_id.clone(), confirm, cx); }, ))); } @@ -3679,8 +4205,53 @@ impl AuraView { list = list.child(block); } - for group in ActionGroup::ALL { - let rows = self.help_rows(group); + if !self.plugin_key_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-plugin-warnings-title", + "This plugin's keys have problems:", + )); + for (i, w) in self.plugin_key_warnings.iter().enumerate() { + block = block.child(sel( + sid(format!("help-plugin-warning-{i}")), + format!("• {}", w.message), + )); + } + list = list.child(block); + } + + let mut groups: Vec<(SharedString, HelpRows)> = ActionGroup::ALL + .into_iter() + .map(|group| { + let rows = self + .help_rows(group) + .into_iter() + .map(|(keys, description)| (keys, SharedString::from(description))) + .collect(); + (SharedString::from(group.label()), rows) + }) + .collect(); + // The keys the plugin section on screen declares. + if self.mode == Mode::Plugin && !self.plugin_bindings.is_empty() { + let name = self.active_plugin.clone().unwrap_or_default(); + let rows = self + .plugin_bindings + .iter() + .map(|b| (vec![b.display.clone()], SharedString::from(b.label.clone()))) + .collect(); + groups.push((SharedString::from(format!("Plugin: {name}")), rows)); + } + + for (label, rows) in groups { if rows.is_empty() { continue; } @@ -3688,7 +4259,7 @@ impl AuraView { div() .text_xs() .text_color(rgb(colors.text_dim)) - .child(group.label()), + .child(label), ); for (keys, description) in rows { let mut chips = div() @@ -4019,6 +4590,7 @@ mod tests { id: "s".to_string(), label: "S".to_string(), uses_period, + keys: Vec::new(), content: PluginContent::default(), }], error: None, diff --git a/crates/aura-ui/src/hints.rs b/crates/aura-ui/src/hints.rs new file mode 100644 index 0000000..4f337dd --- /dev/null +++ b/crates/aura-ui/src/hints.rs @@ -0,0 +1,124 @@ +//! Hint mode: press `hint_mode` (default `f`) in a plugin's `controls` +//! section and every button gets a short label; typing a label presses that +//! button, like Vimium's link hints. +//! +//! This module is the pure half — label generation and matching. The view +//! (`AuraView`) owns the mode state and the key handling. While hint mode is +//! on, the root drops its `Aura` key context (see `keys::root_context`), so +//! no keymap binding matches and the typed characters reach the root's +//! `key_down` listener instead. + +/// Label characters, home row first. Every label uses only these. +pub const HINT_CHARS: &str = "asdfjklghqwertyuiopzxcvbnm"; + +/// `count` distinct labels, all the same length, so no label is a prefix of +/// another and a label fires as soon as it is complete. One character per +/// label up to 26 buttons, two up to 676, and so on. +pub fn labels(count: usize) -> Vec { + let chars: Vec = HINT_CHARS.chars().collect(); + let base = chars.len(); + let mut len = 1; + let mut capacity = base; + while capacity < count { + len += 1; + capacity = capacity.saturating_mul(base); + } + (0..count) + .map(|mut n| { + let mut label = vec![chars[0]; len]; + for slot in label.iter_mut().rev() { + *slot = chars[n % base]; + n /= base; + } + label.into_iter().collect() + }) + .collect() +} + +/// What typing `input` means against `labels`. +#[derive(Debug, PartialEq, Eq)] +pub enum Match { + /// `input` is a whole label: press the button at this index. + Exact(usize), + /// `input` starts at least one label; wait for more characters. + Prefix, + /// No label starts with `input`. + None, +} + +pub fn resolve(labels: &[String], input: &str) -> Match { + if let Some(i) = labels.iter().position(|l| l == input) { + Match::Exact(i) + } else if labels.iter().any(|l| l.starts_with(input)) { + Match::Prefix + } else { + Match::None + } +} + +/// The hint character a keystroke types, if any: a bare label character, +/// case-insensitive, with no ctrl / alt / cmd / fn held. +pub fn hint_char(key: &str, modifiers: &gpui::Modifiers) -> Option { + if modifiers.control || modifiers.alt || modifiers.platform || modifiers.function { + return None; + } + let mut chars = key.chars(); + let c = chars.next()?.to_ascii_lowercase(); + (chars.next().is_none() && HINT_CHARS.contains(c)).then_some(c) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn single_characters_up_to_the_alphabet_size() { + let l = labels(3); + assert_eq!(l, ["a", "s", "d"]); + assert!(labels(26).iter().all(|l| l.len() == 1)); + } + + #[test] + fn longer_labels_share_one_length() { + let l = labels(27); + assert!(l.iter().all(|l| l.len() == 2)); + assert_eq!(l[0], "aa"); + assert_eq!(l[1], "as"); + assert_eq!(l[26], "sa"); + let unique: std::collections::HashSet<_> = l.iter().collect(); + assert_eq!(unique.len(), 27); + } + + #[test] + fn no_labels_for_no_buttons() { + assert!(labels(0).is_empty()); + } + + #[test] + fn resolve_exact_prefix_none() { + let l = labels(30); + assert_eq!(resolve(&l, "aa"), Match::Exact(0)); + assert_eq!(resolve(&l, "a"), Match::Prefix); + assert_eq!(resolve(&l, "m"), Match::None); + assert_eq!(resolve(&l, "sz"), Match::None); + } + + #[test] + fn hint_char_filters_modifiers_and_named_keys() { + let none = gpui::Modifiers::default(); + assert_eq!(hint_char("a", &none), Some('a')); + assert_eq!(hint_char("A", &none), Some('a')); + assert_eq!(hint_char("1", &none), None); + assert_eq!(hint_char("escape", &none), None); + let ctrl = gpui::Modifiers { + control: true, + ..Default::default() + }; + assert_eq!(hint_char("a", &ctrl), None); + let shift = gpui::Modifiers { + shift: true, + ..Default::default() + }; + assert_eq!(hint_char("a", &shift), Some('a')); + } +} diff --git a/crates/aura-ui/src/keys.rs b/crates/aura-ui/src/keys.rs index deff1b1..a18d396 100644 --- a/crates/aura-ui/src/keys.rs +++ b/crates/aura-ui/src/keys.rs @@ -7,6 +7,14 @@ //! 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. +//! +//! Plugin-declared keys (see `aura_core::plugin::keys`) are not GPUI +//! bindings. Only the leader is: it matches `plugin`, which the root adds +//! while a plugin section with keys is on screen, and fires +//! [`PluginLeader`]. That opens leader mode, where the view reads the rest +//! of the shortcut itself (like hint mode), so it waits as long as +//! `leader_timeout_ms` says — forever by default — instead of GPUI's fixed +//! one-second sequence timeout, and Escape only cancels. use std::{rc::Rc, sync::Mutex}; @@ -22,6 +30,16 @@ use crate::app::AuraView; pub const CONTEXT_ROOT: &str = "Aura"; /// Key context identifier added while an overlay is open. pub const CONTEXT_OVERLAY: &str = "overlay"; +/// Key context identifier added while the plugin section on screen has keys. +pub const CONTEXT_PLUGIN: &str = "plugin"; + +actions!( + aura, + [ + /// The plugin leader: open leader mode for the section's keys. + PluginLeader + ] +); /// Declares a GPUI action per [`KeyAction`] variant (same name) plus the two /// exhaustive mappings between them, so a new variant can't be forgotten. @@ -35,11 +53,15 @@ macro_rules! key_actions { } } - /// Attach a listener for every keymap action to `el`. + /// Attach a listener for every keymap action, and for plugin keys, + /// 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) })))* + .on_action(cx.listener(|view: &mut AuraView, _: &PluginLeader, _, cx| { + view.enter_leader_mode(cx) + })) } }; } @@ -81,15 +103,25 @@ key_actions!( OpenKeybindings, OpenUpdate, DismissUpdate, + HintMode, ); -/// The key context the modal's root element carries. -pub fn root_context(overlay_open: bool) -> KeyContext { +/// The key context the modal's root element carries. In hint mode and +/// leader mode (`capture_keys`) it drops `Aura` (and so every keymap +/// binding): the typed keys must reach the root's `key_down` listener +/// instead (see `hints`). +pub fn root_context(overlay_open: bool, capture_keys: bool, plugin_keys: bool) -> KeyContext { let mut context = KeyContext::new_with_defaults(); + if capture_keys { + return context; + } context.add(CONTEXT_ROOT); if overlay_open { context.add(CONTEXT_OVERLAY); } + if plugin_keys { + context.add(CONTEXT_PLUGIN); + } context } @@ -100,10 +132,11 @@ fn predicate(context: BindingContext) -> &'static str { } } -/// 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) { +/// Replace the app's key bindings with `keymap`, plus the plugin `leader` +/// (canonical keys) when the section on screen declares keys, 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, leader: Option<&str>) { cx.clear_key_bindings(); crate::runtime::set_keybindings_active(enabled); if !enabled { @@ -111,9 +144,14 @@ pub fn install(cx: &mut App, keymap: &Keymap, enabled: bool) { } report(&keymap.warnings); - let mut bindings = Vec::with_capacity(keymap.bindings.len()); + let mut bindings = Vec::with_capacity(keymap.bindings.len() + 1); // Global first: overlay bindings must come later to win their ties. for context in BindingContext::ALL { + if context == BindingContext::Overlay { + if let Some(leader) = leader { + push_leader(&mut bindings, leader); + } + } let predicate = KeyBindingContextPredicate::parse(predicate(context)) .ok() .map(Rc::new); @@ -139,6 +177,26 @@ pub fn install(cx: &mut App, keymap: &Keymap, enabled: bool) { cx.bind_keys(bindings); } +/// The leader sits between global and overlay. It can't tie with a global +/// binding unless the user bound it there (`aura keys validate` warns), and +/// the root drops `plugin` while an overlay is open. +fn push_leader(bindings: &mut Vec, leader: &str) { + let predicate = KeyBindingContextPredicate::parse(CONTEXT_PLUGIN) + .ok() + .map(Rc::new); + match KeyBinding::load( + leader, + Box::new(PluginLeader), + predicate, + false, + None, + &DummyKeyboardMapper, + ) { + Ok(b) => bindings.push(b), + Err(e) => eprintln!("aura: plugin leader `{leader}`: {e}"), + } +} + fn report(warnings: &[KeymapWarning]) { static LAST: Mutex> = Mutex::new(Vec::new()); let Ok(mut last) = LAST.lock() else { diff --git a/crates/aura-ui/src/lib.rs b/crates/aura-ui/src/lib.rs index 7eec1e7..33892b7 100644 --- a/crates/aura-ui/src/lib.rs +++ b/crates/aura-ui/src/lib.rs @@ -7,6 +7,7 @@ mod app; mod assets; mod format; +mod hints; mod keys; mod placement; mod platform; @@ -669,7 +670,7 @@ fn toggle_window( // 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); + keys::install(cx, &keymap, config.keybindings.enabled, None); let anchor = placement::Anchor::from_config(&config.window.anchor); // `display_id` rides along to `AuraView` so the auto-fit callback caps the diff --git a/docs/configuration.md b/docs/configuration.md index 20f1371..b955451 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -250,6 +250,8 @@ 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. | +| `plugin_leader` | string | keystroke(s) \| `none` | `"space"` | Key pressed before a plugin's own shortcuts: a plugin's `s` is `space s`. `none` turns plugin shortcuts off. See [keybindings.md](keybindings.md#plugin-keys). | +| `leader_timeout_ms` | u32? | — | unset | How long the plugin key panel waits for the next key before closing. Unset: until the shortcut completes or Escape. | ### `[sponsor]` diff --git a/docs/keybindings.md b/docs/keybindings.md index a8e36ba..107d9b0 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -2,12 +2,14 @@ title: Keybindings status: current version: 0.2.0 -last_updated: 2026-09-23 -last_verified: 2026-09-23 +last_updated: 2026-09-25 +last_verified: 2026-09-25 source_refs: - crates/aura-core/src/keymap/mod.rs - crates/aura-core/src/keymap/file.rs - crates/aura-ui/src/keys.rs + - crates/aura-ui/src/hints.rs + - crates/aura-core/src/plugin/keys.rs - crates/aura-ui/src/app.rs - crates/aura-ui/src/lib.rs - crates/aura-cli/src/keys.rs @@ -69,12 +71,74 @@ the body. | `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) | +| `f` | `hint_mode` | Label a plugin's buttons so you can press them by key. See [Hint mode](#hint-mode) | | — | `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. +selected, it closes the open overlay. If no overlay is open, it disarms a +plugin button waiting for its confirming press. Otherwise it closes the +window. `q` closes the window even when an overlay is open, but it also +disarms an armed button first. + +### Hint mode + +Plugin panels can have buttons (`controls` sections). To press one without +the mouse, press `f`. Every button on the tab gets a short label, such as `a`, +`s` or `d`. Type a label to press that button. + +- Labels use the letters `asdfjklghqwertyuiopzxcvbnm`, home row first. Up to + 26 buttons get one letter each. Beyond that, every label is two letters. +- Buttons that no longer match what you've typed fade out. `backspace` + deletes the last letter you typed. A letter that matches no label does + nothing. +- A button that asks for confirmation (e.g. **Remove**) arms on the first + press of its label and fires when you type the label again. Typing a + different label disarms it. +- `esc` or a click leaves hint mode. So does switching tabs, plugins or + mode, opening an overlay, or pressing a button. +- While hint mode is on, your other shortcuts are paused, so labels can + reuse their letters. + +`f` does nothing outside a plugin tab that has buttons. + +### Plugin keys + +A plugin can give its actions their own shortcuts. They all start with the +**leader**, `space` by default, so a plugin's `m` key is pressed as `space m`. +Aura's own shortcuts never start with the leader, so a plugin can't take one +of them over. + +- Plugin keys work only while the plugin tab that declares them is on screen. + Each tab (section) has its own keys. +- Press the leader: a panel floats at the bottom right of the window with + the tab's keys. As you type, it narrows to the keys that still match and + shows what's left to press. `backspace` takes back a key. A key that + matches nothing is ignored. +- The panel waits until you finish a shortcut or press `esc`. To have it + give up on its own, set `leader_timeout_ms` (below). Each key restarts + the wait. +- If one key starts another (`d` and `d d`), the shorter one runs as soon + as it's typed, so the longer one can never be pressed. `aura plugin run` + warns about this. +- The `?` overlay lists the tab's keys under **Plugin: \**. +- A key for a button that asks for confirmation arms on the first press and + fires on the second, like a click. If that button isn't on screen, the + floating panel shows the prompt. `esc` disarms it. +- Change the leader in `config.toml`, or turn plugin keys off: + + ```toml + [keybindings] + plugin_leader = "ctrl-p" # or "none" + leader_timeout_ms = 3000 # unset (the default): wait until esc + ``` + + Or run `aura config set keybindings.plugin_leader ctrl-p`. +- Aura doesn't remap plugin keys. If a plugin lets you change its keys, it + does so in its own settings. +- `aura keys validate` and `aura doctor` warn when the leader collides with a + `[global]` binding, e.g. a leader of `g` next to `g g`. `aura plugin run + ` prints each tab's keys and any problems with them on stderr. ## Customizing: `keybindings.toml` @@ -274,5 +338,23 @@ wrote it. 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. +- Hint mode (`aura-ui/src/hints.rs`) removes the `Aura` context from the root + element while it runs, so no binding matches. Typed letters then reach the + root's `key_down` listener, which matches them against the labels. Labels + follow the order buttons render in, and all labels have the same length, + so none is a prefix of another. +- Plugin keys (`aura-core/src/plugin/keys.rs`) are resolved per section: + the leader goes in front, spellings are made canonical, and duplicates or + keys that start other keys are reported. Only the leader becomes a GPUI + binding, in a third context, `plugin`, between global and overlay. The + root carries `plugin` only while the section on screen has keys and no + overlay, hint mode, refresh or action is running. The leader opens leader + mode, which works like hint mode: the root drops its keymap contexts and + its `key_down` listener matches the typed strokes against the section's + keys with GPUI's own `KeyBinding::match_keystrokes`. Aura reads the + strokes itself rather than binding whole sequences because GPUI forgets a + pending sequence after a fixed second, and Escape during one would + replay into `dismiss`. The leader is reinstalled whenever the plugin, + section, mode or panel changes. - When shortcuts are on, Escape is an ordinary binding. The fallback Escape observer in `main.rs` only runs when they're off. diff --git a/docs/plans/plugin-keymaps.md b/docs/plans/plugin-keymaps.md new file mode 100644 index 0000000..050c162 --- /dev/null +++ b/docs/plans/plugin-keymaps.md @@ -0,0 +1,272 @@ +--- +title: Plugin keymaps +status: implemented +version: 0.4.0 +last_updated: 2026-09-25 +last_verified: 2026-09-25 +source_refs: + - crates/aura-core/src/plugin/mod.rs + - crates/aura-core/src/keymap/mod.rs + - crates/aura-core/src/config.rs + - crates/aura-core/src/config_schema.rs + - crates/aura-ui/src/keys.rs + - crates/aura-ui/src/hints.rs + - crates/aura-ui/src/app.rs + - crates/aura-cli/src/lib.rs +owner: "@rfluid" +tags: [plugins, keybindings, design] +--- + +# Plugin keymaps + +## Problem + +Plugins can show buttons (`controls` sections), and a click calls the plugin +back with ` action `. From the keyboard, the only way to press a +button is [hint mode](../keybindings.md#hint-mode) (`f`). Hint mode works for +every plugin, but labels depend on where a button sits on the tab, so users +can't learn them. Actions a plugin wants on a fixed key, like "toggle mute", +have no way to get one. + +## Scope + +- A plugin declares keys on each section of its panel JSON. Each key maps to + an action id and is active only while that section is on screen. +- Every plugin key sits behind a **leader** keystroke, `space` by default, + set in `config.toml`. `space s` runs the plugin's `s` key. +- Pressing a plugin key does exactly what clicking the button with that id + does, including the `confirm` two-press rule. +- Aura shows the keys: a badge on the matching button, a group in the `?` + help overlay, and a strip listing the keys after the leader is pressed. + +## Non-goals + +- Plugins can't bind keys without the leader, and they can't override Aura's + own shortcuts. +- No new way to call the plugin. Keys use the existing `action ` call. +- Aura doesn't remap plugin keys. `keybindings.toml` has no plugin table. A + plugin that wants its keys remappable offers that itself, e.g. a settings + section or its own config file, and emits the user's choice in `keys`. +- The leader does nothing outside plugin mode. Agent tabs get no leader strip. +- No keys declared in the sidecar TOML. Button ids change at runtime (e.g. + `agent:Peh:off`) and a panel is re-sent after every action, so keys belong + in the panel next to the buttons. + +## Design + +### Wire format + +Each section gets an optional `keys` list. Old plugins leave it out and see +no change. A key a plugin wants on every section is repeated in each one; +the plugin builds its sections in code, so this costs it one helper call. + +```json +{ + "title": "Audio Hooks", + "sections": [ + { + "id": "agents", "label": "Agents", "type": "controls", + "keys": [ + { "keys": "s", "action": "mute:toggle", "label": "Toggle sound" } + ], + "controls": [ ... ] + }, + { + "id": "profiles", "label": "Profiles", "type": "controls", + "keys": [ + { "keys": "s", "action": "mute:toggle", "label": "Toggle sound" }, + { "keys": "d", "action": "profile:rm:work", "label": "Delete work", + "confirm": "Delete work?" } + ], + "controls": [ ... ] + } + ] +} +``` + +Keys work on every section type, not only `controls`. A `lines` or `table` +section can have keys for actions that have no button. + +`PluginKey` fields: + +| Field | Type | Default | Notes | +| --------- | -------------- | ------- | ----- | +| `keys` | string | — | Keystrokes after the leader, in `keybindings.toml` syntax (`s`, `ctrl-x`, `d d`) | +| `action` | string | — | Action id sent back as `action `, the same as a button `id` | +| `label` | string | — | Shown in the help overlay and the leader strip | +| `confirm` | string \| null | `null` | Two-press confirm when no button carries it (see below) | + +Rust side, in `aura-core/src/plugin/mod.rs`: + +```rust +pub struct PluginSection { + pub id: String, + pub label: String, + #[serde(default = "default_true")] + pub uses_period: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub keys: Vec, + #[serde(flatten)] + pub content: PluginContent, +} +``` + +### Leader config + +```toml +# config.toml +[keybindings] +enabled = true +plugin_leader = "space" # any keystroke(s); "none" turns plugin keys off +``` + +- `KeybindingsConfig.plugin_leader: String`, default `"space"`. Add a + `config_schema` field so `aura config set keybindings.plugin_leader ctrl-p` + works and is validated. +- The value is parsed with `keymap::parse_keys`. If it's invalid, Aura warns + and falls back to `space`. +- Why `space`: no default binding uses it, so a plugin key can't collide with + an Aura shortcut. It's also the leader vim and helix users already expect. +- If the user binds the leader itself in `[global]`, report it in + `aura keys validate` and `aura doctor`. GPUI still waits for a second key, + so both keep working, but the global binding fires only after the timeout. + This is the same case as the existing "one binding is the start of a longer + one" warning. + +### Resolving keys (aura-core, no UI code) + +`aura_core::plugin::keys::resolve(section, leader) -> +(Vec, Vec)`: + +- Joins the leader and `keys` into one sequence (`space s`) and makes it + canonical with the keymap's parser. `S` and `shift-s` are treated as the + same key. +- Warnings, which never fail the panel: + - invalid keystroke → skipped + - empty `action` → skipped + - the same keys twice → the later one wins + - one key is the start of a longer one (`d` and `d d`) → `d` fires after a + short wait + +Keeping this in core means `aura plugin run` can print the same warnings, and +it can be tested without GPUI. + +### GPUI wiring (aura-ui) + +- One action carries the id: + + ```rust + #[derive(Clone, PartialEq, gpui::Action)] + #[action(namespace = aura, no_json)] + pub struct PluginKeyAction { pub action_id: SharedString } + ``` + +- A new key context `plugin`. The root adds it next to `Aura` when all of + these hold: + - the modal is in plugin mode, + - the panel has loaded without an error, + - no overlay is open, + - no action or refresh is running, + - hint mode is off. +- `keys::install` takes the resolved plugin bindings too. It installs global, + then plugin, then overlay, keeping overlay last. +- The bindings depend on the active plugin, section and panel. A single + `sync_plugin_keys()` runs after a refresh, `apply_action_result`, + `set_plugin`, `set_plugin_section` and `set_mode`. It reinstalls only when + the resolved set has changed. `install` already clears and rebinds on every + open and refresh, so this costs little. +- Alternative considered: install every plugin's keys once, with predicates + like `plugin == p3 && section == s1`. That avoids reinstalls, but a panel + still changes after each action, so it would need reinstalls anyway. + +### Pressing a key + +Refactor how a button fires so click, hint mode and plugin keys share one +path: + +```rust +fn press_plugin_action(&mut self, id: String, confirm: bool, cx: &mut Context) +``` + +- `confirm` is true when a button on screen with this id has `confirm`, or + when the key entry sets `confirm`. +- First press arms (`armed_action`), second press fires, and any other press + disarms. This is today's click behavior. +- When the armed action has no button on screen, a one-line strip at the top + of the plugin body shows the key's `confirm` text so the user knows a + second press is needed. +- Add a step to Escape's order: clear the selection → disarm → close the + overlay → dismiss. + +### Showing the keys + +- **Button badge.** If a key's `action` matches a button id on screen, the + pill shows a dim key hint after its label (`␣s`). +- **Help overlay.** A "Plugin: \" group after Commands lists the keys + of the section on screen, with their labels. +- **Leader strip.** After the leader is pressed, GPUI waits about a second for + the next key. `cx.observe_pending_input` plus + `window.pending_input_keystrokes()` tell us when the pending input equals + the leader. While it does, a strip at the bottom of the body lists each key + and its label, like a small which-key popup. The strip goes away when the + sequence completes or times out. + +### CLI + +- `aura plugin run ` prints the resolved keys and any key warnings to + stderr, next to the JSON. +- `aura keys validate` and `aura doctor` check `plugin_leader`. They don't run + plugins. + +## Implementation steps + +1. **Core.** `PluginKey` and `PluginSection.keys` (serde round-trip tests), + `KeybindingsConfig.plugin_leader` plus its schema field, and + `plugin::keys::resolve` with warnings and tests. Add the leader collision + check to the keymap warnings. +2. **UI plumbing.** Add `PluginKeyAction`, the `plugin` context in + `keys::root_context`, a plugin-bindings parameter on `keys::install`, and + `sync_plugin_keys()` wired into the five call sites. +3. **Press path.** Add `press_plugin_action` and move the click and hint-mode + paths onto it. Add the confirm strip for buttons that aren't on screen, and + the disarm step in Escape's order. +4. **Showing keys.** Button badges, the help overlay group, and the leader + strip. +5. **CLI and docs.** Warnings in `aura plugin run`, the leader check in + `aura keys validate` and `aura doctor`, and updates to `keybindings.md`, + `plugin-authoring.md` and `configuration.md`. +6. **Adoption.** Add a `controls` section with keys to `plugins/hello` as the + reference. Give audio-hooks keys for mute and for per-agent profile cycling + (separate repo). + +## Decisions + +- **Keys are declared on each section**, not once for the panel. Scoping is + then the section itself, and there's no `section` field to validate. +- **Remapping is the plugin's job.** Aura installs the keys the plugin emits, + as they are. A plugin that wants remappable keys offers that itself and + emits the user's choice. +- **The leader does nothing outside plugin mode.** + + +## Revision (2026-09-25) + +After trying it: + +- **No key badges on buttons.** The floating panel is where keys are shown. +- **The leader strip became a floating panel** at the bottom right of the + window. It narrows as keys are typed and shows the strokes left to press. +- **Aura reads the strokes after the leader itself (leader mode).** Only + the leader is a GPUI binding. GPUI drops a pending sequence after a fixed + second, which closed the strip too early, and Escape during one replayed + into `dismiss`. Leader mode waits until the shortcut completes or Escape, + or for `[keybindings] leader_timeout_ms` when that's set (unset by + default). +- **The shorter key wins.** When one key starts another, the shorter one + runs as soon as it's typed. Waiting for a timeout makes no sense when the + default is to wait forever. + +## References + +- [Keybindings](../keybindings.md): keymap contexts, hint mode +- [Plugin authoring](../plugin-authoring.md): `controls`, the `action` call diff --git a/docs/plugin-authoring.md b/docs/plugin-authoring.md index 19995b0..ba9c11a 100644 --- a/docs/plugin-authoring.md +++ b/docs/plugin-authoring.md @@ -1,8 +1,8 @@ --- title: Plugin authoring status: stable -version: 0.2.0 -last_updated: 2026-06-12 +version: 0.3.0 +last_updated: 2026-09-25 source_refs: - crates/aura-core/src/plugin/mod.rs - crates/aura-core/src/plugin/runner.rs @@ -167,6 +167,11 @@ Two differences from panel refreshes: - While the action runs, the focus-loss auto-dismiss is suspended, so a dialog your plugin opens can take focus without closing the modal. +Keyboard users can press your buttons too: `f` puts every button on the +tab in hint mode (see [Keybindings → Hint mode](keybindings.md#hint-mode)). +Hint mode sends the same `action ` call as a click, and `confirm` still +needs two presses. + Action ids are opaque to the host: pick any encoding you like and parse it yourself. Test actions headlessly with: @@ -174,6 +179,46 @@ it yourself. Test actions headlessly with: aura plugin run "My Plugin" --action "agent:Peh:off" ``` +### Keyboard shortcuts + +Any section (not only `controls`) can declare shortcuts for actions: + +```json +{ + "id": "agents", "label": "Agents", "type": "controls", + "keys": [ + { "keys": "m", "action": "mute:on", "label": "Mute sound" }, + { "keys": "r 1", "action": "hooks:Peh:remove", "label": "Remove Peh's hooks" } + ], + "controls": [ ... ] +} +``` + +**`PluginKey`** fields: + +| Field | Type | Default | Notes | +| --------- | -------------- | ------- | ----- | +| `keys` | string | — | Keystrokes after the leader, in `keybindings.toml` syntax (`m`, `ctrl-x`, `r 1`) | +| `action` | string | — | Action id, sent back exactly like a button click | +| `label` | string | — | Shown in the leader strip and the `?` overlay | +| `confirm` | string \| null | `null` | Two-press confirm prompt, for actions without a button carrying `confirm` | + +- The user presses the leader first, `space` by default, so `"m"` is + `space m`. The leader is set by the user (`[keybindings] plugin_leader`), + so your keys can never collide with Aura's. +- Keys are active only while their section is on screen. Repeat a key in + each section where it should work. +- If a button on screen has the key's `action` as its id, its `confirm` + applies to the key too. +- Pressing the leader opens a floating panel listing the section's keys and + labels, so keep labels short. +- Aura doesn't remap keys. To let users choose their own keys, offer that + in your plugin and emit their choice. +- Bad entries (an invalid keystroke, an empty action) are skipped. If two + entries use the same keys, the later one wins. If one key starts another + (`d` and `d d`), the shorter runs as soon as it's typed, so avoid that. `aura plugin run ` + prints the resolved keys and any warnings on stderr. + ### Reporting errors To show a friendly error in the panel without exiting non-zero: