Implement TUI colon command mode

This commit is contained in:
Hermes Agent
2026-08-10 08:28:55 +00:00
parent 639ae0d08e
commit d66d9b0f08
7 changed files with 1502 additions and 89 deletions

View File

@@ -3,6 +3,9 @@
use std::collections::BTreeSet;
use ironstorage::{
command::{
CommandRequest, OtpRequest, Presentation, help_text, otp_version_text, version_text,
},
config::Config,
crypto::KeyInfo,
document::{DocumentError, EntryDocument, EntryFieldId},
@@ -14,6 +17,7 @@ use ironstorage::{
use crate::{
action::{Action, WorkflowAction},
command::{CommandInvocation, CommandLine, operation_name},
editor::EntryEditor,
sidebar::{Sidebar, SidebarIntent},
viewer::EntryViewer,
@@ -138,9 +142,16 @@ pub enum AppEffect {
editor: Box<EntryEditor>,
},
OpenWorkflow(WorkflowAction),
RunCommand(CommandRequest),
ManualLock,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum CommandOpenTarget {
Viewer,
Editor,
}
#[derive(Debug)]
pub struct App {
mode: Mode,
@@ -154,6 +165,11 @@ pub struct App {
viewer: Option<EntryViewer>,
editor: Option<EntryEditor>,
discard_confirmation: bool,
command_confirmation: Option<CommandRequest>,
command_confirmation_message: Option<String>,
command_line: CommandLine,
command_help: Option<String>,
command_open_target: Option<CommandOpenTarget>,
editor_generation_pending: Option<EntryFieldId>,
authentication_pending: Option<String>,
remaining_lease: Option<std::time::Duration>,
@@ -185,6 +201,11 @@ impl App {
viewer: None,
editor: None,
discard_confirmation: false,
command_confirmation: None,
command_confirmation_message: None,
command_line: CommandLine::default(),
command_help: None,
command_open_target: None,
editor_generation_pending: None,
authentication_pending: None,
remaining_lease: None,
@@ -209,6 +230,14 @@ impl App {
}
}
pub fn command_context_mode(&self) -> Mode {
if self.mode == Mode::Command {
self.suspended_mode.unwrap_or(Mode::Browser)
} else {
self.mode
}
}
pub fn focus(&self) -> PaneFocus {
self.focus
}
@@ -245,6 +274,18 @@ impl App {
self.discard_confirmation
}
pub fn command_confirmation_message(&self) -> Option<&str> {
self.command_confirmation_message.as_deref()
}
pub fn command_line(&self) -> &CommandLine {
&self.command_line
}
pub fn command_help(&self) -> Option<&str> {
self.command_help.as_deref()
}
pub fn default_key(&self) -> Option<&KeyInfo> {
self.default_key.as_ref()
}
@@ -330,9 +371,16 @@ impl App {
return ResultDisposition::Stale;
}
let field_count = document.fields().len();
self.viewer = Some(EntryViewer::new(*document));
if self.command_open_target.take() == Some(CommandOpenTarget::Editor) {
self.editor = Some(EntryEditor::new(*document));
self.viewer = None;
self.mode = Mode::Editor;
self.status = format!("Editing {entry} ({field_count} fields)");
} else {
self.viewer = Some(EntryViewer::new(*document));
self.status = format!("Opened {entry} ({field_count} fields)");
}
self.focus = PaneFocus::Main;
self.status = format!("Opened {entry} ({field_count} fields)");
}
Ok(AsyncPayload::ClipboardFinished(disposition)) => {
self.status = match disposition {
@@ -414,6 +462,7 @@ impl App {
Err(error) => {
self.status = error;
self.editor_generation_pending = None;
self.command_open_target = None;
if self.mode == Mode::Viewer && self.viewer.is_none() {
self.mode = Mode::Browser;
self.focus = PaneFocus::Sidebar;
@@ -447,9 +496,11 @@ impl App {
self.should_quit = true;
}
Action::Help => {
self.command_help = None;
self.transition(Transition::OpenHelp);
}
Action::Command => {
self.command_line.clear();
self.transition(Transition::OpenCommand);
}
Action::Cancel => {
@@ -457,7 +508,13 @@ impl App {
self.cancel_editor();
} else if self.mode == Mode::Dialog && self.discard_confirmation {
self.keep_editing();
} else if self.mode == Mode::Dialog && self.command_confirmation.is_some() {
self.cancel_command_confirmation();
} else {
if self.mode == Mode::Command {
self.command_line.clear();
self.status = "Command cancelled".to_owned();
}
self.transition(Transition::Dismiss);
}
}
@@ -625,8 +682,20 @@ impl App {
}
}
}
Action::ConfirmDiscard => self.discard_editor(),
Action::KeepEditing => self.keep_editing(),
Action::ConfirmDiscard => {
if self.discard_confirmation {
self.discard_editor();
} else if let Some(request) = self.confirm_command() {
return AppEffect::RunCommand(request);
}
}
Action::KeepEditing => {
if self.discard_confirmation {
self.keep_editing();
} else {
self.cancel_command_confirmation();
}
}
Action::Initialize
| Action::InsertEntry
| Action::GenerateEntry
@@ -654,6 +723,233 @@ impl App {
.is_some_and(EntryEditor::is_input_active)
}
pub fn handle_command_input(
&mut self,
code: crossterm::event::KeyCode,
modifiers: crossterm::event::KeyModifiers,
) -> Option<AppEffect> {
use crossterm::event::{KeyCode, KeyModifiers};
if self.mode != Mode::Command {
return None;
}
let control = modifiers.contains(KeyModifiers::CONTROL);
match (code, control) {
(KeyCode::Esc, _) => return Some(self.dispatch(Action::Cancel)),
(KeyCode::Enter, _) => return Some(self.submit_command()),
(KeyCode::Char('l' | 'z'), true) => return Some(self.dispatch(Action::Lock)),
(KeyCode::Tab, _) => {
let paths = self.sidebar.completion_paths();
if !self.command_line.complete(&paths, false) {
self.status = "No command completion is available".to_owned();
}
}
(KeyCode::BackTab, _) => {
let paths = self.sidebar.completion_paths();
if !self.command_line.complete(&paths, true) {
self.status = "No command completion is available".to_owned();
}
}
(KeyCode::Up, _) => self.command_line.previous_history(),
(KeyCode::Down, _) => self.command_line.next_history(),
(KeyCode::Left, _) => self.command_line.move_left(),
(KeyCode::Right, _) => self.command_line.move_right(),
(KeyCode::Home, _) | (KeyCode::Char('a'), true) => self.command_line.move_home(),
(KeyCode::End, _) | (KeyCode::Char('e'), true) => self.command_line.move_end(),
(KeyCode::Backspace, _) => self.command_line.backspace(),
(KeyCode::Delete, _) => self.command_line.delete(),
(KeyCode::Char(character), false) => self.command_line.insert(character),
_ => return Some(AppEffect::None),
}
Some(AppEffect::None)
}
fn submit_command(&mut self) -> AppEffect {
let invocation = match self.command_line.submit() {
Ok(invocation) => invocation,
Err(error) => {
self.status = format!("Command error: {error}");
return AppEffect::None;
}
};
self.route_command(invocation)
}
fn route_command(&mut self, invocation: CommandInvocation) -> AppEffect {
let context = self.command_context_mode();
match invocation {
CommandInvocation::Display(text) => {
self.show_command_help(text);
AppEffect::None
}
CommandInvocation::Lock if context != Mode::Locked => {
self.transition(Transition::Dismiss);
self.transition(Transition::Lock);
AppEffect::ManualLock
}
CommandInvocation::Unlock if context == Mode::Locked => {
self.transition(Transition::Dismiss);
self.transition(Transition::Unlock);
AppEffect::None
}
CommandInvocation::Quit if context != Mode::Editor => {
self.transition(Transition::Dismiss);
self.should_quit = true;
AppEffect::None
}
CommandInvocation::Lock | CommandInvocation::Unlock | CommandInvocation::Quit => {
self.status = "Command is unavailable in the current mode".to_owned();
AppEffect::None
}
CommandInvocation::Storage(CommandRequest::Help { topic }) => {
self.show_command_help(help_text(topic));
AppEffect::None
}
CommandInvocation::Storage(CommandRequest::Version) => {
self.transition(Transition::Dismiss);
self.status = version_text().trim().to_owned();
AppEffect::None
}
CommandInvocation::Storage(CommandRequest::Otp(OtpRequest::Help)) => {
self.show_command_help(help_text(Some(ironstorage::command::HelpTopic::Otp)));
AppEffect::None
}
CommandInvocation::Storage(CommandRequest::Otp(OtpRequest::Version)) => {
self.transition(Transition::Dismiss);
self.status = format!("pass-otp {}", otp_version_text().trim());
AppEffect::None
}
CommandInvocation::Storage(request) if context == Mode::Locked => {
self.status = format!(
"{} is unavailable while the password store is locked",
operation_name(&request)
);
AppEffect::None
}
CommandInvocation::Storage(CommandRequest::Show(request))
if request.presentation == Presentation::Terminal =>
{
if context == Mode::Editor {
self.status = "show is unavailable while an editor is open".to_owned();
return AppEffect::None;
}
self.transition(Transition::Dismiss);
if let Some(entry) = request.entry {
self.begin_command_open(entry, CommandOpenTarget::Viewer)
} else {
self.close_to_browser();
self.status = "Showing the password-store root".to_owned();
AppEffect::None
}
}
CommandInvocation::Storage(CommandRequest::Edit(request)) => {
if context == Mode::Editor {
if self.selected_entry.as_deref() == Some(&request.entry) {
self.transition(Transition::Dismiss);
self.status = format!("Already editing {}", request.entry);
} else {
self.status = "edit is unavailable while another editor is open".to_owned();
}
return AppEffect::None;
}
self.transition(Transition::Dismiss);
self.begin_command_open(request.entry, CommandOpenTarget::Editor)
}
CommandInvocation::Storage(CommandRequest::List(request)) => {
if context == Mode::Editor {
self.status = "list is unavailable while an editor is open".to_owned();
return AppEffect::None;
}
self.transition(Transition::Dismiss);
self.close_to_browser();
if let Some(path) = request.path {
if self.sidebar.select_path(&path, true) {
self.status = format!("Selected directory {path}");
} else {
self.status =
format!("Directory is unavailable in the loaded tree: {path}");
}
} else {
self.status = "Showing the password-store root".to_owned();
}
AppEffect::None
}
CommandInvocation::Storage(request) if context == Mode::Editor => {
self.status = format!(
"{} is unavailable while an editor is open",
operation_name(&request)
);
AppEffect::None
}
CommandInvocation::Storage(request @ CommandRequest::Remove(_)) => {
let message = format!(
"Confirm {}? y runs it; n or Esc cancels",
operation_name(&request)
);
self.transition(Transition::Dismiss);
self.command_confirmation = Some(request);
self.command_confirmation_message = Some(message.clone());
self.transition(Transition::OpenDialog);
self.status = message;
AppEffect::None
}
CommandInvocation::Storage(request) => {
let operation = operation_name(&request);
self.transition(Transition::Dismiss);
self.status = format!("Starting {operation} workflow…");
AppEffect::RunCommand(request)
}
}
}
fn show_command_help(&mut self, text: String) {
self.transition(Transition::Dismiss);
self.command_help = Some(text);
self.transition(Transition::OpenHelp);
self.status = "Command help; Esc returns".to_owned();
}
fn begin_command_open(&mut self, entry: String, target: CommandOpenTarget) -> AppEffect {
if self.selected_entry.as_deref() == Some(&entry) && self.mode == Mode::Viewer {
if target == CommandOpenTarget::Editor {
return self.dispatch(Action::EditEntry);
}
self.status = format!("Already viewing {entry}");
return AppEffect::None;
}
self.authentication_pending = Some(entry.clone());
self.command_open_target = Some(target);
self.status = format!("Authenticating to open {entry}");
AppEffect::AuthenticateEntry(entry)
}
fn close_to_browser(&mut self) {
if matches!(self.mode, Mode::Viewer | Mode::Editor) {
self.transition(Transition::CloseEntry);
}
}
fn confirm_command(&mut self) -> Option<CommandRequest> {
if self.mode != Mode::Dialog {
return None;
}
let request = self.command_confirmation.take()?;
self.command_confirmation_message = None;
self.transition(Transition::Dismiss);
self.status = format!("Starting confirmed {} workflow…", operation_name(&request));
Some(request)
}
fn cancel_command_confirmation(&mut self) {
if self.mode != Mode::Dialog || self.command_confirmation.is_none() {
return;
}
self.command_confirmation = None;
self.command_confirmation_message = None;
self.transition(Transition::Dismiss);
self.status = "Destructive command cancelled".to_owned();
}
fn editor_context_active(&self) -> bool {
self.mode == Mode::Editor || self.suspended_mode == Some(Mode::Editor)
}
@@ -844,6 +1140,7 @@ impl App {
self.editor = None;
self.discard_confirmation = false;
self.editor_generation_pending = None;
self.command_open_target = None;
self.status = message;
if self.mode != Mode::Browser {
self.mode = Mode::Browser;
@@ -880,10 +1177,13 @@ impl App {
(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::Browser | Mode::Viewer | Mode::Editor | Mode::Locked, Transition::OpenHelp) => {
Some(Mode::Help)
}
(
Mode::Browser | Mode::Viewer | Mode::Editor | Mode::Locked,
Transition::OpenCommand,
) => Some(Mode::Command),
(Mode::Dialog | Mode::Help | Mode::Command, Transition::Dismiss) => {
self.suspended_mode.take()
}
@@ -929,6 +1229,11 @@ impl App {
self.viewer = None;
self.editor = None;
self.discard_confirmation = false;
self.command_confirmation = None;
self.command_confirmation_message = None;
self.command_line.clear();
self.command_help = None;
self.command_open_target = None;
self.editor_generation_pending = None;
self.status = "Locked".to_owned();
} else if current == Mode::Locked {
@@ -972,6 +1277,24 @@ mod tests {
use super::*;
use crate::viewer::test_support::fixture_document;
fn enter_command(app: &mut App, command: &str) -> AppEffect {
assert!(matches!(app.dispatch(Action::Command), AppEffect::None));
for character in command.chars() {
assert!(matches!(
app.handle_command_input(
crossterm::event::KeyCode::Char(character),
crossterm::event::KeyModifiers::NONE,
),
Some(AppEffect::None)
));
}
app.handle_command_input(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
)
.expect("command mode consumes Enter")
}
#[test]
fn every_legal_transition_reaches_its_destination() {
let cases = [
@@ -1187,4 +1510,85 @@ mod tests {
));
assert!(app.status().contains("entry removal"));
}
#[test]
fn colon_show_and_edit_route_to_authenticated_tui_panes() {
let mut app = App::new();
assert!(matches!(
enter_command(&mut app, "show 'email/personal account'"),
AppEffect::AuthenticateEntry(entry) if entry == "email/personal account"
));
assert_eq!(app.mode(), Mode::Browser);
app.open_test_document("email/personal", fixture_document("email/personal"));
assert!(matches!(
enter_command(&mut app, "edit email/personal"),
AppEffect::None
));
assert_eq!(app.mode(), Mode::Editor);
}
#[test]
fn destructive_colon_commands_require_confirmation_and_can_be_cancelled() {
let mut app = App::new();
assert!(matches!(
enter_command(&mut app, "remove -r old/folder"),
AppEffect::None
));
assert_eq!(app.mode(), Mode::Dialog);
assert!(
app.command_confirmation_message()
.is_some_and(|message| message.contains("Confirm remove"))
);
assert!(matches!(
app.dispatch(Action::ConfirmDiscard),
AppEffect::RunCommand(CommandRequest::Remove(request))
if request.entry == "old/folder" && request.recursive
));
assert_eq!(app.mode(), Mode::Browser);
assert!(matches!(
enter_command(&mut app, "remove old/other"),
AppEffect::None
));
assert!(matches!(app.dispatch(Action::KeepEditing), AppEffect::None));
assert_eq!(app.mode(), Mode::Browser);
assert!(app.status().contains("cancelled"));
}
#[test]
fn command_mode_cancellation_unavailable_actions_and_unlock_are_explicit() {
let mut app = App::new();
app.open_test_document("email/personal", fixture_document("email/personal"));
app.dispatch(Action::EditEntry);
assert_eq!(app.mode(), Mode::Editor);
assert!(matches!(
enter_command(&mut app, "show another/entry"),
AppEffect::None
));
assert_eq!(app.mode(), Mode::Command);
assert!(app.status().contains("unavailable while an editor is open"));
app.dispatch(Action::Cancel);
assert_eq!(app.mode(), Mode::Editor);
app.forced_relock("test");
assert_eq!(app.mode(), Mode::Locked);
assert!(matches!(enter_command(&mut app, "unlock"), AppEffect::None));
assert_eq!(app.mode(), Mode::Browser);
}
#[test]
fn secret_bearing_commands_never_enter_history_or_status() {
let mut app = App::new();
let secret = "NEVER-RENDER-THIS";
assert!(matches!(
enter_command(
&mut app,
&format!("otp validate otpauth://totp/test?secret={secret}"),
),
AppEffect::RunCommand(CommandRequest::Otp(OtpRequest::Validate { .. }))
));
assert!(app.command_line().history().is_empty());
assert!(!app.status().contains(secret));
}
}