From c4ccca7e8c7812f6642efbad7d26387de7829e72 Mon Sep 17 00:00:00 2001 From: Sirius DevOps Date: Thu, 10 Sep 2026 23:07:22 -0400 Subject: [PATCH] feat(tui): Enter sends chat to the selected friend Composer Enter only ran slash commands, so typed messages were dropped. Wire Node::send, keep /commands, reject unknown /foo as not-chat. --- README.md | 4 +-- src/tui.rs | 65 ++++++++++++++++++++++++++++++++++++++------- tests/send.rs | 45 +++++++++++++++++++++++++++++++ tests/tui_chrome.rs | 4 +-- 4 files changed, 105 insertions(+), 13 deletions(-) create mode 100644 tests/send.rs diff --git a/README.md b/README.md index 29785e0..6a0482f 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ or `cargo run --release`. On start you are prompted for a store passphrase (echo off, like other CLI passwords) or set `ONIONWIRE_STORE_PASSPHRASE`. Empty passphrase is rejected; a wrong passphrase does not open chat. Then you should see `onionwire: bootstrapping Arti…` on stderr. Directory bootstrap is usually under a minute; the onion is ready once a probe connect works (combined Arti status may still say Bootstrapping). Fail closed at 360s. Data lives in `ONIONWIRE_HOME` if set, otherwise `~/.local/share/onionwire/` (`onionwire.db` + Arti state, mode 0700). First open creates an ed25519 identity key. That key **is** you. -Then `F2` to share your invite, `F3` to paste a friend’s. Mouse-select the `onionwire:v1:…` line to copy. +Then `F2` to share your invite, `F3` to paste a friend’s. Mouse-select the `onionwire:v1:…` line to copy. Highlight them in the roster, type in the composer, Enter to send. Fail closed: if their onion is down, send fails — no outbox. ## Friends are keys @@ -131,7 +131,7 @@ Focus starts on the composer so typing works immediately. `Tab` cycles panes; `j | `F3` | Paste a friend’s invite | | `F4` | Rotate **onion** (locator only) | | `F5` | Selected friend’s profile (`/who`) | -| Enter | Run `/wipe`, `/wipe-all`, `/profile`, `/who`, `/pay`, `/tip`, `/backup`, `/restore` from the composer | +| Enter | Send chat to the selected friend, or run a `/command` | | `Esc` | Close overlay / back to Main / clear composer | | `Ctrl-Q` | Quit: type `CLEAR`+Enter to wipe history, `QUIT`+Enter to leave it, Esc to stay | diff --git a/src/tui.rs b/src/tui.rs index 7d5c00f..88bd2c5 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -68,7 +68,7 @@ OnionWire keys\n\ 1 / 2 / 3 focus roster / chat / composer\n\ j k or Up Down move or scroll focused pane\n\ g / G jump to top / bottom\n\ - Enter /wipe /wipe-all /profile /who /pay /tip\n\ + Enter send chat to selected friend, or a /command\n\ Esc close overlay, go back, clear composer\n\ F2 share invite\n\ F3 paste invite\n\ @@ -165,6 +165,28 @@ pub enum SlashCmd { Restore { path: String }, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ComposerAction { + Cmd(SlashCmd), + Send(String), + UnknownSlash(String), +} + +/// Enter in the composer: slash command, chat send, or unknown `/cmd`. +pub fn composer_enter(raw: &str) -> Option { + let s = raw.trim(); + if s.is_empty() { + return None; + } + if let Some(cmd) = parse_cmd(raw) { + return Some(ComposerAction::Cmd(cmd)); + } + if s.starts_with('/') { + return Some(ComposerAction::UnknownSlash(s.to_string())); + } + Some(ComposerAction::Send(s.to_string())) +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProfileDraft { pub display_name: String, @@ -693,6 +715,7 @@ impl App { let mut open_profile = false; let mut pay_cmd = None; let mut tip_cmd = None; + let mut chat_send = None; match &mut self.screen { Screen::Main => match key.code { KeyCode::Tab => self.focus = self.focus.next(), @@ -718,8 +741,8 @@ impl App { self.composer.pop(); } } - KeyCode::Enter => match parse_cmd(&self.composer) { - Some(SlashCmd::Wipe(kind)) => { + KeyCode::Enter => match composer_enter(&self.composer) { + Some(ComposerAction::Cmd(SlashCmd::Wipe(kind))) => { self.composer.clear(); let prompt = match kind { WipeKind::Messages => WipePrompt::messages(), @@ -727,23 +750,23 @@ impl App { }; self.screen = Screen::Wipe { kind, prompt }; } - Some(SlashCmd::Profile) => { + Some(ComposerAction::Cmd(SlashCmd::Profile)) => { self.composer.clear(); open_profile = true; } - Some(SlashCmd::Who) => { + Some(ComposerAction::Cmd(SlashCmd::Who)) => { self.composer.clear(); self.who_open = true; } - Some(SlashCmd::Pay { atomic, memo }) => { + Some(ComposerAction::Cmd(SlashCmd::Pay { atomic, memo })) => { self.composer.clear(); pay_cmd = Some((atomic, memo)); } - Some(SlashCmd::Tip { atomic, memo }) => { + Some(ComposerAction::Cmd(SlashCmd::Tip { atomic, memo })) => { self.composer.clear(); tip_cmd = Some((atomic, memo)); } - Some(SlashCmd::Backup { path }) => { + Some(ComposerAction::Cmd(SlashCmd::Backup { path })) => { self.composer.clear(); self.screen = Screen::ConfirmKeys { kind: BackupKind::Backup, @@ -751,7 +774,7 @@ impl App { prompt: BackupPrompt::backup(), }; } - Some(SlashCmd::Restore { path }) => { + Some(ComposerAction::Cmd(SlashCmd::Restore { path })) => { self.composer.clear(); self.screen = Screen::ConfirmKeys { kind: BackupKind::Restore, @@ -759,6 +782,14 @@ impl App { prompt: BackupPrompt::restore(), }; } + Some(ComposerAction::Send(text)) => { + self.composer.clear(); + chat_send = Some(text); + } + Some(ComposerAction::UnknownSlash(cmd)) => { + self.composer.clear(); + self.status_note = Some(format!("unknown command {cmd}")); + } None => {} }, KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { @@ -912,6 +943,9 @@ impl App { if let Some((atomic, memo)) = tip_cmd { self.send_tip(&atomic, &memo)?; } + if let Some(text) = chat_send { + self.send_chat(&text)?; + } Ok(None) } @@ -1163,6 +1197,19 @@ impl App { Ok(()) } + fn send_chat(&mut self, text: &str) -> Result<(), String> { + let Some(friend) = self.friends.get(self.selected) else { + self.status_note = Some("no friend selected".into()); + return Ok(()); + }; + let pk = friend.pubkey.clone(); + match self.rt.block_on(self.node.send(&pk, text.as_bytes())) { + Ok(()) => self.status_note = Some("sent".into()), + Err(e) => self.status_note = Some(format!("send failed: {e}")), + } + Ok(()) + } + fn who_body(&self) -> String { let Some(friend) = self.friends.get(self.selected) else { return "no friend selected\n\nEsc closes".into(); diff --git a/tests/send.rs b/tests/send.rs new file mode 100644 index 0000000..8aedab5 --- /dev/null +++ b/tests/send.rs @@ -0,0 +1,45 @@ +//! Composer Enter must send chat to the selected friend, not only slash commands. + +use onionwire::tui::{composer_enter, ComposerAction, SlashCmd, WipeKind}; + +#[test] +fn enter_plain_text_is_send() { + assert_eq!( + composer_enter("hello wire"), + Some(ComposerAction::Send("hello wire".into())) + ); +} + +#[test] +fn enter_trims_but_sends() { + assert_eq!( + composer_enter(" hi there "), + Some(ComposerAction::Send("hi there".into())) + ); +} + +#[test] +fn enter_empty_does_nothing() { + assert_eq!(composer_enter(""), None); + assert_eq!(composer_enter(" "), None); +} + +#[test] +fn enter_slash_cmds_are_not_chat() { + assert_eq!( + composer_enter("/wipe"), + Some(ComposerAction::Cmd(SlashCmd::Wipe(WipeKind::Messages))) + ); + assert_eq!( + composer_enter("/who"), + Some(ComposerAction::Cmd(SlashCmd::Who)) + ); +} + +#[test] +fn enter_unknown_slash_is_not_chat() { + assert_eq!( + composer_enter("/nope"), + Some(ComposerAction::UnknownSlash("/nope".into())) + ); +} diff --git a/tests/tui_chrome.rs b/tests/tui_chrome.rs index 35be74b..5edd606 100644 --- a/tests/tui_chrome.rs +++ b/tests/tui_chrome.rs @@ -40,8 +40,8 @@ fn help_overlay_lists_core_bindings() { let help = help_overlay_text(); assert!(!help.is_empty()); for needle in [ - "Tab", "F2", "F3", "F4", "F5", "/profile", "/pay", "/tip", "/backup", "/restore", "Ctrl-Q", - "?", + "Tab", "F2", "F3", "F4", "F5", "send chat", "/profile", "/pay", "/tip", "/backup", + "/restore", "Ctrl-Q", "?", ] { assert!(help.contains(needle), "help overlay missing {needle:?}"); }