Implement TUI init insert and generate workflows

This commit is contained in:
Hermes Agent
2026-08-10 09:22:55 +00:00
parent 977eb236da
commit 6ea184cbc1
8 changed files with 1213 additions and 32 deletions

View File

@@ -21,6 +21,7 @@ use crate::{
editor::EntryEditor,
sidebar::{Sidebar, SidebarIntent},
viewer::EntryViewer,
workflow::{WorkflowForm, WorkflowInput, WorkflowSubmission},
};
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
@@ -64,6 +65,15 @@ pub struct StartupData {
pub config: Config,
pub tree: TreeModel,
pub key: KeyInfo,
pub keys: Vec<KeyInfo>,
}
#[derive(Debug)]
pub struct WorkflowSuccess {
pub tree: Option<TreeModel>,
pub entry: Option<String>,
pub document: Option<Box<EntryDocument>>,
pub status: String,
}
#[derive(Debug)]
@@ -88,6 +98,7 @@ pub enum AsyncPayload {
editor: Box<EntryEditor>,
result: Result<WriteOutcome, EditorSaveFailure>,
},
WorkflowFinished(Result<Box<WorkflowSuccess>, String>),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -141,6 +152,7 @@ pub enum AppEffect {
entry: String,
editor: Box<EntryEditor>,
},
AuthenticateWorkflow(Box<WorkflowSubmission>),
OpenWorkflow(WorkflowAction),
RunCommand(CommandRequest),
ManualLock,
@@ -160,6 +172,7 @@ pub struct App {
status: String,
config: Option<Config>,
default_key: Option<KeyInfo>,
available_keys: Vec<KeyInfo>,
sidebar: Sidebar,
selected_entry: Option<String>,
viewer: Option<EntryViewer>,
@@ -172,6 +185,8 @@ pub struct App {
command_open_target: Option<CommandOpenTarget>,
editor_generation_pending: Option<EntryFieldId>,
authentication_pending: Option<String>,
workflow: Option<WorkflowForm>,
workflow_pending: bool,
remaining_lease: Option<std::time::Duration>,
terminal_size: (u16, u16),
ticks: u64,
@@ -196,6 +211,7 @@ impl App {
status: "Starting…".to_owned(),
config: None,
default_key: None,
available_keys: Vec::new(),
sidebar: Sidebar::default(),
selected_entry: None,
viewer: None,
@@ -208,6 +224,8 @@ impl App {
command_open_target: None,
editor_generation_pending: None,
authentication_pending: None,
workflow: None,
workflow_pending: false,
remaining_lease: None,
terminal_size: (0, 0),
ticks: 0,
@@ -291,7 +309,11 @@ impl App {
}
pub fn authentication_pending(&self) -> bool {
self.authentication_pending.is_some()
self.authentication_pending.is_some() || self.workflow_pending
}
pub fn workflow(&self) -> Option<&WorkflowForm> {
self.workflow.as_ref()
}
pub fn remaining_lease(&self) -> Option<std::time::Duration> {
@@ -354,6 +376,7 @@ impl App {
self.status = format!("Vault: {}", startup.config.vault().display());
self.sidebar.replace_tree(&startup.tree);
self.default_key = Some(startup.key);
self.available_keys = startup.keys;
self.config = Some(startup.config);
}
Ok(AsyncPayload::Refreshed(tree)) => {
@@ -459,6 +482,35 @@ impl App {
},
}
}
Ok(AsyncPayload::WorkflowFinished(result)) => {
self.workflow_pending = false;
match result {
Ok(success) => {
if let Some(tree) = success.tree.as_ref() {
self.sidebar.replace_tree(tree);
}
self.workflow = None;
self.suspended_mode = None;
if let (Some(entry), Some(document)) = (success.entry, success.document) {
self.selected_entry = Some(entry);
self.viewer = Some(EntryViewer::new(*document));
self.editor = None;
self.mode = Mode::Viewer;
self.focus = PaneFocus::Main;
} else {
self.selected_entry = None;
self.viewer = None;
self.editor = None;
self.mode = Mode::Browser;
self.focus = PaneFocus::Sidebar;
}
self.status = success.status;
}
Err(error) => {
self.status = format!("Workflow failed: {error}. Form retained.");
}
}
}
Err(error) => {
self.status = error;
self.editor_generation_pending = None;
@@ -510,6 +562,8 @@ impl App {
self.keep_editing();
} else if self.mode == Mode::Dialog && self.command_confirmation.is_some() {
self.cancel_command_confirmation();
} else if self.mode == Mode::Dialog && self.workflow.is_some() {
self.cancel_workflow();
} else {
if self.mode == Mode::Command {
self.command_line.clear();
@@ -706,6 +760,15 @@ impl App {
| Action::GitPull
| Action::GitPush => {
let workflow = action.workflow().expect("matched workflow action");
if matches!(
workflow,
WorkflowAction::Initialize
| WorkflowAction::InsertEntry
| WorkflowAction::GenerateEntry
) {
self.open_workflow(workflow, None);
return AppEffect::None;
}
self.status = format!("Selected {} workflow", workflow_label(workflow));
return AppEffect::OpenWorkflow(workflow);
}
@@ -723,6 +786,42 @@ impl App {
.is_some_and(EntryEditor::is_input_active)
}
pub fn handle_workflow_input(
&mut self,
code: crossterm::event::KeyCode,
modifiers: crossterm::event::KeyModifiers,
) -> Option<AppEffect> {
if self.mode != Mode::Dialog || self.workflow.is_none() {
return None;
}
if self.workflow_pending {
if code == crossterm::event::KeyCode::Esc {
self.status = "Wait for the active workflow to finish".to_owned();
}
return Some(AppEffect::None);
}
let input = self.workflow.as_mut()?.handle_key(code, modifiers);
match input {
WorkflowInput::Consumed => Some(AppEffect::None),
WorkflowInput::Cancel => {
self.cancel_workflow();
Some(AppEffect::None)
}
WorkflowInput::Lock => Some(self.dispatch(Action::Lock)),
WorkflowInput::Submit => match self.workflow.as_ref()?.submission() {
Ok(submission) => {
self.workflow_pending = true;
self.status = "Authenticating before applying the workflow…".to_owned();
Some(AppEffect::AuthenticateWorkflow(Box::new(submission)))
}
Err(error) => {
self.status = format!("Validation: {error}");
Some(AppEffect::None)
}
},
}
}
pub fn handle_command_input(
&mut self,
code: crossterm::event::KeyCode,
@@ -893,6 +992,19 @@ impl App {
self.status = message;
AppEffect::None
}
CommandInvocation::Storage(request @ CommandRequest::Init(_))
| CommandInvocation::Storage(request @ CommandRequest::Insert(_))
| CommandInvocation::Storage(request @ CommandRequest::Generate(_)) => {
self.transition(Transition::Dismiss);
let workflow = match &request {
CommandRequest::Init(_) => WorkflowAction::Initialize,
CommandRequest::Insert(_) => WorkflowAction::InsertEntry,
CommandRequest::Generate(_) => WorkflowAction::GenerateEntry,
_ => unreachable!(),
};
self.open_workflow(workflow, Some(request));
AppEffect::None
}
CommandInvocation::Storage(request) => {
let operation = operation_name(&request);
self.transition(Transition::Dismiss);
@@ -929,6 +1041,53 @@ impl App {
}
}
fn open_workflow(&mut self, workflow: WorkflowAction, request: Option<CommandRequest>) {
let form = match (workflow, request) {
(WorkflowAction::Initialize, Some(CommandRequest::Init(request))) => {
WorkflowForm::init(
&self.available_keys,
self.default_key.as_ref(),
Some(request),
)
}
(WorkflowAction::Initialize, None) => {
WorkflowForm::init(&self.available_keys, self.default_key.as_ref(), None)
}
(WorkflowAction::InsertEntry, Some(CommandRequest::Insert(request))) => {
WorkflowForm::insert(Some(request))
}
(WorkflowAction::InsertEntry, None) => WorkflowForm::insert(None),
(WorkflowAction::GenerateEntry, Some(CommandRequest::Generate(request))) => {
WorkflowForm::generate(Some(request))
}
(WorkflowAction::GenerateEntry, None) => WorkflowForm::generate(None),
_ => return,
};
self.workflow = Some(form);
self.workflow_pending = false;
self.transition(Transition::OpenDialog);
self.status = "Tab moves, Space toggles, Ctrl-S submits, Esc cancels".to_owned();
}
fn cancel_workflow(&mut self) {
if self.workflow_pending {
self.status = "Wait for the active workflow to finish".to_owned();
return;
}
self.workflow = None;
self.transition(Transition::Dismiss);
self.status = "Workflow cancelled; password store unchanged".to_owned();
}
pub fn workflow_authentication_granted(&mut self) -> bool {
self.mode == Mode::Dialog && self.workflow.is_some() && self.workflow_pending
}
pub fn workflow_authentication_failed(&mut self, message: String) {
self.workflow_pending = false;
self.status = format!("Authentication failed: {message}. Form retained.");
}
fn confirm_command(&mut self) -> Option<CommandRequest> {
if self.mode != Mode::Dialog {
return None;
@@ -1235,6 +1394,8 @@ impl App {
self.command_help = None;
self.command_open_target = None;
self.editor_generation_pending = None;
self.workflow = None;
self.workflow_pending = false;
self.status = "Locked".to_owned();
} else if current == Mode::Locked {
self.status = "Authentication required".to_owned();
@@ -1498,11 +1659,10 @@ mod tests {
#[test]
fn direct_workflow_keys_dispatch_typed_ui_effects_without_domain_work() {
let mut app = App::new();
assert!(matches!(
app.dispatch(Action::InsertEntry),
AppEffect::OpenWorkflow(WorkflowAction::InsertEntry)
));
assert!(app.status().contains("entry insertion"));
assert!(matches!(app.dispatch(Action::InsertEntry), AppEffect::None));
assert_eq!(app.mode(), Mode::Dialog);
assert!(app.workflow().is_some());
app.dispatch(Action::Cancel);
app.open_test_document("email/personal", fixture_document("email/personal"));
assert!(matches!(
app.dispatch(Action::RemoveEntry),
@@ -1511,6 +1671,34 @@ mod tests {
assert!(app.status().contains("entry removal"));
}
#[test]
fn colon_write_commands_open_the_same_secure_forms_with_typed_options() {
let mut app = App::new();
assert!(matches!(
enter_command(&mut app, "insert --multiline --force nested/note"),
AppEffect::None
));
assert_eq!(app.mode(), Mode::Dialog);
let rows = app.workflow().expect("insert form").rows().join("\n");
assert!(rows.contains("nested/note"));
assert!(rows.contains("multiline"));
assert!(rows.contains("Overwrite: yes"));
app.handle_workflow_input(
crossterm::event::KeyCode::Esc,
crossterm::event::KeyModifiers::NONE,
);
assert_eq!(app.mode(), Mode::Browser);
assert!(matches!(
enter_command(&mut app, "generate --no-symbols --in-place nested/note 31"),
AppEffect::None
));
let rows = app.workflow().expect("generate form").rows().join("\n");
assert!(rows.contains("Length: 31"));
assert!(rows.contains("Symbols: no"));
assert!(rows.contains("In place: yes"));
}
#[test]
fn colon_show_and_edit_route_to_authenticated_tui_panes() {
let mut app = App::new();