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

826
apps/tui/src/command.rs Normal file
View File

@@ -0,0 +1,826 @@
//! Editable, shell-free `:` command input over the shared storage command contract.
use std::{error::Error, fmt};
use ironstorage::command::{CliAction, CommandRequest, GitRequest, OtpRequest, parse_from};
const HISTORY_LIMIT: usize = 100;
const ROOT_COMMANDS: &[&str] = &[
"init", "list", "show", "find", "grep", "insert", "edit", "generate", "remove", "move", "copy",
"git", "otp", "lock", "unlock", "help", "version", "quit",
];
const GIT_COMMANDS: &[&str] = &[
"init", "status", "log", "diff", "add", "commit", "remote", "config", "fetch", "pull", "push",
"sync",
];
const GIT_REMOTE_COMMANDS: &[&str] = &["get-url", "add", "set-url", "remove"];
const OTP_COMMANDS: &[&str] = &["code", "insert", "append", "uri", "validate", "version"];
const HELP_TOPICS: &[&str] = &[
"init", "list", "show", "find", "grep", "insert", "edit", "generate", "remove", "move", "copy",
"git", "otp",
];
/// Auditable mapping from every milestone-01 command family to the TUI command surface.
pub const COMMAND_COVERAGE: &[CommandCoverage] = &[
coverage("initialize recipients", ":init GPG-ID…"),
coverage("list a directory", ":list [PATH]"),
coverage("show an entry", ":show [OPTIONS] [ENTRY]"),
coverage("find names", ":find TERM…"),
coverage("grep decrypted entries", ":grep [OPTIONS] PATTERN"),
coverage("insert an entry", ":insert [OPTIONS] ENTRY"),
coverage("edit an entry", ":edit ENTRY"),
coverage("generate a password", ":generate [OPTIONS] ENTRY [LENGTH]"),
coverage("remove entries or directories", ":remove [OPTIONS] ENTRY"),
coverage(
"move entries or directories",
":move [OPTIONS] SOURCE DESTINATION",
),
coverage(
"copy entries or directories",
":copy [OPTIONS] SOURCE DESTINATION",
),
coverage("initialize Git", ":git init"),
coverage("show Git status", ":git status"),
coverage("show Git log", ":git log [OPTIONS]"),
coverage("show Git diff", ":git diff [PATH]…"),
coverage("stage Git paths", ":git add PATH…"),
coverage("create a Git commit", ":git commit -m MESSAGE"),
coverage("manage Git remotes", ":git remote [COMMAND]"),
coverage(
"manage Git configuration",
":git config (--get KEY | KEY VALUE)",
),
coverage("fetch Git remote", ":git fetch [REMOTE]"),
coverage("pull Git remote", ":git pull [REMOTE] [BRANCH]"),
coverage("push Git remote", ":git push [REMOTE] [BRANCH]"),
coverage("synchronize Git remote", ":git sync [REMOTE]"),
coverage("generate an OTP code", ":otp code [OPTIONS] ENTRY"),
coverage("insert an OTP entry", ":otp insert [OPTIONS] [ENTRY]"),
coverage("append OTP data", ":otp append [OPTIONS] ENTRY"),
coverage("present an OTP URI", ":otp uri [OPTIONS] ENTRY"),
coverage("validate an OTP URI", ":otp validate URI"),
coverage("show pass-otp version", ":otp version"),
coverage("show help", ":help [TOPIC]"),
coverage("show version", ":version"),
coverage("lock the TUI", ":lock"),
coverage("unlock the TUI", ":unlock"),
];
const fn coverage(operation: &'static str, command: &'static str) -> CommandCoverage {
CommandCoverage { operation, command }
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct CommandCoverage {
pub operation: &'static str,
pub command: &'static str,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum CommandInvocation {
Storage(CommandRequest),
Display(String),
Lock,
Unlock,
Quit,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CommandError {
message: String,
}
impl CommandError {
fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}
impl fmt::Display for CommandError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.message)
}
}
impl Error for CommandError {}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct CommandLine {
input: String,
cursor: usize,
history: Vec<String>,
history_position: Option<usize>,
history_draft: String,
completion: Option<CompletionCycle>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct CompletionCycle {
start: usize,
end: usize,
candidates: Vec<String>,
selected: usize,
}
impl CommandLine {
pub fn input(&self) -> &str {
&self.input
}
pub fn cursor(&self) -> usize {
self.cursor
}
pub fn history(&self) -> &[String] {
&self.history
}
pub fn display(&self) -> String {
if is_secret_bearing(&self.input) {
let command = tokenize(&self.input)
.ok()
.and_then(|tokens| {
let keep = if tokens.first().is_some_and(|token| token == "otp") {
2
} else {
1
};
(!tokens.is_empty()).then(|| {
format!(
"{} [hidden secret-bearing arguments]",
tokens[..tokens.len().min(keep)].join(" ")
)
})
})
.unwrap_or_else(|| "[hidden secret-bearing command]".to_owned());
return command;
}
let mut displayed = self.input.clone();
displayed.insert(self.cursor, '▏');
displayed
}
pub fn clear(&mut self) {
self.input.clear();
self.cursor = 0;
self.history_position = None;
self.history_draft.clear();
self.completion = None;
}
pub fn insert(&mut self, character: char) {
if character.is_control() {
return;
}
self.reset_navigation();
self.input.insert(self.cursor, character);
self.cursor += character.len_utf8();
}
pub fn backspace(&mut self) {
self.reset_navigation();
if let Some(previous) = previous_boundary(&self.input, self.cursor) {
self.input.drain(previous..self.cursor);
self.cursor = previous;
}
}
pub fn delete(&mut self) {
self.reset_navigation();
if let Some(next) = next_boundary(&self.input, self.cursor) {
self.input.drain(self.cursor..next);
}
}
pub fn move_left(&mut self) {
self.completion = None;
if let Some(previous) = previous_boundary(&self.input, self.cursor) {
self.cursor = previous;
}
}
pub fn move_right(&mut self) {
self.completion = None;
if let Some(next) = next_boundary(&self.input, self.cursor) {
self.cursor = next;
}
}
pub fn move_home(&mut self) {
self.completion = None;
self.cursor = 0;
}
pub fn move_end(&mut self) {
self.completion = None;
self.cursor = self.input.len();
}
pub fn previous_history(&mut self) {
self.completion = None;
if self.history.is_empty() {
return;
}
let position = match self.history_position {
None => {
self.history_draft.clone_from(&self.input);
self.history.len() - 1
}
Some(position) => position.saturating_sub(1),
};
self.history_position = Some(position);
self.input.clone_from(&self.history[position]);
self.cursor = self.input.len();
}
pub fn next_history(&mut self) {
self.completion = None;
let Some(position) = self.history_position else {
return;
};
if position + 1 < self.history.len() {
self.history_position = Some(position + 1);
self.input.clone_from(&self.history[position + 1]);
} else {
self.history_position = None;
self.input.clone_from(&self.history_draft);
}
self.cursor = self.input.len();
}
pub fn complete(&mut self, paths: &[String], reverse: bool) -> bool {
if let Some(cycle) = self.completion.as_mut() {
cycle.selected = if reverse {
cycle
.selected
.checked_sub(1)
.unwrap_or(cycle.candidates.len() - 1)
} else {
(cycle.selected + 1) % cycle.candidates.len()
};
let replacement = cycle.candidates[cycle.selected].clone();
self.input
.replace_range(cycle.start..cycle.end, &replacement);
cycle.end = cycle.start + replacement.len();
self.cursor = cycle.end;
return true;
}
let fragment = completion_fragment(&self.input, self.cursor);
let preceding = tokenize(&self.input[..fragment.start]).unwrap_or_default();
let candidates = completion_candidates(&preceding, &fragment.value, paths)
.into_iter()
.map(|candidate| quote_argument(&candidate))
.collect::<Vec<_>>();
if candidates.is_empty() {
return false;
}
let selected = if reverse { candidates.len() - 1 } else { 0 };
let replacement = if candidates.len() == 1 {
format!("{} ", candidates[0])
} else {
candidates[selected].clone()
};
self.input
.replace_range(fragment.start..fragment.end, &replacement);
self.cursor = fragment.start + replacement.len();
if candidates.len() > 1 {
self.completion = Some(CompletionCycle {
start: fragment.start,
end: self.cursor,
candidates,
selected,
});
}
true
}
pub fn submit(&mut self) -> Result<CommandInvocation, CommandError> {
let raw = self.input.trim().to_owned();
let result = parse_command(&raw);
if !raw.is_empty() && !is_secret_bearing(&raw) && self.history.last() != Some(&raw) {
self.history.push(raw);
if self.history.len() > HISTORY_LIMIT {
self.history.remove(0);
}
}
self.clear();
result
}
fn reset_navigation(&mut self) {
self.history_position = None;
self.history_draft.clear();
self.completion = None;
}
}
pub fn parse_command(input: &str) -> Result<CommandInvocation, CommandError> {
let input = input.trim().strip_prefix(':').unwrap_or(input.trim());
if input.is_empty() {
return Err(CommandError::new("command is empty"));
}
let sensitive = is_secret_bearing(input);
let tokens = tokenize(input).map_err(|error| {
if sensitive {
CommandError::new("invalid secret-bearing command; arguments omitted")
} else {
error
}
})?;
let Some(command) = tokens.first().map(String::as_str) else {
return Err(CommandError::new("command is empty"));
};
let builtin = match command {
"lock" => Some(CommandInvocation::Lock),
"unlock" => Some(CommandInvocation::Unlock),
"quit" | "q" => Some(CommandInvocation::Quit),
_ => None,
};
if let Some(invocation) = builtin {
if tokens.len() != 1 {
return Err(CommandError::new(format!(
":{command} does not accept arguments"
)));
}
return Ok(invocation);
}
let arguments = std::iter::once("ironstorage".to_owned()).chain(tokens);
match parse_from(arguments) {
Ok(CliAction::Display(text)) => Ok(CommandInvocation::Display(text)),
Ok(CliAction::Run(invocation)) => {
let (config, request) = invocation.into_parts();
if config.is_some() {
return Err(CommandError::new(
"the TUI uses its loaded configuration; --config is unavailable in command mode",
));
}
if matches!(request, CommandRequest::Completion { .. }) {
return Err(CommandError::new(
"shell completion scripts are unavailable inside the interactive TUI",
));
}
Ok(CommandInvocation::Storage(request))
}
Err(_error) if sensitive => Err(CommandError::new(
"invalid secret-bearing command; arguments omitted",
)),
Err(error) => Err(CommandError::new(error.to_string().trim().to_owned())),
}
}
pub fn operation_name(request: &CommandRequest) -> &'static str {
match request {
CommandRequest::Init(_) => "initialization",
CommandRequest::List(_) => "list",
CommandRequest::Show(_) => "show",
CommandRequest::Find(_) => "find",
CommandRequest::Grep(_) => "grep",
CommandRequest::Insert(_) => "insert",
CommandRequest::Edit(_) => "edit",
CommandRequest::Generate(_) => "generate",
CommandRequest::Remove(_) => "remove",
CommandRequest::Move(_) => "move",
CommandRequest::Copy(_) => "copy",
CommandRequest::Git(request) => match request {
GitRequest::Init => "Git init",
GitRequest::Status => "Git status",
GitRequest::Log { .. } => "Git log",
GitRequest::Diff { .. } => "Git diff",
GitRequest::Add { .. } => "Git add",
GitRequest::Commit { .. } => "Git commit",
GitRequest::Remote(_) => "Git remote",
GitRequest::Config(_) => "Git config",
GitRequest::Fetch { .. } => "Git fetch",
GitRequest::Pull { .. } => "Git pull",
GitRequest::Push { .. } => "Git push",
GitRequest::Sync { .. } => "Git sync",
},
CommandRequest::Otp(request) => match request {
OtpRequest::Code(_) => "OTP code",
OtpRequest::Insert(_) => "OTP insert",
OtpRequest::Append(_) => "OTP append",
OtpRequest::Uri(_) => "OTP URI",
OtpRequest::Validate { .. } => "OTP validation",
OtpRequest::Help => "OTP help",
OtpRequest::Version => "OTP version",
},
CommandRequest::Completion { .. } => "completion",
CommandRequest::Help { .. } => "help",
CommandRequest::Version => "version",
}
}
fn tokenize(input: &str) -> Result<Vec<String>, CommandError> {
#[derive(Clone, Copy, Eq, PartialEq)]
enum Quote {
None,
Single,
Double,
}
let mut tokens = Vec::new();
let mut token = String::new();
let mut quote = Quote::None;
let mut escaped = false;
let mut started = false;
for character in input.chars() {
if escaped {
token.push(character);
escaped = false;
started = true;
continue;
}
match (quote, character) {
(Quote::None | Quote::Double, '\\') => {
escaped = true;
started = true;
}
(Quote::None, '\'') => {
quote = Quote::Single;
started = true;
}
(Quote::Single, '\'') => quote = Quote::None,
(Quote::None, '"') => {
quote = Quote::Double;
started = true;
}
(Quote::Double, '"') => quote = Quote::None,
(Quote::None, character) if character.is_whitespace() => {
if started {
tokens.push(std::mem::take(&mut token));
started = false;
}
}
(_, character) => {
token.push(character);
started = true;
}
}
}
if escaped {
return Err(CommandError::new("command ends with an incomplete escape"));
}
if quote != Quote::None {
return Err(CommandError::new("command contains an unterminated quote"));
}
if started {
tokens.push(token);
}
Ok(tokens)
}
#[derive(Debug)]
struct CompletionFragment {
start: usize,
end: usize,
value: String,
}
fn completion_fragment(input: &str, cursor: usize) -> CompletionFragment {
let mut start = 0;
let mut quote = None;
let mut escaped = false;
for (index, character) in input[..cursor].char_indices() {
if escaped {
escaped = false;
continue;
}
match (quote, character) {
(Some('"'), '\\') | (None, '\\') => escaped = true,
(None, '\'' | '"') => quote = Some(character),
(Some(active), character) if active == character => quote = None,
(None, character) if character.is_whitespace() => {
start = index + character.len_utf8();
}
_ => {}
}
}
let raw = &input[start..cursor];
let value = tokenize(raw)
.ok()
.and_then(|mut tokens| tokens.pop())
.unwrap_or_else(|| raw.trim_start_matches(['\'', '"']).replace("\\ ", " "));
CompletionFragment {
start,
end: cursor,
value,
}
}
fn completion_candidates(preceding: &[String], fragment: &str, paths: &[String]) -> Vec<String> {
let candidates: &[&str] = match preceding {
[] => ROOT_COMMANDS,
[command] if command == "git" => GIT_COMMANDS,
[command, remote] if command == "git" && remote == "remote" => GIT_REMOTE_COMMANDS,
[command] if command == "otp" => OTP_COMMANDS,
[command] if command == "help" => HELP_TOPICS,
[command, ..] if command == "init" && fragment.starts_with('-') => &["--path"],
[command, ..] if command == "show" && fragment.starts_with('-') => &["--clip", "--qrcode"],
[command, ..] if command == "grep" && fragment.starts_with('-') => &[
"--ignore-case",
"--invert-match",
"--line-number",
"--fixed-strings",
],
[command, ..] if command == "generate" && fragment.starts_with('-') => &[
"--no-symbols",
"--clip",
"--qrcode",
"--in-place",
"--force",
],
[command, ..] if command == "insert" && fragment.starts_with('-') => {
&["--echo", "--multiline", "--force"]
}
[command, ..] if command == "remove" && fragment.starts_with('-') => {
&["--recursive", "--force"]
}
[command, ..]
if matches!(command.as_str(), "move" | "copy") && fragment.starts_with('-') =>
{
&["--force"]
}
[git, subcommand, ..]
if git == "git" && subcommand == "log" && fragment.starts_with('-') =>
{
&["--max-count"]
}
[git, subcommand, ..]
if git == "git" && subcommand == "commit" && fragment.starts_with('-') =>
{
&["--message"]
}
[git, subcommand, ..]
if git == "git" && subcommand == "config" && fragment.starts_with('-') =>
{
&["--get"]
}
[otp, subcommand, ..]
if otp == "otp" && subcommand == "code" && fragment.starts_with('-') =>
{
&["--clip"]
}
[otp, subcommand, ..]
if otp == "otp"
&& matches!(subcommand.as_str(), "insert" | "append")
&& fragment.starts_with('-') =>
{
&["--force", "--echo", "--secret", "--issuer", "--account"]
}
[otp, subcommand, ..]
if otp == "otp" && subcommand == "uri" && fragment.starts_with('-') =>
{
&["--clip", "--qrcode"]
}
_ => &[],
};
let mut matches = candidates
.iter()
.filter(|candidate| candidate.starts_with(fragment))
.map(|candidate| (*candidate).to_owned())
.collect::<Vec<_>>();
if candidates.is_empty() && command_accepts_path(preceding) {
matches.extend(
paths
.iter()
.filter(|path| path.starts_with(fragment))
.cloned(),
);
}
matches.sort();
matches.dedup();
matches
}
fn command_accepts_path(tokens: &[String]) -> bool {
matches!(
tokens.first().map(String::as_str),
Some(
"list"
| "ls"
| "show"
| "insert"
| "edit"
| "generate"
| "remove"
| "rm"
| "move"
| "mv"
| "copy"
| "cp"
)
) || matches!(
tokens,
[command, subcommand, ..]
if (command == "otp"
&& matches!(subcommand.as_str(), "code" | "show" | "insert" | "append" | "uri"))
|| (command == "git" && matches!(subcommand.as_str(), "diff" | "add"))
)
}
fn quote_argument(argument: &str) -> String {
if !argument.is_empty()
&& argument
.chars()
.all(|character| character.is_alphanumeric() || "/._-:@".contains(character))
{
return argument.to_owned();
}
format!("'{}'", argument.replace('\'', "'\\''"))
}
fn is_secret_bearing(input: &str) -> bool {
let lowercase = input.to_ascii_lowercase();
if lowercase.contains("otpauth://") {
return true;
}
let tokens = tokenize(input).unwrap_or_else(|_| {
input
.split_whitespace()
.map(str::to_owned)
.collect::<Vec<_>>()
});
matches!(tokens.as_slice(), [otp, validate, ..] if otp == "otp" && validate == "validate")
|| matches!(tokens.as_slice(), [git, config, ..] if git == "git" && config == "config")
|| matches!(tokens.as_slice(), [git, remote, action, ..]
if git == "git" && remote == "remote" && matches!(action.as_str(), "add" | "set-url"))
}
fn previous_boundary(value: &str, cursor: usize) -> Option<usize> {
value[..cursor]
.char_indices()
.next_back()
.map(|(index, _)| index)
}
fn next_boundary(value: &str, cursor: usize) -> Option<usize> {
value[cursor..].char_indices().nth(1).map_or_else(
|| (cursor < value.len()).then_some(value.len()),
|(index, _)| Some(cursor + index),
)
}
#[cfg(test)]
mod tests {
use super::*;
use ironstorage::command::{GitRemoteRequest, RemoveRequest};
#[test]
fn quoting_and_paths_with_spaces_use_the_shared_request_parser() {
assert!(matches!(
parse_command("show 'team accounts/mail primary'"),
Ok(CommandInvocation::Storage(CommandRequest::Show(request)))
if request.entry.as_deref() == Some("team accounts/mail primary")
));
assert!(matches!(
parse_command("git commit -m \"rotate signing key\""),
Ok(CommandInvocation::Storage(CommandRequest::Git(GitRequest::Commit { message })))
if message == "rotate signing key"
));
assert!(parse_command("show 'unfinished").is_err());
}
#[test]
fn editing_history_and_secret_omission_are_unicode_safe() {
let mut line = CommandLine::default();
for character in "show café/mail".chars() {
line.insert(character);
}
line.move_left();
line.backspace();
line.insert('i');
assert_eq!(line.input(), "show café/mail");
assert!(line.submit().is_ok());
assert_eq!(line.history(), &["show café/mail"]);
for character in "otp validate otpauth://totp/a?secret=TOPSECRET".chars() {
line.insert(character);
}
assert!(!line.display().contains("TOPSECRET"));
assert!(line.submit().is_ok());
assert_eq!(line.history(), &["show café/mail"]);
line.previous_history();
assert_eq!(line.input(), "show café/mail");
line.next_history();
assert!(line.input().is_empty());
}
#[test]
fn completion_is_context_aware_and_quotes_paths() {
let paths = vec!["team accounts/mail primary".to_owned()];
let mut line = CommandLine::default();
for character in "gi".chars() {
line.insert(character);
}
assert!(line.complete(&paths, false));
assert_eq!(line.input(), "git ");
for character in "pu".chars() {
line.insert(character);
}
assert!(line.complete(&paths, false));
assert!(matches!(line.input(), "git pull" | "git push"));
line.clear();
for character in "show team".chars() {
line.insert(character);
}
assert!(line.complete(&paths, false));
assert_eq!(line.input(), "show 'team accounts/mail primary' ");
}
#[test]
fn invalid_flags_cancellation_inputs_and_unavailable_meta_actions_are_typed() {
assert!(parse_command("generate --clip --qrcode entry").is_err());
assert!(
parse_command("completion bash")
.expect_err("interactive completion is unavailable")
.to_string()
.contains("unavailable")
);
assert!(matches!(
parse_command(":lock"),
Ok(CommandInvocation::Lock)
));
assert!(matches!(
parse_command(":unlock"),
Ok(CommandInvocation::Unlock)
));
assert!(matches!(
parse_command(":quit"),
Ok(CommandInvocation::Quit)
));
}
#[test]
fn destructive_and_nested_requests_preserve_typed_storage_contracts() {
assert_eq!(
parse_command("remove -r folder"),
Ok(CommandInvocation::Storage(CommandRequest::Remove(
RemoveRequest {
entry: "folder".to_owned(),
recursive: true,
force: false,
}
)))
);
assert!(matches!(
parse_command("git remote set-url origin https://example.test/repo"),
Ok(CommandInvocation::Storage(CommandRequest::Git(GitRequest::Remote(
GitRemoteRequest::SetUrl { name, url }
)))) if name == "origin" && url == "https://example.test/repo"
));
}
#[test]
fn coverage_table_has_every_command_family_and_no_duplicates() {
assert!(COMMAND_COVERAGE.len() >= 33);
for (index, row) in COMMAND_COVERAGE.iter().enumerate() {
assert!(row.command.starts_with(':'));
assert!(!row.operation.is_empty());
assert!(!COMMAND_COVERAGE[index + 1..].contains(row));
}
for command in [
":init",
":list",
":show",
":find",
":grep",
":insert",
":edit",
":generate",
":remove",
":move",
":copy",
":git",
":otp",
":lock",
":unlock",
":help",
":version",
] {
assert!(
COMMAND_COVERAGE
.iter()
.any(|row| row.command.starts_with(command))
);
}
}
#[test]
fn command_mode_source_has_no_process_launch_boundary() {
let source = include_str!("command.rs");
let process_type = ["process", "::Command"].concat();
let constructor = ["Command", "::new("].concat();
for forbidden in [process_type, constructor] {
assert!(!source.contains(&forbidden));
}
for executable in ["pass ", "git ", "gpg ", "ironstorage "] {
assert!(!source.contains(&format!("{executable}--")));
}
}
}