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 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

284 lines
9.8 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
/// Open the `ctrl-k` command bar (`docs/COMMAND-BAR.md`) — a query line
/// over the sessions, ranked by the shared matcher.
OpenCommandBar,
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::OpenCommandBar => "Go to a 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),
"OpenCommandBar" => return Ok(Action::OpenCommandBar),
"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"
);
}
/// The command bar is an ordinary keymap entry, so it must round-trip
/// like every other action (#153).
#[test]
fn open_command_bar_round_trips() {
assert_eq!(
Action::parse("OpenCommandBar").unwrap(),
Action::OpenCommandBar
);
assert_eq!(Action::OpenCommandBar.to_string(), "OpenCommandBar");
assert_eq!(Action::OpenCommandBar.help_label(), "Go to a session");
}
#[test]
fn display_round_trips() {
for a in [
Action::EnterInsert,
Action::OpenCommandBar,
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())
);
}
}