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

341
apps/tui/src/app.rs Normal file
View File

@@ -0,0 +1,341 @@
//! Pure UI state machine. Storage behavior is represented only by typed results.
use std::collections::BTreeSet;
use crate::action::Action;
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub enum Mode {
Browser,
Viewer,
Editor,
Dialog,
Help,
Command,
Locked,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Transition {
OpenEntry,
EditEntry,
CloseEntry,
OpenDialog,
OpenHelp,
OpenCommand,
Dismiss,
Lock,
Unlock,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PaneFocus {
Sidebar,
Main,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct RequestToken {
id: u64,
generation: u64,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct StartupSummary {
pub configuration: String,
pub vault: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum AsyncPayload {
Startup(StartupSummary),
Refreshed,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AsyncResult {
pub token: RequestToken,
pub payload: Result<AsyncPayload, String>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ResultDisposition {
Applied,
Stale,
}
#[derive(Debug)]
pub struct App {
mode: Mode,
suspended_mode: Option<Mode>,
focus: PaneFocus,
status: String,
startup: Option<StartupSummary>,
terminal_size: (u16, u16),
ticks: u64,
should_quit: bool,
generation: u64,
next_request_id: u64,
pending: BTreeSet<u64>,
}
impl Default for App {
fn default() -> Self {
Self::new()
}
}
impl App {
pub fn new() -> Self {
Self {
mode: Mode::Browser,
suspended_mode: None,
focus: PaneFocus::Sidebar,
status: "Starting…".to_owned(),
startup: None,
terminal_size: (0, 0),
ticks: 0,
should_quit: false,
generation: 0,
next_request_id: 0,
pending: BTreeSet::new(),
}
}
pub fn mode(&self) -> Mode {
self.mode
}
pub fn focus(&self) -> PaneFocus {
self.focus
}
pub fn status(&self) -> &str {
&self.status
}
pub fn startup(&self) -> Option<&StartupSummary> {
self.startup.as_ref()
}
pub fn terminal_size(&self) -> (u16, u16) {
self.terminal_size
}
pub fn ticks(&self) -> u64 {
self.ticks
}
pub fn is_busy(&self) -> bool {
!self.pending.is_empty()
}
pub fn should_quit(&self) -> bool {
self.should_quit
}
pub fn resize(&mut self, width: u16, height: u16) {
self.terminal_size = (width, height);
}
pub fn tick(&mut self) {
self.ticks = self.ticks.wrapping_add(1);
}
pub fn begin_request(&mut self) -> RequestToken {
let token = RequestToken {
id: self.next_request_id,
generation: self.generation,
};
self.next_request_id = self.next_request_id.wrapping_add(1);
self.pending.insert(token.id);
token
}
pub fn apply_result(&mut self, result: AsyncResult) -> ResultDisposition {
if result.token.generation != self.generation || !self.pending.remove(&result.token.id) {
return ResultDisposition::Stale;
}
match result.payload {
Ok(AsyncPayload::Startup(summary)) => {
self.status = format!("Vault: {}", summary.vault);
self.startup = Some(summary);
}
Ok(AsyncPayload::Refreshed) => self.status = "Password store refreshed".to_owned(),
Err(error) => self.status = error,
}
ResultDisposition::Applied
}
pub fn dispatch(&mut self, action: Action) {
match action {
Action::Quit if matches!(self.mode, Mode::Browser | Mode::Viewer) => {
self.should_quit = true;
}
Action::Help => {
self.transition(Transition::OpenHelp);
}
Action::Command => {
self.transition(Transition::OpenCommand);
}
Action::Cancel => {
self.transition(Transition::Dismiss);
}
Action::Lock => {
self.transition(Transition::Lock);
}
Action::FocusNext | Action::FocusPrevious => {
self.focus = match self.focus {
PaneFocus::Sidebar => PaneFocus::Main,
PaneFocus::Main => PaneFocus::Sidebar,
};
}
Action::Quit => {}
}
}
pub fn transition(&mut self, transition: Transition) -> bool {
let current = self.mode;
let destination = match (current, transition) {
(Mode::Browser, Transition::OpenEntry) => Some(Mode::Viewer),
(Mode::Viewer, Transition::EditEntry) => Some(Mode::Editor),
(Mode::Viewer | Mode::Editor, Transition::CloseEntry) => Some(Mode::Browser),
(Mode::Browser | Mode::Viewer | Mode::Editor, Transition::OpenDialog) => {
Some(Mode::Dialog)
}
(Mode::Browser | Mode::Viewer | Mode::Editor, Transition::OpenHelp) => Some(Mode::Help),
(Mode::Browser | Mode::Viewer | Mode::Editor, Transition::OpenCommand) => {
Some(Mode::Command)
}
(Mode::Dialog | Mode::Help | Mode::Command, Transition::Dismiss) => {
self.suspended_mode.take()
}
(Mode::Browser | Mode::Viewer | Mode::Editor, Transition::Lock) => Some(Mode::Locked),
(Mode::Locked, Transition::Unlock) => Some(Mode::Browser),
_ => None,
};
let Some(destination) = destination else {
return false;
};
if matches!(destination, Mode::Dialog | Mode::Help | Mode::Command) {
self.suspended_mode = Some(current);
} else if destination == Mode::Locked {
self.suspended_mode = None;
self.focus = PaneFocus::Sidebar;
self.invalidate_requests();
self.startup = None;
self.status = "Locked".to_owned();
} else if current == Mode::Locked {
self.status = "Authentication required".to_owned();
}
self.mode = destination;
true
}
fn invalidate_requests(&mut self) {
self.generation = self.generation.wrapping_add(1);
self.pending.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn success(token: RequestToken, payload: AsyncPayload) -> AsyncResult {
AsyncResult {
token,
payload: Ok(payload),
}
}
#[test]
fn every_legal_transition_reaches_its_destination() {
let cases = [
(Mode::Browser, Transition::OpenEntry, Mode::Viewer),
(Mode::Viewer, Transition::EditEntry, Mode::Editor),
(Mode::Viewer, Transition::CloseEntry, Mode::Browser),
(Mode::Editor, Transition::CloseEntry, Mode::Browser),
(Mode::Locked, Transition::Unlock, Mode::Browser),
];
for (start, transition, expected) in cases {
let mut app = App::new();
app.mode = start;
assert!(app.transition(transition));
assert_eq!(app.mode(), expected);
}
for start in [Mode::Browser, Mode::Viewer, Mode::Editor] {
for (transition, overlay) in [
(Transition::OpenDialog, Mode::Dialog),
(Transition::OpenHelp, Mode::Help),
(Transition::OpenCommand, Mode::Command),
] {
let mut app = App::new();
app.mode = start;
assert!(app.transition(transition));
assert_eq!(app.mode(), overlay);
assert!(app.transition(Transition::Dismiss));
assert_eq!(app.mode(), start);
}
let mut app = App::new();
app.mode = start;
assert!(app.transition(Transition::Lock));
assert_eq!(app.mode(), Mode::Locked);
}
}
#[test]
fn illegal_transitions_do_not_change_state() {
let mut app = App::new();
assert!(!app.transition(Transition::EditEntry));
assert_eq!(app.mode(), Mode::Browser);
assert!(!app.transition(Transition::Dismiss));
assert_eq!(app.mode(), Mode::Browser);
}
#[test]
fn stale_and_duplicate_async_results_are_rejected() {
let mut app = App::new();
let token = app.begin_request();
let result = success(token, AsyncPayload::Refreshed);
assert_eq!(app.apply_result(result.clone()), ResultDisposition::Applied);
assert_eq!(app.apply_result(result), ResultDisposition::Stale);
let stale = app.begin_request();
assert!(app.transition(Transition::Lock));
assert_eq!(
app.apply_result(success(stale, AsyncPayload::Refreshed)),
ResultDisposition::Stale
);
assert_eq!(app.status(), "Locked");
}
#[test]
fn storage_results_are_applied_without_domain_inference() {
let mut app = App::new();
let token = app.begin_request();
let summary = StartupSummary {
configuration: "/config.toml".to_owned(),
vault: "/vault".to_owned(),
};
assert_eq!(
app.apply_result(success(token, AsyncPayload::Startup(summary.clone()))),
ResultDisposition::Applied
);
assert_eq!(app.startup(), Some(&summary));
assert_eq!(app.status(), "Vault: /vault");
}
#[test]
fn ticks_resize_and_focus_are_independent_ui_state() {
let mut app = App::new();
app.resize(100, 30);
app.tick();
app.dispatch(Action::FocusNext);
assert_eq!(app.terminal_size(), (100, 30));
assert_eq!(app.ticks(), 1);
assert_eq!(app.focus(), PaneFocus::Main);
}
}