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/action.rs
T
Miguel PalhasandClaude Opus 5 f89be4a716
ci / web (push) Successful in 37s
ci / rust (push) Successful in 4m52s
ci / flake (push) Successful in 7m12s
feat(maestro): spawn form back in a modal in the TUI
`s` (OpenSpawnModal, was NewDraftPane) puts the form up as a centered
overlay instead of taking over the pane. The attached session stays
attached behind it and comes back untouched on Escape; the modal owns
every key while it is up, so `x` and `j` typed into a field no longer
reach the keymap.

Tab::pane is a session id again and PaneRef is gone. The form lives on
App next to the rename buffer, so its loads no longer address a tab
index that could not identify them.

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

266 lines
9.0 KiB
Rust

//! `Action` — the closed list of verbs the TUI knows about, plus the canonical
//! string form used by the keymap config.
//!
//! Every binding in `[tui.keymap.normal]` / `[tui.keymap.insert]` resolves to
//! one of these variants. The string form is what the user types in the TOML;
//! the enum is what the dispatcher matches on.
use std::fmt;
/// Geometric direction for focus motion and resize. Used inside [`Action`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Direction {
Left,
Right,
Up,
Down,
}
/// Closed set of verbs the TUI dispatch loop understands. New verbs require a
/// matching arm in `Action::parse`, the `Display` impl, and the dispatcher.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Action {
/// No-op. Bind to a key to suppress a default binding.
Noop,
// Mode
EnterInsert,
LeaveInsert,
// Focus
FocusSidebar,
FocusPane(Direction),
// Session scrollback
ScrollPanePageUp,
ScrollPanePageDown,
// Sidebar highlight movement
SidebarNext,
SidebarPrev,
ToggleSidebarGroup,
// Catalog actions on the highlighted sidebar row
AttachHighlighted,
ToggleAttachHighlighted,
OpenSpawnModal,
/// Fast-forward spawn (#67): spawn immediately with every default
/// resolved; unresolvable defaults open a pre-filled form instead.
FastForwardSpawn,
KillHighlighted,
/// Rename the highlighted session (#125): opens a prompt pre-filled with
/// its current name.
RenameHighlighted,
// Chrome
ToggleSidebar,
ShowHelp,
Quit,
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum ActionParseError {
#[error("unknown action {0:?}")]
Unknown(String),
#[error("bad arg for {action}: {detail}")]
BadArg { action: String, detail: String },
#[error("malformed action string {0:?}")]
Malformed(String),
}
impl Action {
/// Plain-language label for the in-app help menu. Configuration keeps the
/// stable enum spelling; operators should not have to decode it.
pub fn help_label(&self) -> String {
match self {
Action::Noop => "Do nothing".into(),
Action::EnterInsert => "Send keys to session".into(),
Action::LeaveInsert => "Return to sidebar".into(),
Action::FocusSidebar => "Focus sidebar".into(),
Action::FocusPane(direction) => {
format!(
"Focus session pane ({})",
format!("{direction:?}").to_lowercase()
)
}
Action::ScrollPanePageUp => "Scroll session up one page".into(),
Action::ScrollPanePageDown => "Scroll session down one page".into(),
Action::SidebarNext => "Select next sidebar item".into(),
Action::SidebarPrev => "Select previous sidebar item".into(),
Action::ToggleSidebarGroup => "Expand or collapse directory".into(),
Action::AttachHighlighted => "Open selected item".into(),
Action::ToggleAttachHighlighted => "Open selected item".into(),
Action::OpenSpawnModal => "Create a session".into(),
Action::FastForwardSpawn => "Create session with defaults".into(),
Action::KillHighlighted => "Kill selected session".into(),
Action::RenameHighlighted => "Rename selected session".into(),
Action::ToggleSidebar => "Show or hide sidebar".into(),
Action::ShowHelp => "Show help".into(),
Action::Quit => "Quit Maestro".into(),
}
}
/// Parse a canonical action string like `"EnterInsert"` or
/// `"FocusPane(Left)"`.
pub fn parse(s: &str) -> Result<Self, ActionParseError> {
let s = s.trim();
match s {
"Noop" => return Ok(Action::Noop),
"EnterInsert" => return Ok(Action::EnterInsert),
"LeaveInsert" => return Ok(Action::LeaveInsert),
"FocusSidebar" => return Ok(Action::FocusSidebar),
"ScrollPanePageUp" => return Ok(Action::ScrollPanePageUp),
"ScrollPanePageDown" => return Ok(Action::ScrollPanePageDown),
"SidebarNext" => return Ok(Action::SidebarNext),
"SidebarPrev" => return Ok(Action::SidebarPrev),
"ToggleSidebarGroup" => return Ok(Action::ToggleSidebarGroup),
"AttachHighlighted" => return Ok(Action::AttachHighlighted),
"ToggleAttachHighlighted" => return Ok(Action::ToggleAttachHighlighted),
"OpenSpawnModal" => return Ok(Action::OpenSpawnModal),
"FastForwardSpawn" => return Ok(Action::FastForwardSpawn),
"KillHighlighted" => return Ok(Action::KillHighlighted),
"RenameHighlighted" => return Ok(Action::RenameHighlighted),
"ToggleSidebar" => return Ok(Action::ToggleSidebar),
"ShowHelp" => return Ok(Action::ShowHelp),
"Quit" => return Ok(Action::Quit),
_ => {}
}
// Arg actions: Name(arg)
let (name, rest) = s
.split_once('(')
.ok_or_else(|| ActionParseError::Unknown(s.to_string()))?;
let arg = rest
.strip_suffix(')')
.ok_or_else(|| ActionParseError::Malformed(s.to_string()))?;
match name {
"FocusPane" => {
let dir = parse_direction(arg).map_err(|d| ActionParseError::BadArg {
action: name.to_string(),
detail: d,
})?;
Ok(Action::FocusPane(dir))
}
_ => Err(ActionParseError::Unknown(name.to_string())),
}
}
}
fn parse_direction(s: &str) -> Result<Direction, String> {
match s {
"Left" => Ok(Direction::Left),
"Right" => Ok(Direction::Right),
"Up" => Ok(Direction::Up),
"Down" => Ok(Direction::Down),
other => Err(format!("expected Left/Right/Up/Down, got {other:?}")),
}
}
impl fmt::Display for Action {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Action::FocusPane(d) => write!(f, "FocusPane({d:?})"),
other => write!(f, "{other:?}"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_no_arg_actions() {
assert_eq!(Action::parse("EnterInsert").unwrap(), Action::EnterInsert);
assert_eq!(Action::parse("Quit").unwrap(), Action::Quit);
assert_eq!(Action::parse(" Noop ").unwrap(), Action::Noop);
}
#[test]
fn help_labels_are_plain_language() {
assert_eq!(Action::EnterInsert.help_label(), "Send keys to session");
assert_eq!(Action::AttachHighlighted.help_label(), "Open selected item");
assert!(!Action::OpenSpawnModal
.help_label()
.contains("OpenSpawnModal"));
}
#[test]
fn parses_focus_pane_directions() {
assert_eq!(
Action::parse("FocusPane(Left)").unwrap(),
Action::FocusPane(Direction::Left),
);
assert_eq!(
Action::parse("FocusPane(Down)").unwrap(),
Action::FocusPane(Direction::Down),
);
}
#[test]
fn rejects_unknown_action() {
let err = Action::parse("Frobnicate").unwrap_err();
assert_eq!(err, ActionParseError::Unknown("Frobnicate".into()));
}
#[test]
fn rejects_bad_direction_arg() {
let err = Action::parse("FocusPane(Sideways)").unwrap_err();
assert!(
matches!(err, ActionParseError::BadArg { ref action, .. } if action == "FocusPane")
);
}
#[test]
fn rejects_unclosed_paren() {
let err = Action::parse("FocusPane(Left").unwrap_err();
assert_eq!(err, ActionParseError::Malformed("FocusPane(Left".into()));
}
/// The fast-forward pair sits next to the spawn key (#67).
#[test]
fn fast_forward_spawn_round_trips() {
assert_eq!(
Action::parse("FastForwardSpawn").unwrap(),
Action::FastForwardSpawn
);
assert_eq!(Action::FastForwardSpawn.to_string(), "FastForwardSpawn");
}
#[test]
fn rename_action_round_trips() {
assert_eq!(
Action::parse("RenameHighlighted").unwrap(),
Action::RenameHighlighted
);
assert_eq!(Action::RenameHighlighted.to_string(), "RenameHighlighted");
assert_eq!(
Action::RenameHighlighted.help_label(),
"Rename selected session"
);
}
#[test]
fn display_round_trips() {
for a in [
Action::EnterInsert,
Action::RenameHighlighted,
Action::Quit,
Action::OpenSpawnModal,
Action::ScrollPanePageDown,
Action::FocusPane(Direction::Right),
] {
let s = a.to_string();
assert_eq!(Action::parse(&s).unwrap(), a, "round-trip for {a:?}");
}
}
/// Draft panes are gone; their old action string must not resolve.
#[test]
fn the_removed_draft_pane_action_no_longer_parses() {
assert_eq!(
Action::parse("NewDraftPane").unwrap_err(),
ActionParseError::Unknown("NewDraftPane".into())
);
}
}