ctrl-a is consumed as the leader on every press, so it never reaches the shell and readline's beginning-of-line was gone with no way back — unlike Ctrl-K, which already has an escape hatch. Fix it the way tmux does: pressing the leader twice sends the literal leader byte to the focused pane and clears the pending chord, on both web and TUI, built from the configured leader so a rebound leader carries it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
311 lines
11 KiB
Rust
311 lines
11 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,
|
|
/// Escape hatch (#156): leader then `ctrl-k` sends the literal `0x0B`
|
|
/// byte to the focused pane instead of opening the command bar, giving
|
|
/// readline's kill-to-end-of-line back.
|
|
SendLiteralCtrlK,
|
|
/// Escape hatch (#158): the leader pressed twice sends one literal
|
|
/// leader byte to the focused pane and drops the pending chord, the way
|
|
/// tmux resolves a doubled prefix key.
|
|
SendLiteralLeader,
|
|
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::SendLiteralCtrlK => "Send literal Ctrl-K to session".into(),
|
|
Action::SendLiteralLeader => "Send literal leader key to 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),
|
|
"SendLiteralCtrlK" => return Ok(Action::SendLiteralCtrlK),
|
|
"SendLiteralLeader" => return Ok(Action::SendLiteralLeader),
|
|
"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");
|
|
}
|
|
|
|
/// The escape hatch (#156) is an ordinary action like any other.
|
|
#[test]
|
|
fn send_literal_ctrl_k_round_trips() {
|
|
assert_eq!(
|
|
Action::parse("SendLiteralCtrlK").unwrap(),
|
|
Action::SendLiteralCtrlK
|
|
);
|
|
assert_eq!(Action::SendLiteralCtrlK.to_string(), "SendLiteralCtrlK");
|
|
assert_eq!(
|
|
Action::SendLiteralCtrlK.help_label(),
|
|
"Send literal Ctrl-K to session"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn display_round_trips() {
|
|
for a in [
|
|
Action::EnterInsert,
|
|
Action::OpenCommandBar,
|
|
Action::SendLiteralCtrlK,
|
|
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())
|
|
);
|
|
}
|
|
}
|