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/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() { 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: