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.
This commit is contained in:
Sirius DevOps 2026-09-10 23:07:22 -04:00
parent c141eb45b8
commit c4ccca7e8c
No known key found for this signature in database
4 changed files with 105 additions and 13 deletions

View file

@ -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 friends. Mouse-select the `onionwire:v1:…` line to copy.
Then `F2` to share your invite, `F3` to paste a friends. 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 friends invite |
| `F4` | Rotate **onion** (locator only) |
| `F5` | Selected friends 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 |

View file

@ -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<ComposerAction> {
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();

45
tests/send.rs Normal file
View file

@ -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()))
);
}

View file

@ -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:?}");
}