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

570
apps/tui/src/workflow.rs Normal file
View File

@@ -0,0 +1,570 @@
//! Keyboard-only presentation state for storage-owned write workflows.
use std::{fmt, num::NonZeroUsize};
use crossterm::event::{KeyCode, KeyModifiers};
use ironstorage::{
command::{GenerateRequest, GeneratedPresentation, InitRequest, InsertInput, InsertRequest},
crypto::KeyInfo,
write::{InsertContent, OverwriteDecision},
};
use zeroize::Zeroize;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum InsertMode {
Hidden,
Echoed,
Multiline,
}
impl InsertMode {
fn next(self) -> Self {
match self {
Self::Hidden => Self::Echoed,
Self::Echoed => Self::Multiline,
Self::Multiline => Self::Hidden,
}
}
pub fn label(self) -> &'static str {
match self {
Self::Hidden => "single line (confirmed)",
Self::Echoed => "single line",
Self::Multiline => "multiline",
}
}
}
#[derive(Default)]
struct SecretText {
characters: Vec<char>,
}
impl SecretText {
fn push(&mut self, character: char) {
self.characters.push(character);
}
fn pop(&mut self) {
self.characters.pop();
}
fn bytes(&self) -> Vec<u8> {
self.characters.iter().collect::<String>().into_bytes()
}
fn is_empty(&self) -> bool {
self.characters.is_empty()
}
}
impl Drop for SecretText {
fn drop(&mut self) {
self.characters.zeroize();
}
}
impl fmt::Debug for SecretText {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("SecretText([REDACTED])")
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct RecipientChoice {
identity: String,
label: String,
selected: bool,
}
#[derive(Debug)]
pub struct InitForm {
path: String,
recipients: Vec<RecipientChoice>,
focus: usize,
}
#[derive(Debug)]
pub struct InsertForm {
entry: String,
mode: InsertMode,
secret: SecretText,
confirmation: SecretText,
overwrite: bool,
focus: usize,
}
#[derive(Debug)]
pub struct GenerateForm {
entry: String,
length: String,
no_symbols: bool,
overwrite: bool,
in_place: bool,
focus: usize,
}
#[derive(Debug)]
pub enum WorkflowForm {
Init(InitForm),
Insert(InsertForm),
Generate(GenerateForm),
}
#[derive(Debug)]
pub enum WorkflowSubmission {
Init(InitRequest),
Insert {
request: InsertRequest,
contents: InsertContent,
overwrite: OverwriteDecision,
},
Generate {
request: GenerateRequest,
overwrite: OverwriteDecision,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum WorkflowInput {
Consumed,
Cancel,
Lock,
Submit,
}
impl WorkflowForm {
pub fn init(keys: &[KeyInfo], default: Option<&KeyInfo>, request: Option<InitRequest>) -> Self {
let request = request.unwrap_or_else(|| InitRequest {
path: None,
key_identities: default
.map(|key| vec![key.fingerprint().to_string()])
.unwrap_or_default(),
});
let mut recipients = keys
.iter()
.filter(|key| key.can_encrypt())
.map(|key| {
let identity = key.fingerprint().to_string();
let user = key
.user_ids()
.first()
.map_or("unknown identity", String::as_str);
RecipientChoice {
selected: request.key_identities.iter().any(|item| item == &identity),
label: format!("{} — {user}", key.key_id()),
identity,
}
})
.collect::<Vec<_>>();
for identity in &request.key_identities {
if !recipients.iter().any(|choice| &choice.identity == identity) {
recipients.push(RecipientChoice {
identity: identity.clone(),
label: format!("requested identity {identity}"),
selected: true,
});
}
}
Self::Init(InitForm {
path: request.path.unwrap_or_default(),
recipients,
focus: 0,
})
}
pub fn insert(request: Option<InsertRequest>) -> Self {
let request = request.unwrap_or(InsertRequest {
entry: String::new(),
input: InsertInput::HiddenConfirmed,
force: false,
});
let mode = match request.input {
InsertInput::HiddenConfirmed => InsertMode::Hidden,
InsertInput::EchoedLine => InsertMode::Echoed,
InsertInput::Multiline => InsertMode::Multiline,
};
Self::Insert(InsertForm {
entry: request.entry,
mode,
secret: SecretText::default(),
confirmation: SecretText::default(),
overwrite: request.force,
focus: 0,
})
}
pub fn generate(request: Option<GenerateRequest>) -> Self {
let request = request.unwrap_or(GenerateRequest {
entry: String::new(),
length: None,
no_symbols: false,
force: false,
in_place: false,
presentation: GeneratedPresentation::Terminal,
});
Self::Generate(GenerateForm {
entry: request.entry,
length: request
.length
.map_or_else(String::new, |length| length.to_string()),
no_symbols: request.no_symbols,
overwrite: request.force,
in_place: request.in_place,
focus: 0,
})
}
pub fn title(&self) -> &'static str {
match self {
Self::Init(_) => "Initialize recipients",
Self::Insert(_) => "Insert entry",
Self::Generate(_) => "Generate password",
}
}
pub fn rows(&self) -> Vec<String> {
match self {
Self::Init(form) => {
let mut rows = vec![row(
form.focus == 0,
"Path",
if form.path.is_empty() {
"/"
} else {
&form.path
},
)];
rows.push("Recipients (Space toggles):".to_owned());
rows.extend(form.recipients.iter().enumerate().map(|(index, choice)| {
row(
form.focus == index + 1,
if choice.selected { "[x]" } else { "[ ]" },
&choice.label,
)
}));
rows
}
Self::Insert(form) => vec![
row(form.focus == 0, "Entry", &form.entry),
row(form.focus == 1, "Input", form.mode.label()),
row(
form.focus == 2,
"Secret",
if form.secret.is_empty() {
"(empty)"
} else {
"••••••••"
},
),
row(
form.focus == 3,
"Confirm",
if form.mode != InsertMode::Hidden {
"(not required)"
} else if form.confirmation.is_empty() {
"(empty)"
} else {
"••••••••"
},
),
row(form.focus == 4, "Overwrite", yes_no(form.overwrite)),
],
Self::Generate(form) => vec![
row(form.focus == 0, "Entry", &form.entry),
row(
form.focus == 1,
"Length",
if form.length.is_empty() {
"pass default"
} else {
&form.length
},
),
row(form.focus == 2, "Symbols", yes_no(!form.no_symbols)),
row(form.focus == 3, "Overwrite", yes_no(form.overwrite)),
row(form.focus == 4, "In place", yes_no(form.in_place)),
],
}
}
pub fn handle_key(&mut self, code: KeyCode, modifiers: KeyModifiers) -> WorkflowInput {
if modifiers.contains(KeyModifiers::CONTROL) {
return match code {
KeyCode::Char('s') => WorkflowInput::Submit,
KeyCode::Char('l' | 'z') => WorkflowInput::Lock,
_ => WorkflowInput::Consumed,
};
}
match code {
KeyCode::Esc => return WorkflowInput::Cancel,
KeyCode::Tab | KeyCode::Down => self.move_focus(true),
KeyCode::BackTab | KeyCode::Up => self.move_focus(false),
KeyCode::Char(' ') => self.space(),
KeyCode::Enter => self.enter(),
KeyCode::Backspace => self.backspace(),
KeyCode::Char(character) if !character.is_control() => self.character(character),
_ => {}
}
WorkflowInput::Consumed
}
pub fn submission(&self) -> Result<WorkflowSubmission, String> {
match self {
Self::Init(form) => {
let key_identities = form
.recipients
.iter()
.filter(|choice| choice.selected)
.map(|choice| choice.identity.clone())
.collect::<Vec<_>>();
if key_identities.is_empty() {
return Err("Select at least one encryption recipient".to_owned());
}
Ok(WorkflowSubmission::Init(InitRequest {
path: (!form.path.trim().is_empty()).then(|| form.path.trim().to_owned()),
key_identities,
}))
}
Self::Insert(form) => {
if form.entry.trim().is_empty() {
return Err("Entry path is required".to_owned());
}
let (input, contents) = match form.mode {
InsertMode::Hidden => (
InsertInput::HiddenConfirmed,
InsertContent::hidden(form.secret.bytes(), form.confirmation.bytes()),
),
InsertMode::Echoed => (
InsertInput::EchoedLine,
InsertContent::echoed(form.secret.bytes()),
),
InsertMode::Multiline => (
InsertInput::Multiline,
Ok(InsertContent::multiline(form.secret.bytes())),
),
};
Ok(WorkflowSubmission::Insert {
request: InsertRequest {
entry: form.entry.trim().to_owned(),
input,
force: form.overwrite,
},
contents: contents.map_err(|error| error.to_string())?,
overwrite: if form.overwrite {
OverwriteDecision::Allow
} else {
OverwriteDecision::Decline
},
})
}
Self::Generate(form) => {
if form.entry.trim().is_empty() {
return Err("Entry path is required".to_owned());
}
let length = if form.length.is_empty() {
None
} else {
Some(
form.length
.parse::<NonZeroUsize>()
.map_err(|_| "Length must be a positive integer".to_owned())?,
)
};
Ok(WorkflowSubmission::Generate {
request: GenerateRequest {
entry: form.entry.trim().to_owned(),
length,
no_symbols: form.no_symbols,
force: form.overwrite,
in_place: form.in_place,
presentation: GeneratedPresentation::Terminal,
},
overwrite: if form.overwrite || form.in_place {
OverwriteDecision::Allow
} else {
OverwriteDecision::Decline
},
})
}
}
}
fn focus_count(&self) -> usize {
match self {
Self::Init(form) => form.recipients.len() + 1,
Self::Insert(_) | Self::Generate(_) => 5,
}
}
fn move_focus(&mut self, forward: bool) {
let count = self.focus_count();
let focus = match self {
Self::Init(form) => &mut form.focus,
Self::Insert(form) => &mut form.focus,
Self::Generate(form) => &mut form.focus,
};
*focus = if forward {
(*focus + 1) % count
} else {
(*focus + count - 1) % count
};
}
fn space(&mut self) {
match self {
Self::Init(form) if form.focus > 0 => form.recipients[form.focus - 1].selected ^= true,
Self::Insert(form) if form.focus == 1 => form.mode = form.mode.next(),
Self::Insert(form) if form.focus == 4 => form.overwrite ^= true,
Self::Generate(form) if form.focus == 2 => form.no_symbols ^= true,
Self::Generate(form) if form.focus == 3 => {
form.overwrite ^= true;
if form.overwrite {
form.in_place = false;
}
}
Self::Generate(form) if form.focus == 4 => {
form.in_place ^= true;
if form.in_place {
form.overwrite = false;
}
}
_ => self.character(' '),
}
}
fn enter(&mut self) {
match self {
Self::Insert(form) if form.focus == 2 && form.mode == InsertMode::Multiline => {
form.secret.push('\n')
}
_ => self.space(),
}
}
fn backspace(&mut self) {
match self {
Self::Init(form) if form.focus == 0 => {
form.path.pop();
}
Self::Insert(form) if form.focus == 0 => {
form.entry.pop();
}
Self::Insert(form) if form.focus == 2 => form.secret.pop(),
Self::Insert(form) if form.focus == 3 && form.mode == InsertMode::Hidden => {
form.confirmation.pop()
}
Self::Generate(form) if form.focus == 0 => {
form.entry.pop();
}
Self::Generate(form) if form.focus == 1 => {
form.length.pop();
}
_ => {}
}
}
fn character(&mut self, character: char) {
match self {
Self::Init(form) if form.focus == 0 => form.path.push(character),
Self::Insert(form) if form.focus == 0 => form.entry.push(character),
Self::Insert(form)
if form.focus == 2 && (form.mode == InsertMode::Multiline || character != '\n') =>
{
form.secret.push(character)
}
Self::Insert(form)
if form.focus == 3 && form.mode == InsertMode::Hidden && character != '\n' =>
{
form.confirmation.push(character)
}
Self::Generate(form) if form.focus == 0 => form.entry.push(character),
Self::Generate(form) if form.focus == 1 && character.is_ascii_digit() => {
form.length.push(character)
}
_ => {}
}
}
}
fn row(focused: bool, label: &str, value: &str) -> String {
format!("{} {label}: {value}", if focused { ">" } else { " " })
}
fn yes_no(value: bool) -> &'static str {
if value { "yes" } else { "no" }
}
#[cfg(test)]
mod tests {
use super::*;
fn type_text(form: &mut WorkflowForm, text: &str) {
for character in text.chars() {
form.handle_key(KeyCode::Char(character), KeyModifiers::NONE);
}
}
#[test]
fn hidden_entry_is_confirmed_and_never_rendered_or_debugged() {
let mut form = WorkflowForm::insert(None);
type_text(&mut form, "nested/account");
form.handle_key(KeyCode::Tab, KeyModifiers::NONE);
form.handle_key(KeyCode::Tab, KeyModifiers::NONE);
type_text(&mut form, "do-not-display");
form.handle_key(KeyCode::Tab, KeyModifiers::NONE);
type_text(&mut form, "do-not-display");
let rendered = form.rows().join("\n");
let debug = format!("{form:?}");
assert!(!rendered.contains("do-not-display"));
assert!(!debug.contains("do-not-display"));
assert!(matches!(
form.submission(),
Ok(WorkflowSubmission::Insert { .. })
));
}
#[test]
fn confirmation_and_generation_validation_are_storage_compatible() {
let mut insert = WorkflowForm::insert(None);
type_text(&mut insert, "entry");
insert.handle_key(KeyCode::Tab, KeyModifiers::NONE);
insert.handle_key(KeyCode::Tab, KeyModifiers::NONE);
type_text(&mut insert, "first");
insert.handle_key(KeyCode::Tab, KeyModifiers::NONE);
type_text(&mut insert, "second");
assert!(insert.submission().unwrap_err().contains("match"));
let mut generate = WorkflowForm::generate(None);
type_text(&mut generate, "entry");
generate.handle_key(KeyCode::Tab, KeyModifiers::NONE);
assert!(generate.submission().is_ok());
type_text(&mut generate, "0");
assert!(generate.submission().unwrap_err().contains("positive"));
}
#[test]
fn multiline_input_and_generate_options_are_preserved() {
let mut insert = WorkflowForm::insert(Some(InsertRequest {
entry: "notes/item".into(),
input: InsertInput::Multiline,
force: true,
}));
insert.handle_key(KeyCode::Tab, KeyModifiers::NONE);
insert.handle_key(KeyCode::Tab, KeyModifiers::NONE);
type_text(&mut insert, "first");
insert.handle_key(KeyCode::Enter, KeyModifiers::NONE);
type_text(&mut insert, "second");
match insert.submission().expect("submission") {
WorkflowSubmission::Insert {
contents,
overwrite,
..
} => {
assert_eq!(contents.expose(), b"first\nsecond");
assert_eq!(overwrite, OverwriteDecision::Allow);
}
_ => panic!("wrong submission"),
}
}
}