Skip to main content

teamctl_ui/
compose.rs

1//! Send-mail compose modal — multi-line vim-style editor + CLI send.
2//!
3//! Two abstractions live here, mirroring the PR-UI-4 approvals
4//! split:
5//!
6//! - `Editor` — pure-state vim-style multi-line buffer. `apply_key`
7//!   takes a `KeyEvent` and returns an `EditorAction` so the
8//!   surrounding App can react to commands like "send" / "cancel"
9//!   without the editor itself knowing about the message bus.
10//! - `MessageSender` — write side. `CliMessageSender` shells out to
11//!   `teamctl send <agent> "<body>"` (DM) or
12//!   `teamctl broadcast #<channel> "<body>"` (broadcast), the same
13//!   architectural discipline as PR-UI-4's `CliApprovalDecider`. A
14//!   direct `INSERT INTO messages …` from the UI would silently
15//!   sidestep the channel-ACL + ratelimit + delivery hooks the CLI
16//!   already runs through.
17//!
18//! Vim keybindings shipped in PR-UI-5: insert mode (`i`/`a`/`o`,
19//! Esc back to Normal), Normal motions (`h`/`j`/`k`/`l`, arrows,
20//! `0`/`$`), ex command shim (`:w`/`:q`/`:wq`), Ctrl+Enter to send,
21//! Esc-Esc to cancel. Word motions (`w`/`b`) and line ops
22//! (`dd`/`yy`/`p`) deferred to the PR-UI-7 polish cycle — flagged
23//! in the PR description.
24
25use std::path::Path;
26use std::process::Command;
27
28use anyhow::{Context, Result};
29use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum ComposeTarget {
33    /// DM to a specific agent. `agent_id` is `<project>:<agent>`.
34    Dm {
35        agent_id: String,
36        project_id: String,
37    },
38    /// Broadcast to a channel. `channel_id` is `<project>:<name>`,
39    /// rendered as `#<name>` in the modal title.
40    Broadcast {
41        channel_id: String,
42        project_id: String,
43    },
44}
45
46impl ComposeTarget {
47    /// Header text for the compose-modal title bar. DM targets render
48    /// the agent's `display_name` when set (T-160 fallback) so the
49    /// operator sees the same human label that surfaces in the roster
50    /// and mailbox; broadcast targets render `#<channel-name>`
51    /// unchanged. Pass `&app.team` so the agent lookup goes through
52    /// the existing TeamSnapshot rather than carving a second context
53    /// path.
54    pub fn title(&self, team: &crate::data::TeamSnapshot) -> String {
55        match self {
56            ComposeTarget::Dm { agent_id, .. } => {
57                let label = crate::data::agent_label(team, agent_id);
58                format!("→ {label}")
59            }
60            ComposeTarget::Broadcast { channel_id, .. } => {
61                let short = channel_id
62                    .rsplit_once(':')
63                    .map(|(_, n)| n)
64                    .unwrap_or(channel_id);
65                format!("→ #{short}")
66            }
67        }
68    }
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum VimMode {
73    Normal,
74    Insert,
75    /// Awaiting an ex-command after `:`. `ex_buffer` accumulates
76    /// the typed string; Enter dispatches it.
77    Ex,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct Editor {
82    pub lines: Vec<String>,
83    pub cursor_row: usize,
84    pub cursor_col: usize,
85    pub mode: VimMode,
86    pub ex_buffer: String,
87    /// Tracks whether the previous keypress was `Esc`. Two Escs in
88    /// a row from any mode cancel the surrounding modal — same
89    /// shape SPEC §4 specifies for "close all modals."
90    pub esc_armed: bool,
91    /// Single-register yank buffer for `yy` / `dd` → `p`. Only
92    /// holds full lines; word-level yanks aren't in PR-UI-7 scope.
93    /// Empty `Vec` means "nothing to paste."
94    pub yank: Vec<String>,
95    /// Tracks whether the previous Normal-mode key was `d` or `y`.
96    /// `dd` deletes the line, `yy` yanks it. The pending-op state
97    /// clears on any non-matching key so `dx` doesn't leave the
98    /// editor in a half-operation.
99    pub pending_op: Option<char>,
100}
101
102impl Default for Editor {
103    fn default() -> Self {
104        Self {
105            lines: vec![String::new()],
106            cursor_row: 0,
107            cursor_col: 0,
108            // Compose modals open in Insert because typing is the
109            // central UX — operators expect their first keystroke
110            // to land in the buffer.
111            mode: VimMode::Insert,
112            ex_buffer: String::new(),
113            esc_armed: false,
114            yank: Vec::new(),
115            pending_op: None,
116        }
117    }
118}
119
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub enum EditorAction {
122    /// Keep the modal open; editor consumed the key.
123    Continue,
124    /// Operator hit `Ctrl+Enter` or `:wq` — send + close.
125    Send,
126    /// Operator hit Esc-Esc or `:q` — close without send.
127    Cancel,
128}
129
130impl Editor {
131    /// Final body for sending. Joins lines with `\n`; trailing
132    /// blank lines are stripped so a single newline at the bottom
133    /// doesn't sneak past the operator's intent.
134    pub fn body(&self) -> String {
135        let mut out = self.lines.join("\n");
136        while out.ends_with('\n') {
137            out.pop();
138        }
139        out
140    }
141
142    pub fn is_empty(&self) -> bool {
143        self.lines.iter().all(|l| l.is_empty())
144    }
145
146    /// Apply a keypress and return what the surrounding modal
147    /// should do. Held under `&mut self` so tests can drive the
148    /// editor deterministically without a `Terminal`.
149    pub fn apply_key(&mut self, k: KeyEvent) -> EditorAction {
150        if k.kind != KeyEventKind::Press {
151            return EditorAction::Continue;
152        }
153
154        // Send chord — Alt+Enter is the terminal-universal send
155        // (xterm / Terminal.app / tmux deliver it as Enter+ALT in
156        // their default modes). Ctrl+Enter is kept for terminals
157        // running the kitty keyboard protocol or modifyOtherKeys,
158        // where it does decode distinctly. Either chord fires send
159        // from any editor mode.
160        if k.code == KeyCode::Enter
161            && (k.modifiers.contains(KeyModifiers::ALT)
162                || k.modifiers.contains(KeyModifiers::CONTROL))
163        {
164            return EditorAction::Send;
165        }
166
167        // Esc-Esc handling spans modes: a single Esc out of Insert
168        // / Ex arms the second-Esc; from Normal the first Esc is
169        // the arming press. Any non-Esc key clears the arm.
170        if k.code == KeyCode::Esc {
171            return self.handle_esc();
172        }
173        self.esc_armed = false;
174
175        match self.mode {
176            VimMode::Insert => self.apply_insert(k),
177            VimMode::Normal => self.apply_normal(k),
178            VimMode::Ex => self.apply_ex(k),
179        }
180    }
181
182    fn handle_esc(&mut self) -> EditorAction {
183        // Two Escs in a row → cancel the modal regardless of mode.
184        if self.esc_armed {
185            return EditorAction::Cancel;
186        }
187        self.esc_armed = true;
188        match self.mode {
189            VimMode::Insert | VimMode::Ex => {
190                self.mode = VimMode::Normal;
191                self.ex_buffer.clear();
192            }
193            VimMode::Normal => {
194                // Already Normal — Esc just arms the second-Esc.
195            }
196        }
197        EditorAction::Continue
198    }
199
200    fn apply_insert(&mut self, k: KeyEvent) -> EditorAction {
201        match k.code {
202            KeyCode::Char(c) => {
203                let line = &mut self.lines[self.cursor_row];
204                let col = self.cursor_col.min(line.len());
205                line.insert(col, c);
206                self.cursor_col = col + 1;
207            }
208            KeyCode::Enter => {
209                let line = &mut self.lines[self.cursor_row];
210                let col = self.cursor_col.min(line.len());
211                let tail = line.split_off(col);
212                self.cursor_row += 1;
213                self.lines.insert(self.cursor_row, tail);
214                self.cursor_col = 0;
215            }
216            KeyCode::Backspace => {
217                if self.cursor_col > 0 {
218                    let line = &mut self.lines[self.cursor_row];
219                    let col = self.cursor_col.min(line.len());
220                    line.remove(col - 1);
221                    self.cursor_col = col - 1;
222                } else if self.cursor_row > 0 {
223                    let removed = self.lines.remove(self.cursor_row);
224                    self.cursor_row -= 1;
225                    let prev_len = self.lines[self.cursor_row].len();
226                    self.lines[self.cursor_row].push_str(&removed);
227                    self.cursor_col = prev_len;
228                }
229            }
230            KeyCode::Left => self.move_left(),
231            KeyCode::Right => self.move_right(),
232            KeyCode::Up => self.move_up(),
233            KeyCode::Down => self.move_down(),
234            _ => {}
235        }
236        EditorAction::Continue
237    }
238
239    fn apply_normal(&mut self, k: KeyEvent) -> EditorAction {
240        // Pending-op (dd/yy) machine: when the previous press was
241        // `d` or `y`, the next key either completes the op
242        // (`d`/`y`) or aborts it. Any non-d/y/p key clears the
243        // pending-op so we don't leave the editor stuck.
244        if let Some(op) = self.pending_op {
245            self.pending_op = None;
246            match (op, k.code) {
247                ('d', KeyCode::Char('d')) => {
248                    self.delete_line();
249                    return EditorAction::Continue;
250                }
251                ('y', KeyCode::Char('y')) => {
252                    self.yank_line();
253                    return EditorAction::Continue;
254                }
255                _ => {} // fall through and re-dispatch the key
256            }
257        }
258        match k.code {
259            KeyCode::Char('i') => self.mode = VimMode::Insert,
260            KeyCode::Char('a') => {
261                self.move_right_or_eol();
262                self.mode = VimMode::Insert;
263            }
264            KeyCode::Char('o') => {
265                self.cursor_row += 1;
266                self.lines.insert(self.cursor_row, String::new());
267                self.cursor_col = 0;
268                self.mode = VimMode::Insert;
269            }
270            KeyCode::Char('h') | KeyCode::Left => self.move_left(),
271            KeyCode::Char('l') | KeyCode::Right => self.move_right(),
272            KeyCode::Char('j') | KeyCode::Down => self.move_down(),
273            KeyCode::Char('k') | KeyCode::Up => self.move_up(),
274            KeyCode::Char('0') => self.cursor_col = 0,
275            KeyCode::Char('$') => {
276                self.cursor_col = self.lines[self.cursor_row].len();
277            }
278            KeyCode::Char(':') => {
279                self.mode = VimMode::Ex;
280                self.ex_buffer.clear();
281            }
282            // PR-UI-7 word motions — `w` next word, `b` prev
283            // word, `e` end of word. ASCII-word semantics
284            // (alphanumeric + `_`); good enough for prose +
285            // single-line code we'd compose into mailbox messages.
286            KeyCode::Char('w') => self.move_word_forward(),
287            KeyCode::Char('b') => self.move_word_back(),
288            KeyCode::Char('e') => self.move_word_end(),
289            // PR-UI-7 line ops — first press arms the op, second
290            // press completes. `p` pastes the yank register
291            // below the current line.
292            KeyCode::Char('d') => self.pending_op = Some('d'),
293            KeyCode::Char('y') => self.pending_op = Some('y'),
294            KeyCode::Char('p') => self.paste_below(),
295            _ => {}
296        }
297        EditorAction::Continue
298    }
299
300    fn apply_ex(&mut self, k: KeyEvent) -> EditorAction {
301        match k.code {
302            KeyCode::Char(c) => {
303                self.ex_buffer.push(c);
304                EditorAction::Continue
305            }
306            KeyCode::Backspace => {
307                self.ex_buffer.pop();
308                EditorAction::Continue
309            }
310            KeyCode::Enter => {
311                let cmd = std::mem::take(&mut self.ex_buffer);
312                self.mode = VimMode::Normal;
313                match cmd.trim() {
314                    "wq" | "x" => EditorAction::Send,
315                    "q" | "q!" => EditorAction::Cancel,
316                    "w" => EditorAction::Continue,
317                    _ => EditorAction::Continue,
318                }
319            }
320            _ => EditorAction::Continue,
321        }
322    }
323
324    fn move_left(&mut self) {
325        if self.cursor_col > 0 {
326            self.cursor_col -= 1;
327        }
328    }
329    fn move_right(&mut self) {
330        let len = self.lines[self.cursor_row].len();
331        if self.cursor_col < len {
332            self.cursor_col += 1;
333        }
334    }
335    fn move_right_or_eol(&mut self) {
336        // `a` in vim moves one past the cursor, clamped at EOL.
337        let len = self.lines[self.cursor_row].len();
338        self.cursor_col = (self.cursor_col + 1).min(len);
339    }
340    fn move_word_forward(&mut self) {
341        let line = self.lines[self.cursor_row].as_bytes();
342        let mut i = self.cursor_col;
343        // Skip current word.
344        while i < line.len() && is_word_byte(line[i]) {
345            i += 1;
346        }
347        // Skip whitespace / non-word.
348        while i < line.len() && !is_word_byte(line[i]) {
349            i += 1;
350        }
351        if i == self.cursor_col && self.cursor_row + 1 < self.lines.len() {
352            // At EOL with no further word — advance to next line's start.
353            self.cursor_row += 1;
354            self.cursor_col = 0;
355        } else {
356            self.cursor_col = i;
357        }
358    }
359    fn move_word_back(&mut self) {
360        if self.cursor_col == 0 {
361            if self.cursor_row > 0 {
362                self.cursor_row -= 1;
363                self.cursor_col = self.lines[self.cursor_row].len();
364            }
365            return;
366        }
367        let line = self.lines[self.cursor_row].as_bytes();
368        let mut i = self.cursor_col;
369        // Step back over whitespace.
370        while i > 0 && !is_word_byte(line[i - 1]) {
371            i -= 1;
372        }
373        // Step back to start of word.
374        while i > 0 && is_word_byte(line[i - 1]) {
375            i -= 1;
376        }
377        self.cursor_col = i;
378    }
379    fn move_word_end(&mut self) {
380        let line = self.lines[self.cursor_row].as_bytes();
381        let mut i = self.cursor_col;
382        // If currently in a word, move to its end; if not, find
383        // the next word and move to its end.
384        if i < line.len() && !is_word_byte(line[i]) {
385            while i < line.len() && !is_word_byte(line[i]) {
386                i += 1;
387            }
388        } else if i < line.len()
389            && is_word_byte(line[i])
390            && (i + 1 >= line.len() || !is_word_byte(line[i + 1]))
391        {
392            // Already at EOW — skip past this word's terminator
393            // and find the next word's end.
394            i += 1;
395            while i < line.len() && !is_word_byte(line[i]) {
396                i += 1;
397            }
398        }
399        while i + 1 < line.len() && is_word_byte(line[i + 1]) {
400            i += 1;
401        }
402        if i < line.len() {
403            self.cursor_col = i;
404        }
405    }
406
407    fn delete_line(&mut self) {
408        if self.lines.is_empty() {
409            return;
410        }
411        let removed = self.lines.remove(self.cursor_row);
412        self.yank = vec![removed];
413        if self.lines.is_empty() {
414            self.lines.push(String::new());
415        }
416        if self.cursor_row >= self.lines.len() {
417            self.cursor_row = self.lines.len() - 1;
418        }
419        self.cursor_col = self.cursor_col.min(self.lines[self.cursor_row].len());
420    }
421    fn yank_line(&mut self) {
422        if let Some(line) = self.lines.get(self.cursor_row) {
423            self.yank = vec![line.clone()];
424        }
425    }
426    fn paste_below(&mut self) {
427        if self.yank.is_empty() {
428            return;
429        }
430        let yanked = self.yank.clone();
431        for (offset, line) in yanked.into_iter().enumerate() {
432            self.lines.insert(self.cursor_row + 1 + offset, line);
433        }
434        self.cursor_row += 1;
435        self.cursor_col = 0;
436    }
437
438    fn move_up(&mut self) {
439        if self.cursor_row > 0 {
440            self.cursor_row -= 1;
441            self.cursor_col = self.cursor_col.min(self.lines[self.cursor_row].len());
442        }
443    }
444    fn move_down(&mut self) {
445        if self.cursor_row + 1 < self.lines.len() {
446            self.cursor_row += 1;
447            self.cursor_col = self.cursor_col.min(self.lines[self.cursor_row].len());
448        }
449    }
450}
451
452/// ASCII word-boundary classifier — alphanumeric + `_` is a word
453/// byte, everything else is whitespace / punctuation. Used by the
454/// vim word-motion `w` / `b` / `e` keys.
455fn is_word_byte(b: u8) -> bool {
456    b.is_ascii_alphanumeric() || b == b'_'
457}
458
459pub trait MessageSender: Send + Sync {
460    fn send_dm(&self, root: &Path, agent_id: &str, body: &str) -> Result<()>;
461    fn broadcast(&self, root: &Path, channel_id: &str, body: &str) -> Result<()>;
462}
463
464#[derive(Debug, Default, Clone, Copy)]
465pub struct CliMessageSender;
466
467impl MessageSender for CliMessageSender {
468    fn send_dm(&self, root: &Path, agent_id: &str, body: &str) -> Result<()> {
469        let status = Command::new("teamctl")
470            .arg("--root")
471            .arg(root)
472            .args(["send", agent_id, body])
473            .status()
474            .with_context(|| format!("invoke teamctl send {agent_id}"))?;
475        if !status.success() {
476            anyhow::bail!("teamctl send {agent_id} exited {status}");
477        }
478        Ok(())
479    }
480
481    fn broadcast(&self, root: &Path, channel_id: &str, body: &str) -> Result<()> {
482        // `teamctl broadcast` takes a `#<name>` argument scoped to
483        // the project's compose root. We pass the short name (after
484        // the last `:`); the CLI resolves to the project's channel.
485        let short = channel_id
486            .rsplit_once(':')
487            .map(|(_, n)| n)
488            .unwrap_or(channel_id);
489        let target = format!("#{short}");
490        let status = Command::new("teamctl")
491            .arg("--root")
492            .arg(root)
493            .args(["broadcast", &target, body])
494            .status()
495            .with_context(|| format!("invoke teamctl broadcast {target}"))?;
496        if !status.success() {
497            anyhow::bail!("teamctl broadcast {target} exited {status}");
498        }
499        Ok(())
500    }
501}
502
503pub mod test_support {
504    use super::*;
505    use std::sync::Mutex;
506
507    #[derive(Default)]
508    pub struct MockMessageSender {
509        pub dm_calls: Mutex<Vec<(String, String)>>,
510        pub broadcast_calls: Mutex<Vec<(String, String)>>,
511        /// When set, the next call returns an error of this text.
512        /// Reset after firing so subsequent calls succeed (the
513        /// modal's error-then-success path is a real flow).
514        pub fail_next: Mutex<Option<String>>,
515    }
516
517    impl MessageSender for MockMessageSender {
518        fn send_dm(&self, _root: &Path, agent_id: &str, body: &str) -> Result<()> {
519            if let Some(err) = self.fail_next.lock().unwrap().take() {
520                anyhow::bail!(err);
521            }
522            self.dm_calls
523                .lock()
524                .unwrap()
525                .push((agent_id.into(), body.into()));
526            Ok(())
527        }
528        fn broadcast(&self, _root: &Path, channel_id: &str, body: &str) -> Result<()> {
529            if let Some(err) = self.fail_next.lock().unwrap().take() {
530                anyhow::bail!(err);
531            }
532            self.broadcast_calls
533                .lock()
534                .unwrap()
535                .push((channel_id.into(), body.into()));
536            Ok(())
537        }
538    }
539}
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544
545    fn k(code: KeyCode) -> KeyEvent {
546        KeyEvent::new(code, KeyModifiers::NONE)
547    }
548
549    fn k_ctrl(code: KeyCode) -> KeyEvent {
550        KeyEvent::new(code, KeyModifiers::CONTROL)
551    }
552
553    fn empty_team() -> crate::data::TeamSnapshot {
554        crate::data::TeamSnapshot::empty(std::path::PathBuf::from("/tmp"))
555    }
556
557    #[test]
558    fn dm_target_title_renders_as_arrow_agent() {
559        let team = empty_team();
560        let t = ComposeTarget::Dm {
561            agent_id: "writing:dev1".into(),
562            project_id: "writing".into(),
563        };
564        // Empty team → no display_name override; falls through to the
565        // canonical id.
566        assert_eq!(t.title(&team), "→ writing:dev1");
567    }
568
569    #[test]
570    fn dm_target_title_uses_display_name_when_set() {
571        // T-160: DM modal title swaps in `display_name` when the
572        // target agent has one in the team snapshot.
573        use crate::data::{AgentInfo, TeamSnapshot};
574        use team_core::supervisor::AgentState;
575        let agent = AgentInfo {
576            id: "writing:dev1".into(),
577            agent: "dev1".into(),
578            project: "writing".into(),
579            tmux_session: "a-writing-dev1".into(),
580            state: AgentState::Unknown,
581            unread_mail: 0,
582            pending_approvals: 0,
583            is_manager: false,
584            display_name: Some("Dev 1 (Drafter)".into()),
585        };
586        let team = TeamSnapshot {
587            root: std::path::PathBuf::from("/tmp"),
588            team_name: "t".into(),
589            agents: vec![agent],
590            channels: vec![],
591        };
592        let t = ComposeTarget::Dm {
593            agent_id: "writing:dev1".into(),
594            project_id: "writing".into(),
595        };
596        assert_eq!(t.title(&team), "→ Dev 1 (Drafter)");
597    }
598
599    #[test]
600    fn broadcast_target_title_strips_project_prefix() {
601        let team = empty_team();
602        let t = ComposeTarget::Broadcast {
603            channel_id: "writing:editorial".into(),
604            project_id: "writing".into(),
605        };
606        assert_eq!(t.title(&team), "→ #editorial");
607    }
608
609    #[test]
610    fn editor_starts_in_insert_mode() {
611        let e = Editor::default();
612        assert_eq!(e.mode, VimMode::Insert);
613        assert!(e.is_empty());
614    }
615
616    #[test]
617    fn typing_chars_appends_to_line() {
618        let mut e = Editor::default();
619        for c in "hello".chars() {
620            e.apply_key(k(KeyCode::Char(c)));
621        }
622        assert_eq!(e.lines, vec!["hello"]);
623        assert_eq!(e.cursor_col, 5);
624        assert_eq!(e.body(), "hello");
625    }
626
627    #[test]
628    fn enter_splits_line() {
629        let mut e = Editor::default();
630        for c in "hi".chars() {
631            e.apply_key(k(KeyCode::Char(c)));
632        }
633        e.apply_key(k(KeyCode::Enter));
634        for c in "yo".chars() {
635            e.apply_key(k(KeyCode::Char(c)));
636        }
637        assert_eq!(e.lines, vec!["hi", "yo"]);
638        assert_eq!(e.body(), "hi\nyo");
639    }
640
641    #[test]
642    fn backspace_at_line_start_joins_with_previous() {
643        let mut e = Editor::default();
644        for c in "ab".chars() {
645            e.apply_key(k(KeyCode::Char(c)));
646        }
647        e.apply_key(k(KeyCode::Enter));
648        for c in "cd".chars() {
649            e.apply_key(k(KeyCode::Char(c)));
650        }
651        // Cursor at start of line 2 → Backspace joins.
652        e.cursor_col = 0;
653        e.apply_key(k(KeyCode::Backspace));
654        assert_eq!(e.lines, vec!["abcd"]);
655        assert_eq!(e.cursor_row, 0);
656        assert_eq!(e.cursor_col, 2);
657    }
658
659    #[test]
660    fn esc_from_insert_drops_to_normal() {
661        let mut e = Editor::default();
662        e.apply_key(k(KeyCode::Esc));
663        assert_eq!(e.mode, VimMode::Normal);
664        assert!(e.esc_armed);
665    }
666
667    #[test]
668    fn second_esc_cancels_from_any_mode() {
669        let mut e = Editor::default();
670        // From Insert: first Esc → Normal+armed; second Esc → Cancel.
671        e.apply_key(k(KeyCode::Esc));
672        assert_eq!(e.apply_key(k(KeyCode::Esc)), EditorAction::Cancel);
673
674        // From Normal: first Esc arms; second Esc cancels.
675        let mut e = Editor {
676            mode: VimMode::Normal,
677            ..Editor::default()
678        };
679        assert_eq!(e.apply_key(k(KeyCode::Esc)), EditorAction::Continue);
680        assert_eq!(e.apply_key(k(KeyCode::Esc)), EditorAction::Cancel);
681    }
682
683    #[test]
684    fn ctrl_enter_sends_from_any_mode() {
685        let mut e = Editor::default();
686        for c in "hi".chars() {
687            e.apply_key(k(KeyCode::Char(c)));
688        }
689        assert_eq!(e.apply_key(k_ctrl(KeyCode::Enter)), EditorAction::Send);
690    }
691
692    #[test]
693    fn ex_wq_sends() {
694        let mut e = Editor::default();
695        for c in "hi".chars() {
696            e.apply_key(k(KeyCode::Char(c)));
697        }
698        // Esc → Normal, then `:wq` → Send.
699        e.apply_key(k(KeyCode::Esc));
700        e.apply_key(k(KeyCode::Char(':')));
701        for c in "wq".chars() {
702            e.apply_key(k(KeyCode::Char(c)));
703        }
704        assert_eq!(e.apply_key(k(KeyCode::Enter)), EditorAction::Send);
705    }
706
707    #[test]
708    fn ex_q_cancels() {
709        let mut e = Editor::default();
710        e.apply_key(k(KeyCode::Esc));
711        e.apply_key(k(KeyCode::Char(':')));
712        e.apply_key(k(KeyCode::Char('q')));
713        assert_eq!(e.apply_key(k(KeyCode::Enter)), EditorAction::Cancel);
714    }
715
716    #[test]
717    fn normal_i_re_enters_insert() {
718        let mut e = Editor::default();
719        e.apply_key(k(KeyCode::Esc));
720        // Non-Esc key clears the arm.
721        e.apply_key(k(KeyCode::Char('i')));
722        assert_eq!(e.mode, VimMode::Insert);
723        assert!(!e.esc_armed);
724    }
725
726    #[test]
727    fn hjkl_navigate_in_normal_mode() {
728        let mut e = Editor::default();
729        for c in "abc".chars() {
730            e.apply_key(k(KeyCode::Char(c)));
731        }
732        e.apply_key(k(KeyCode::Esc));
733        e.apply_key(k(KeyCode::Char('0')));
734        assert_eq!(e.cursor_col, 0);
735        e.apply_key(k(KeyCode::Char('l')));
736        e.apply_key(k(KeyCode::Char('l')));
737        assert_eq!(e.cursor_col, 2);
738        e.apply_key(k(KeyCode::Char('h')));
739        assert_eq!(e.cursor_col, 1);
740    }
741
742    #[test]
743    fn body_strips_trailing_blank_lines() {
744        let mut e = Editor::default();
745        for c in "x".chars() {
746            e.apply_key(k(KeyCode::Char(c)));
747        }
748        e.apply_key(k(KeyCode::Enter));
749        e.apply_key(k(KeyCode::Enter));
750        // body is `x\n\n` — strip both trailing newlines.
751        assert_eq!(e.body(), "x");
752    }
753}