The TUI read its leader from `[tui].leader`; the web used `DEFAULT_LEADER`, a module constant. Rebinding one left the other on `ctrl-a`, so the two escape hatches hanging off the leader (`leader ctrl-k`, `leader leader`) moved on one client and not the other. `GET /api/info` now carries `leader`. The web binds that when it arrives and rebuilds the leader-owning `CommandSet`, so the `?` overlay and the command bar render the configured chord; an absent or empty field — an older daemon, or an unreachable `/api/info` — leaves it on `ctrl-a`. Chose the existing `[tui].leader` served as-is over a new shared key: one knob, no precedence rules between two spellings, and no migration for operators who already set it. The name is a wart — it is the leader for every client now, not the TUI's — and `docs/COMMAND-BAR.md` and `docs/INSTALL.md` say so.
423 lines
16 KiB
Rust
423 lines
16 KiB
Rust
//! `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, TuiTheme};
|
|
|
|
use crate::input::InputKey;
|
|
use crate::keymap::{Keymap, KeymapError, Mode};
|
|
use crate::palette::Palette;
|
|
|
|
#[derive(Debug)]
|
|
pub struct TuiConfig {
|
|
pub palette: Palette,
|
|
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"),
|
|
// Sidebar navigation
|
|
("j", "SidebarNext"),
|
|
("k", "SidebarPrev"),
|
|
("down", "SidebarNext"),
|
|
("up", "SidebarPrev"),
|
|
("c", "ToggleSidebarGroup"),
|
|
// Catalog actions
|
|
("enter", "AttachHighlighted"),
|
|
// Tab focuses the highlighted pane, mirroring the web keybind — bare
|
|
// because Normal mode only exists while the sidebar is focused, so
|
|
// nothing forwards it to a pty.
|
|
("tab", "AttachHighlighted"),
|
|
// `n`/`N`, the same pair the web client binds.
|
|
("n", "OpenSpawnModal"),
|
|
// Fast-forward spawn (#67): the spawn key's shifted twin does the same
|
|
// thing with every default resolved and no form.
|
|
("N", "FastForwardSpawn"),
|
|
("x", "KillHighlighted"),
|
|
("r", "RenameHighlighted"),
|
|
// Focus
|
|
("ctrl-w h", "FocusSidebar"),
|
|
// Session scrollback
|
|
("pageup", "ScrollPanePageUp"),
|
|
("pagedown", "ScrollPanePageDown"),
|
|
// Chrome
|
|
// The command bar (#153). An ordinary entry, so it is rebindable and the
|
|
// help overlay picks it up from the live keymap.
|
|
("ctrl-k", "OpenCommandBar"),
|
|
("ctrl-w b", "ToggleSidebar"),
|
|
("?", "ShowHelp"),
|
|
("ctrl-w q", "Quit"),
|
|
// Bare quit, AoE-style: only reachable from the sidebar (Normal mode
|
|
// never forwards keys to a pty), same reasoning as bare `tab` above.
|
|
("q", "Quit"),
|
|
// Mouse
|
|
("mouse:dblclick(sidebar-row)", "AttachHighlighted"),
|
|
];
|
|
|
|
/// Default Insert-mode bindings — almost empty on purpose. Every key typed in
|
|
/// Insert mode belongs to the session, `esc` included: full-screen apps read
|
|
/// it constantly, so binding it here would make it unreachable. The only way
|
|
/// back to the sidebar is the leader (`LeaveInsert`), which
|
|
/// [`TuiConfig::load`] inserts dynamically so rebinding the leader rebinds
|
|
/// the Insert escape with it.
|
|
///
|
|
/// The one exception is the command bar (#153): `ctrl-k` opens it from inside
|
|
/// a pane too, so `docs/COMMAND-BAR.md`'s "from anywhere" holds. Readline's
|
|
/// kill-to-end-of-line is given back through the leader (#156).
|
|
const DEFAULT_INSERT: &[(&str, &str)] = &[("ctrl-k", "OpenCommandBar")];
|
|
|
|
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 palette = match section.theme.unwrap_or(TuiTheme::Dark) {
|
|
TuiTheme::Dark => Palette::DARK,
|
|
TuiTheme::Light => Palette::LIGHT,
|
|
TuiTheme::IceCyan => Palette::ICE_CYAN,
|
|
TuiTheme::Periwinkle => Palette::PERIWINKLE,
|
|
TuiTheme::AmberPhosphor => Palette::AMBER_PHOSPHOR,
|
|
TuiTheme::Ember => Palette::EMBER,
|
|
TuiTheme::Orchid => Palette::ORCHID,
|
|
TuiTheme::Ansi => Palette::ANSI,
|
|
TuiTheme::SolarizedDark => Palette::SOLARIZED_DARK,
|
|
TuiTheme::SolarizedLight => Palette::SOLARIZED_LIGHT,
|
|
TuiTheme::Catppuccin => Palette::CATPPUCCIN,
|
|
TuiTheme::GithubDark => Palette::GITHUB_DARK,
|
|
TuiTheme::GithubLight => Palette::GITHUB_LIGHT,
|
|
TuiTheme::TokyoNight => Palette::TOKYO_NIGHT,
|
|
TuiTheme::Gruvbox => Palette::GRUVBOX,
|
|
TuiTheme::Nord => Palette::NORD,
|
|
TuiTheme::Dracula => Palette::DRACULA,
|
|
};
|
|
let leader_str = section.leader();
|
|
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");
|
|
// Leader, ctrl-k → the literal-Ctrl-K escape hatch (#156), built from
|
|
// the same leader spec so rebinding leader rebinds this chord too.
|
|
keymap
|
|
.set(
|
|
Mode::Insert,
|
|
&format!("{} ctrl-k", leader.canonical()),
|
|
"SendLiteralCtrlK",
|
|
)
|
|
.expect("leader canonical form is valid by construction");
|
|
// Leader, leader → the literal-leader escape hatch (#158), tmux-style:
|
|
// pressing it twice sends the leader byte itself. Built from the same
|
|
// leader spec, so rebinding leader rebinds this chord too.
|
|
keymap
|
|
.set(
|
|
Mode::Insert,
|
|
&format!("{} {}", leader.canonical(), leader.canonical()),
|
|
"SendLiteralLeader",
|
|
)
|
|
.expect("leader canonical form is valid by construction");
|
|
|
|
for (input, action) in §ion.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 §ion.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 {
|
|
palette,
|
|
leader,
|
|
chord_timeout,
|
|
keymap,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[allow(clippy::field_reassign_with_default)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::action::Action;
|
|
use maestro_config::{TuiSection, TuiTheme};
|
|
|
|
#[test]
|
|
fn absent_theme_selects_the_dark_default() {
|
|
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
|
assert_eq!(cfg.palette, Palette::DARK);
|
|
}
|
|
|
|
#[test]
|
|
fn named_themes_select_the_matching_complete_palette() {
|
|
for (theme, expected) in [
|
|
(TuiTheme::Dark, Palette::DARK),
|
|
(TuiTheme::Light, Palette::LIGHT),
|
|
(TuiTheme::IceCyan, Palette::ICE_CYAN),
|
|
(TuiTheme::Periwinkle, Palette::PERIWINKLE),
|
|
(TuiTheme::AmberPhosphor, Palette::AMBER_PHOSPHOR),
|
|
(TuiTheme::Ember, Palette::EMBER),
|
|
(TuiTheme::Orchid, Palette::ORCHID),
|
|
(TuiTheme::Ansi, Palette::ANSI),
|
|
(TuiTheme::SolarizedDark, Palette::SOLARIZED_DARK),
|
|
(TuiTheme::SolarizedLight, Palette::SOLARIZED_LIGHT),
|
|
(TuiTheme::Catppuccin, Palette::CATPPUCCIN),
|
|
(TuiTheme::GithubDark, Palette::GITHUB_DARK),
|
|
(TuiTheme::GithubLight, Palette::GITHUB_LIGHT),
|
|
(TuiTheme::TokyoNight, Palette::TOKYO_NIGHT),
|
|
(TuiTheme::Gruvbox, Palette::GRUVBOX),
|
|
(TuiTheme::Nord, Palette::NORD),
|
|
(TuiTheme::Dracula, Palette::DRACULA),
|
|
] {
|
|
let section = TuiSection {
|
|
theme: Some(theme),
|
|
..Default::default()
|
|
};
|
|
assert_eq!(TuiConfig::load(§ion).unwrap().palette, expected);
|
|
}
|
|
}
|
|
|
|
#[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, "n"),
|
|
Some(Action::OpenSpawnModal),
|
|
);
|
|
assert_eq!(
|
|
cfg.keymap.resolve(Mode::Normal, "N"),
|
|
Some(Action::FastForwardSpawn),
|
|
);
|
|
}
|
|
|
|
/// AoE-style defaults (#shortcuts): `q` and `tab` are bare because Normal
|
|
/// mode only exists while the sidebar is focused, so neither can leak
|
|
/// into a pty; `esc` stays unbound in Insert so it reaches the session.
|
|
#[test]
|
|
fn aoe_style_defaults_are_bound() {
|
|
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
|
assert_eq!(cfg.keymap.resolve(Mode::Normal, "q"), Some(Action::Quit));
|
|
assert_eq!(
|
|
cfg.keymap.resolve(Mode::Normal, "tab"),
|
|
Some(Action::AttachHighlighted),
|
|
);
|
|
assert_eq!(cfg.keymap.resolve(Mode::Insert, "esc"), None);
|
|
}
|
|
|
|
#[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(§ion).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);
|
|
}
|
|
|
|
/// The literal-Ctrl-K escape hatch (#156) is chorded off the default
|
|
/// leader, `ctrl-a ctrl-k` — not the command bar.
|
|
#[test]
|
|
fn default_leader_escapes_a_literal_ctrl_k() {
|
|
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
|
assert_eq!(
|
|
cfg.keymap.resolve(Mode::Insert, "ctrl-a ctrl-k"),
|
|
Some(Action::SendLiteralCtrlK),
|
|
);
|
|
}
|
|
|
|
/// Rebinding the leader must rebind the literal-Ctrl-K chord with it, not
|
|
/// just the Insert escape — proving the chord is built from the
|
|
/// configured leader rather than a hardcoded `ctrl-a`.
|
|
#[test]
|
|
fn rebinding_leader_moves_the_literal_ctrl_k_chord() {
|
|
let mut section = TuiSection::default();
|
|
section.leader = Some("ctrl-b".into());
|
|
let cfg = TuiConfig::load(§ion).unwrap();
|
|
assert_eq!(
|
|
cfg.keymap.resolve(Mode::Insert, "ctrl-b ctrl-k"),
|
|
Some(Action::SendLiteralCtrlK),
|
|
);
|
|
// The old leader's chord is gone along with its Insert escape.
|
|
assert_eq!(cfg.keymap.resolve(Mode::Insert, "ctrl-a ctrl-k"), None);
|
|
}
|
|
|
|
/// The literal-leader escape hatch (#158) is chorded off the leader
|
|
/// doubled: `ctrl-a ctrl-a` — tmux's doubled-prefix resolution.
|
|
#[test]
|
|
fn default_leader_escapes_a_literal_leader() {
|
|
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
|
assert_eq!(
|
|
cfg.keymap.resolve(Mode::Insert, "ctrl-a ctrl-a"),
|
|
Some(Action::SendLiteralLeader),
|
|
);
|
|
}
|
|
|
|
/// Rebinding the leader must rebind the literal-leader chord with it,
|
|
/// not just the Insert escape and the Ctrl-K chord — proving it too is
|
|
/// built from the configured leader rather than a hardcoded `ctrl-a`.
|
|
#[test]
|
|
fn rebinding_leader_moves_the_literal_leader_chord() {
|
|
let mut section = TuiSection::default();
|
|
section.leader = Some("ctrl-b".into());
|
|
let cfg = TuiConfig::load(§ion).unwrap();
|
|
assert_eq!(
|
|
cfg.keymap.resolve(Mode::Insert, "ctrl-b ctrl-b"),
|
|
Some(Action::SendLiteralLeader),
|
|
);
|
|
// The old leader's chord is gone along with the rest of its bindings.
|
|
assert_eq!(cfg.keymap.resolve(Mode::Insert, "ctrl-a 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(§ion).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(§ion).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(§ion).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(§ion).unwrap_err();
|
|
let s = format!("{err}");
|
|
assert!(s.contains("ctrl-glorb"), "diagnostic: {s}");
|
|
}
|
|
|
|
/// Regression for #70: the previous canonical form of a real Shift+N
|
|
/// keypress was `"shift-N"`, which the default `"N"` binding never
|
|
/// matched. Drive the actual crossterm event, not the binding string,
|
|
/// so the dead-key bug can't come back unnoticed.
|
|
#[test]
|
|
fn real_shift_n_keypress_resolves_to_fast_forward_spawn() {
|
|
use crossterm::event::{Event as CtEvent, KeyCode, KeyEvent, KeyModifiers};
|
|
|
|
use crate::input::{canonicalise, Region};
|
|
|
|
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
|
let ev = CtEvent::Key(KeyEvent::new(KeyCode::Char('N'), KeyModifiers::SHIFT));
|
|
let key = canonicalise(&ev, |_, _| Region::Unknown).expect("key event canonicalises");
|
|
|
|
assert_eq!(
|
|
cfg.keymap.resolve(Mode::Normal, &key.canonical()),
|
|
Some(Action::FastForwardSpawn),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn custom_chord_timeout_is_applied() {
|
|
let mut section = TuiSection::default();
|
|
section.chord_timeout_ms = Some(250);
|
|
let cfg = TuiConfig::load(§ion).unwrap();
|
|
assert_eq!(cfg.chord_timeout, Duration::from_millis(250));
|
|
}
|
|
}
|