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 PalhasandClaude Opus 5 d39fbdbbfc feat(maestro): drop retained worktrees and header debug toggle
Removes the retained-worktree store end to end: the daemon's state-dir
store, GET/DELETE /api/worktrees, `maestro worktrees list|reclaim`, the
TUI overlay behind `w`, and the web `#/worktrees` view and header link.

The kill guard stays. A forced kill and a dirty natural exit now leave
the worktree on disk untouched instead of parking it in a store, so
removing one is an ordinary `git worktree remove`. A pristine tree is
still removed on exit. DELETE /api/sessions/:id always answers 204.

The header's debug button also goes; Ctrl+Shift+D and `?hud` still
toggle the HUD.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 09:00:52 +01:00

308 lines
11 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"),
("s", "NewDraftPane"),
// Fast-forward spawn (#67): the draft-pane key's neighbour does the same
// thing with every default resolved and no form.
("S", "FastForwardSpawn"),
("x", "KillHighlighted"),
("r", "RenameHighlighted"),
// Focus
("ctrl-w h", "FocusSidebar"),
// Session scrollback
("pageup", "ScrollPanePageUp"),
("pagedown", "ScrollPanePageDown"),
// Chrome
("ctrl-w b", "ToggleSidebar"),
("?", "ShowHelp"),
("ctrl-w q", "Quit"),
// Mouse
("mouse:dblclick(sidebar-row)", "AttachHighlighted"),
];
/// 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 palette = match section.theme.unwrap_or(TuiTheme::Dark) {
TuiTheme::Dark => Palette::DARK,
TuiTheme::Light => Palette::LIGHT,
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::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, "s"),
Some(Action::NewDraftPane),
);
assert_eq!(
cfg.keymap.resolve(Mode::Normal, "S"),
Some(Action::FastForwardSpawn),
);
}
#[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+S
/// keypress was `"shift-S"`, which the default `"S"` 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_s_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('S'), 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));
}
}