Build the Mutt-style TUI shell and action model

This commit is contained in:
Hermes Agent
2026-08-10 05:46:32 +00:00
parent f03fdc063c
commit 42e6a82b2d
7 changed files with 1051 additions and 42 deletions

185
apps/tui/src/action.rs Normal file
View File

@@ -0,0 +1,185 @@
//! Central action metadata shared by dispatch, contextual help, and command mode.
use crossterm::event::{KeyCode, KeyModifiers};
use crate::app::Mode;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Action {
Quit,
Help,
Command,
Cancel,
Lock,
FocusNext,
FocusPrevious,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct KeyBinding {
pub code: KeyCode,
pub modifiers: KeyModifiers,
pub display: &'static str,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ActionSpec {
pub action: Action,
pub label: &'static str,
pub command: &'static str,
pub binding: KeyBinding,
modes: &'static [Mode],
}
impl ActionSpec {
pub fn is_available(self, mode: Mode) -> bool {
self.modes.contains(&mode)
}
}
const BROWSER_LIKE: &[Mode] = &[Mode::Browser, Mode::Viewer];
const UNLOCKED: &[Mode] = &[Mode::Browser, Mode::Viewer, Mode::Editor];
const OVERLAYS: &[Mode] = &[Mode::Help, Mode::Command, Mode::Dialog];
#[cfg(test)]
const ALL: &[Mode] = &[
Mode::Browser,
Mode::Viewer,
Mode::Editor,
Mode::Dialog,
Mode::Help,
Mode::Command,
Mode::Locked,
];
pub static ACTIONS: &[ActionSpec] = &[
ActionSpec {
action: Action::Quit,
label: "quit",
command: "quit",
binding: KeyBinding {
code: KeyCode::Char('q'),
modifiers: KeyModifiers::NONE,
display: "q",
},
modes: BROWSER_LIKE,
},
ActionSpec {
action: Action::Help,
label: "help",
command: "help",
binding: KeyBinding {
code: KeyCode::Char('?'),
modifiers: KeyModifiers::NONE,
display: "?",
},
modes: UNLOCKED,
},
ActionSpec {
action: Action::Command,
label: "command",
command: "command",
binding: KeyBinding {
code: KeyCode::Char(':'),
modifiers: KeyModifiers::NONE,
display: ":",
},
modes: UNLOCKED,
},
ActionSpec {
action: Action::Cancel,
label: "back",
command: "cancel",
binding: KeyBinding {
code: KeyCode::Esc,
modifiers: KeyModifiers::NONE,
display: "Esc",
},
modes: OVERLAYS,
},
ActionSpec {
action: Action::Lock,
label: "lock",
command: "lock",
binding: KeyBinding {
code: KeyCode::Char('l'),
modifiers: KeyModifiers::CONTROL,
display: "C-l",
},
modes: UNLOCKED,
},
ActionSpec {
action: Action::FocusNext,
label: "next pane",
command: "focus-next",
binding: KeyBinding {
code: KeyCode::Tab,
modifiers: KeyModifiers::NONE,
display: "Tab",
},
modes: UNLOCKED,
},
ActionSpec {
action: Action::FocusPrevious,
label: "previous pane",
command: "focus-previous",
binding: KeyBinding {
code: KeyCode::BackTab,
modifiers: KeyModifiers::SHIFT,
display: "S-Tab",
},
modes: UNLOCKED,
},
];
pub fn resolve_key(mode: Mode, code: KeyCode, modifiers: KeyModifiers) -> Option<Action> {
ACTIONS
.iter()
.find(|spec| {
spec.is_available(mode)
&& spec.binding.code == code
&& spec.binding.modifiers == modifiers
})
.map(|spec| spec.action)
}
pub fn available_actions(mode: Mode) -> impl Iterator<Item = &'static ActionSpec> {
ACTIONS.iter().filter(move |spec| spec.is_available(mode))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_has_unique_commands_and_bindings_per_mode() {
for (index, action) in ACTIONS.iter().enumerate() {
assert!(!action.command.is_empty());
assert!(!action.label.is_empty());
assert!(!action.modes.is_empty());
for other in &ACTIONS[index + 1..] {
assert_ne!(action.command, other.command);
for mode in ALL {
if action.is_available(*mode) && other.is_available(*mode) {
assert_ne!(action.binding, other.binding);
}
}
}
}
}
#[test]
fn resolution_and_help_consume_the_same_registry() {
for mode in ALL {
for spec in available_actions(*mode) {
assert_eq!(
resolve_key(*mode, spec.binding.code, spec.binding.modifiers),
Some(spec.action)
);
}
}
assert_eq!(
resolve_key(Mode::Editor, KeyCode::Char('q'), KeyModifiers::NONE),
None
);
}
}