This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/crates/maestro-tui/src/config.rs
T
Miguel Palhas dc783778c8 feat(maestro): add the ctrl-k command bar to the TUI
Opens from Normal and Insert mode as an ordinary keymap entry, so it is
rebindable and the help overlay picks it up from the live keymap. An
empty query lists sessions most recently focused first, matching the web
bar's `orderSessionsForBar`; typing ranks through `matcher.rs` with the
matched ranges highlighted, grouped under kind headers.

Enter attaches through the same path the sidebar's attach uses, Escape
closes and leaves focus and mode where they were.
2026-09-03 16:42:21 +01:00

350 lines
13 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.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 {
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(&section).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(&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}");
}
/// 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(&section).unwrap();
assert_eq!(cfg.chord_timeout, Duration::from_millis(250));
}
}