feat(maestro-tui): TuiConfig with shipped defaults + override merge (NAP-277)
ci / rust (push) Successful in 15m8s

`TuiConfig::load(&TuiSection)` builds the runtime config:

1. Apply shipped default Normal-mode bindings (cover every v1 action).
2. Insert mode binds the leader → `LeaveInsert` (built from the leader spec
   so rebinding leader moves the Insert escape automatically).
3. Apply user overrides from `[tui.keymap.normal]` / `[tui.keymap.insert]`
   over the defaults, with the same validation path — bad TOML entries
   surface as `BadKeymap { mode, input, action, source }`.

Also adjusts the existing `MaestroConfigFile` round-trip test to include the
new `tui` field.

8 new unit tests passing (49 total for tui + config).
This commit is contained in:
2026-05-28 19:52:41 +00:00
parent 231032aa85
commit 31e1b73193
7 changed files with 392 additions and 13 deletions
+6
View File
@@ -395,6 +395,12 @@ mod tests {
daemon_url: Some("http://127.0.0.1:7070".into()),
dev_proxy_target: Some("http://127.0.0.1:5173".into()),
},
tui: TuiSection {
leader: Some("ctrl-b".into()),
chord_timeout_ms: Some(750),
keymap_normal: BTreeMap::from([("i".into(), "ShowHelp".into())]),
keymap_insert: BTreeMap::new(),
},
};
cfg.save(&path).unwrap();
assert_eq!(MaestroConfigFile::load(&path).unwrap(), cfg);
+3 -1
View File
@@ -168,7 +168,9 @@ mod tests {
#[test]
fn rejects_bad_direction_arg() {
let err = Action::parse("FocusPane(Sideways)").unwrap_err();
assert!(matches!(err, ActionParseError::BadArg { ref action, .. } if action == "FocusPane"));
assert!(
matches!(err, ActionParseError::BadArg { ref action, .. } if action == "FocusPane")
);
}
#[test]
+227
View File
@@ -0,0 +1,227 @@
//! `TuiConfig` — runtime configuration for the TUI, built from the on-disk
//! [`maestro_config::TuiSection`].
//!
//! The TUI ships with a default keymap that covers every action; entries in
//! `[tui.keymap.normal]` / `[tui.keymap.insert]` are applied **over** those
//! defaults. The shipped defaults are not contractual — they will be tuned
//! during dogfooding; users override what they don't like.
use std::time::Duration;
use maestro_config::TuiSection;
use crate::input::InputKey;
use crate::keymap::{Keymap, KeymapError, Mode};
#[derive(Debug)]
pub struct TuiConfig {
pub leader: InputKey,
pub chord_timeout: Duration,
pub keymap: Keymap,
}
#[derive(Debug, thiserror::Error)]
pub enum TuiConfigError {
#[error("invalid leader key {0:?}")]
BadLeader(String),
#[error("invalid keymap entry [{mode:?}] {input:?} = {action:?}: {source}")]
BadKeymap {
mode: Mode,
input: String,
action: String,
source: KeymapError,
},
}
/// Default Normal-mode bindings. Tunable during dogfooding — not contract.
/// Every variant of [`crate::action::Action`] reachable in v1 is covered.
const DEFAULT_NORMAL: &[(&str, &str)] = &[
// Mode
("i", "EnterInsert"),
// Catalog actions
("enter", "AttachHighlighted"),
("s", "OpenSpawnModal"),
("x", "KillHighlighted"),
// Tabs
("ctrl-t", "NewTab"),
("g t", "NextTab"),
("g T", "PrevTab"),
// Focus
("ctrl-w h", "FocusSidebar"),
// Chrome
("ctrl-w b", "ToggleSidebar"),
("?", "ShowHelp"),
("ctrl-w q", "Quit"),
// Mouse
("mouse:dblclick(sidebar-row)", "AttachHighlighted"),
("mouse:click(tab)", "NextTab"),
];
/// Default Insert-mode bindings. Empty by default — the only Insert binding
/// is the leader, which is inserted dynamically by [`TuiConfig::load`] so
/// rebinding the leader rebinds its Insert escape automatically.
const DEFAULT_INSERT: &[(&str, &str)] = &[];
impl TuiConfig {
/// Build a runtime config from a [`TuiSection`] (typically the `tui`
/// field of the loaded `MaestroConfigFile`). The shipped defaults are
/// applied first, then user overrides on top.
pub fn load(section: &TuiSection) -> Result<Self, TuiConfigError> {
let leader_str = section.leader.as_deref().unwrap_or("ctrl-a");
let leader = InputKey::parse(leader_str)
.map_err(|_| TuiConfigError::BadLeader(leader_str.to_string()))?;
let chord_timeout = Duration::from_millis(section.chord_timeout_ms.unwrap_or(1000));
let mut keymap = Keymap::default();
for (input, action) in DEFAULT_NORMAL {
keymap.set(Mode::Normal, input, action).map_err(|source| {
TuiConfigError::BadKeymap {
mode: Mode::Normal,
input: (*input).to_string(),
action: (*action).to_string(),
source,
}
})?;
}
for (input, action) in DEFAULT_INSERT {
keymap.set(Mode::Insert, input, action).map_err(|source| {
TuiConfigError::BadKeymap {
mode: Mode::Insert,
input: (*input).to_string(),
action: (*action).to_string(),
source,
}
})?;
}
// Leader → LeaveInsert is built from the leader spec so rebinding
// leader automatically rebinds the Insert escape.
keymap
.set(Mode::Insert, &leader.canonical(), "LeaveInsert")
.expect("leader canonical form is valid by construction");
for (input, action) in &section.keymap_normal {
keymap.set(Mode::Normal, input, action).map_err(|source| {
TuiConfigError::BadKeymap {
mode: Mode::Normal,
input: input.clone(),
action: action.clone(),
source,
}
})?;
}
for (input, action) in &section.keymap_insert {
keymap.set(Mode::Insert, input, action).map_err(|source| {
TuiConfigError::BadKeymap {
mode: Mode::Insert,
input: input.clone(),
action: action.clone(),
source,
}
})?;
}
Ok(TuiConfig {
leader,
chord_timeout,
keymap,
})
}
}
#[cfg(test)]
#[allow(clippy::field_reassign_with_default)]
mod tests {
use super::*;
use crate::action::Action;
#[test]
fn defaults_cover_core_actions() {
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
assert_eq!(
cfg.keymap.resolve(Mode::Normal, "i"),
Some(Action::EnterInsert),
);
assert_eq!(
cfg.keymap.resolve(Mode::Normal, "enter"),
Some(Action::AttachHighlighted),
);
assert_eq!(
cfg.keymap.resolve(Mode::Normal, "g t"),
Some(Action::NextTab),
);
}
#[test]
fn default_leader_is_ctrl_a_and_escapes_insert() {
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
assert_eq!(cfg.leader, InputKey::ctrl_char('a'));
assert_eq!(
cfg.keymap.resolve(Mode::Insert, "ctrl-a"),
Some(Action::LeaveInsert),
);
}
#[test]
fn rebinding_leader_moves_insert_escape() {
let mut section = TuiSection::default();
section.leader = Some("ctrl-b".into());
let cfg = TuiConfig::load(&section).unwrap();
assert_eq!(cfg.leader, InputKey::ctrl_char('b'));
assert_eq!(
cfg.keymap.resolve(Mode::Insert, "ctrl-b"),
Some(Action::LeaveInsert),
);
// The old leader's binding is gone (no Insert default for it).
assert_eq!(cfg.keymap.resolve(Mode::Insert, "ctrl-a"), None);
}
#[test]
fn user_normal_override_replaces_default() {
let mut section = TuiSection::default();
section.keymap_normal.insert("i".into(), "ShowHelp".into());
let cfg = TuiConfig::load(&section).unwrap();
assert_eq!(
cfg.keymap.resolve(Mode::Normal, "i"),
Some(Action::ShowHelp),
);
}
#[test]
fn user_unbind_via_noop() {
let mut section = TuiSection::default();
section.keymap_normal.insert("i".into(), "Noop".into());
let cfg = TuiConfig::load(&section).unwrap();
assert_eq!(cfg.keymap.resolve(Mode::Normal, "i"), Some(Action::Noop));
}
#[test]
fn bad_leader_surfaces_diagnostic() {
let mut section = TuiSection::default();
section.leader = Some("ctrl-glorb".into());
let err = TuiConfig::load(&section).unwrap_err();
assert!(
format!("{err}").contains("ctrl-glorb"),
"diagnostic missing offending leader: {err}"
);
}
#[test]
fn bad_user_binding_surfaces_diagnostic() {
let mut section = TuiSection::default();
section
.keymap_normal
.insert("ctrl-glorb".into(), "EnterInsert".into());
let err = TuiConfig::load(&section).unwrap_err();
let s = format!("{err}");
assert!(s.contains("ctrl-glorb"), "diagnostic: {s}");
}
#[test]
fn custom_chord_timeout_is_applied() {
let mut section = TuiSection::default();
section.chord_timeout_ms = Some(250);
let cfg = TuiConfig::load(&section).unwrap();
assert_eq!(cfg.chord_timeout, Duration::from_millis(250));
}
}
+10 -1
View File
@@ -368,7 +368,16 @@ mod tests {
#[test]
fn canonical_round_trips_specials() {
for s in ["esc", "enter", "tab", "backspace", "up", "down", "left", "right"] {
for s in [
"esc",
"enter",
"tab",
"backspace",
"up",
"down",
"left",
"right",
] {
let k = InputKey::parse(s).unwrap();
assert_eq!(k.canonical(), s, "round-trip {s:?}");
}
+5 -9
View File
@@ -133,11 +133,10 @@ mod tests {
#[test]
fn reports_bad_input_at_load() {
let mut k = Keymap::default();
let err = k.set(Mode::Normal, "ctrl-glorb", "EnterInsert").unwrap_err();
assert!(
matches!(err, KeymapError::BadInput(_)),
"got {err:?}"
);
let err = k
.set(Mode::Normal, "ctrl-glorb", "EnterInsert")
.unwrap_err();
assert!(matches!(err, KeymapError::BadInput(_)), "got {err:?}");
assert!(format!("{err}").contains("ctrl-glorb"));
}
@@ -145,10 +144,7 @@ mod tests {
fn reports_bad_action_at_load() {
let mut k = Keymap::default();
let err = k.set(Mode::Normal, "i", "Frobnicate").unwrap_err();
assert!(
matches!(err, KeymapError::BadAction(_)),
"got {err:?}"
);
assert!(matches!(err, KeymapError::BadAction(_)), "got {err:?}");
assert!(format!("{err}").contains("Frobnicate"));
}
+1
View File
@@ -10,6 +10,7 @@
pub mod action;
pub mod cli;
pub mod config;
pub mod input;
pub mod keymap;