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            // T-313: plain Enter in Normal mode is the terminal-
296            // universal submit (Esc → Enter). It carries no modifier
297            // and no kitty/modifyOtherKeys dependency, so it works on
298            // the default-mode terminals where Alt/Ctrl+Enter is eaten.
299            // Normal mode never inserts text, so this doesn't fight
300            // newline entry (that stays Insert-mode Enter).
301            KeyCode::Enter => return EditorAction::Send,
302            _ => {}
303        }
304        EditorAction::Continue
305    }
306
307    fn apply_ex(&mut self, k: KeyEvent) -> EditorAction {
308        match k.code {
309            KeyCode::Char(c) => {
310                self.ex_buffer.push(c);
311                EditorAction::Continue
312            }
313            KeyCode::Backspace => {
314                self.ex_buffer.pop();
315                EditorAction::Continue
316            }
317            KeyCode::Enter => {
318                let cmd = std::mem::take(&mut self.ex_buffer);
319                self.mode = VimMode::Normal;
320                match cmd.trim() {
321                    "wq" | "x" => EditorAction::Send,
322                    "q" | "q!" => EditorAction::Cancel,
323                    "w" => EditorAction::Continue,
324                    _ => EditorAction::Continue,
325                }
326            }
327            _ => EditorAction::Continue,
328        }
329    }
330
331    fn move_left(&mut self) {
332        if self.cursor_col > 0 {
333            self.cursor_col -= 1;
334        }
335    }
336    fn move_right(&mut self) {
337        let len = self.lines[self.cursor_row].len();
338        if self.cursor_col < len {
339            self.cursor_col += 1;
340        }
341    }
342    fn move_right_or_eol(&mut self) {
343        // `a` in vim moves one past the cursor, clamped at EOL.
344        let len = self.lines[self.cursor_row].len();
345        self.cursor_col = (self.cursor_col + 1).min(len);
346    }
347    fn move_word_forward(&mut self) {
348        let line = self.lines[self.cursor_row].as_bytes();
349        let mut i = self.cursor_col;
350        // Skip current word.
351        while i < line.len() && is_word_byte(line[i]) {
352            i += 1;
353        }
354        // Skip whitespace / non-word.
355        while i < line.len() && !is_word_byte(line[i]) {
356            i += 1;
357        }
358        if i == self.cursor_col && self.cursor_row + 1 < self.lines.len() {
359            // At EOL with no further word — advance to next line's start.
360            self.cursor_row += 1;
361            self.cursor_col = 0;
362        } else {
363            self.cursor_col = i;
364        }
365    }
366    fn move_word_back(&mut self) {
367        if self.cursor_col == 0 {
368            if self.cursor_row > 0 {
369                self.cursor_row -= 1;
370                self.cursor_col = self.lines[self.cursor_row].len();
371            }
372            return;
373        }
374        let line = self.lines[self.cursor_row].as_bytes();
375        let mut i = self.cursor_col;
376        // Step back over whitespace.
377        while i > 0 && !is_word_byte(line[i - 1]) {
378            i -= 1;
379        }
380        // Step back to start of word.
381        while i > 0 && is_word_byte(line[i - 1]) {
382            i -= 1;
383        }
384        self.cursor_col = i;
385    }
386    fn move_word_end(&mut self) {
387        let line = self.lines[self.cursor_row].as_bytes();
388        let mut i = self.cursor_col;
389        // If currently in a word, move to its end; if not, find
390        // the next word and move to its end.
391        if i < line.len() && !is_word_byte(line[i]) {
392            while i < line.len() && !is_word_byte(line[i]) {
393                i += 1;
394            }
395        } else if i < line.len()
396            && is_word_byte(line[i])
397            && (i + 1 >= line.len() || !is_word_byte(line[i + 1]))
398        {
399            // Already at EOW — skip past this word's terminator
400            // and find the next word's end.
401            i += 1;
402            while i < line.len() && !is_word_byte(line[i]) {
403                i += 1;
404            }
405        }
406        while i + 1 < line.len() && is_word_byte(line[i + 1]) {
407            i += 1;
408        }
409        if i < line.len() {
410            self.cursor_col = i;
411        }
412    }
413
414    fn delete_line(&mut self) {
415        if self.lines.is_empty() {
416            return;
417        }
418        let removed = self.lines.remove(self.cursor_row);
419        self.yank = vec![removed];
420        if self.lines.is_empty() {
421            self.lines.push(String::new());
422        }
423        if self.cursor_row >= self.lines.len() {
424            self.cursor_row = self.lines.len() - 1;
425        }
426        self.cursor_col = self.cursor_col.min(self.lines[self.cursor_row].len());
427    }
428    fn yank_line(&mut self) {
429        if let Some(line) = self.lines.get(self.cursor_row) {
430            self.yank = vec![line.clone()];
431        }
432    }
433    fn paste_below(&mut self) {
434        if self.yank.is_empty() {
435            return;
436        }
437        let yanked = self.yank.clone();
438        for (offset, line) in yanked.into_iter().enumerate() {
439            self.lines.insert(self.cursor_row + 1 + offset, line);
440        }
441        self.cursor_row += 1;
442        self.cursor_col = 0;
443    }
444
445    fn move_up(&mut self) {
446        if self.cursor_row > 0 {
447            self.cursor_row -= 1;
448            self.cursor_col = self.cursor_col.min(self.lines[self.cursor_row].len());
449        }
450    }
451    fn move_down(&mut self) {
452        if self.cursor_row + 1 < self.lines.len() {
453            self.cursor_row += 1;
454            self.cursor_col = self.cursor_col.min(self.lines[self.cursor_row].len());
455        }
456    }
457}
458
459/// ASCII word-boundary classifier — alphanumeric + `_` is a word
460/// byte, everything else is whitespace / punctuation. Used by the
461/// vim word-motion `w` / `b` / `e` keys.
462fn is_word_byte(b: u8) -> bool {
463    b.is_ascii_alphanumeric() || b == b'_'
464}
465
466pub trait MessageSender: Send + Sync {
467    fn send_dm(&self, root: &Path, agent_id: &str, body: &str) -> Result<()>;
468    fn broadcast(&self, root: &Path, channel_id: &str, body: &str) -> Result<()>;
469}
470
471#[derive(Debug, Default, Clone, Copy)]
472pub struct CliMessageSender;
473
474impl MessageSender for CliMessageSender {
475    fn send_dm(&self, root: &Path, agent_id: &str, body: &str) -> Result<()> {
476        let status = Command::new("teamctl")
477            .arg("--root")
478            .arg(root)
479            .args(["send", agent_id, body])
480            .status()
481            .with_context(|| format!("invoke teamctl send {agent_id}"))?;
482        if !status.success() {
483            anyhow::bail!("teamctl send {agent_id} exited {status}");
484        }
485        Ok(())
486    }
487
488    fn broadcast(&self, root: &Path, channel_id: &str, body: &str) -> Result<()> {
489        // `teamctl broadcast` takes a `#<name>` argument scoped to
490        // the project's compose root. We pass the short name (after
491        // the last `:`); the CLI resolves to the project's channel.
492        let short = channel_id
493            .rsplit_once(':')
494            .map(|(_, n)| n)
495            .unwrap_or(channel_id);
496        let target = format!("#{short}");
497        let status = Command::new("teamctl")
498            .arg("--root")
499            .arg(root)
500            .args(["broadcast", &target, body])
501            .status()
502            .with_context(|| format!("invoke teamctl broadcast {target}"))?;
503        if !status.success() {
504            anyhow::bail!("teamctl broadcast {target} exited {status}");
505        }
506        Ok(())
507    }
508}
509
510pub mod test_support {
511    use super::*;
512    use std::sync::Mutex;
513
514    #[derive(Default)]
515    pub struct MockMessageSender {
516        pub dm_calls: Mutex<Vec<(String, String)>>,
517        pub broadcast_calls: Mutex<Vec<(String, String)>>,
518        /// When set, the next call returns an error of this text.
519        /// Reset after firing so subsequent calls succeed (the
520        /// modal's error-then-success path is a real flow).
521        pub fail_next: Mutex<Option<String>>,
522    }
523
524    impl MessageSender for MockMessageSender {
525        fn send_dm(&self, _root: &Path, agent_id: &str, body: &str) -> Result<()> {
526            if let Some(err) = self.fail_next.lock().unwrap().take() {
527                anyhow::bail!(err);
528            }
529            self.dm_calls
530                .lock()
531                .unwrap()
532                .push((agent_id.into(), body.into()));
533            Ok(())
534        }
535        fn broadcast(&self, _root: &Path, channel_id: &str, body: &str) -> Result<()> {
536            if let Some(err) = self.fail_next.lock().unwrap().take() {
537                anyhow::bail!(err);
538            }
539            self.broadcast_calls
540                .lock()
541                .unwrap()
542                .push((channel_id.into(), body.into()));
543            Ok(())
544        }
545    }
546}
547
548#[cfg(test)]
549mod tests {
550    use super::*;
551
552    fn k(code: KeyCode) -> KeyEvent {
553        KeyEvent::new(code, KeyModifiers::NONE)
554    }
555
556    fn k_ctrl(code: KeyCode) -> KeyEvent {
557        KeyEvent::new(code, KeyModifiers::CONTROL)
558    }
559
560    fn empty_team() -> crate::data::TeamSnapshot {
561        crate::data::TeamSnapshot::empty(std::path::PathBuf::from("/tmp"))
562    }
563
564    #[test]
565    fn dm_target_title_renders_as_arrow_agent() {
566        let team = empty_team();
567        let t = ComposeTarget::Dm {
568            agent_id: "writing:dev1".into(),
569            project_id: "writing".into(),
570        };
571        // Empty team → no display_name override; falls through to the
572        // canonical id.
573        assert_eq!(t.title(&team), "→ writing:dev1");
574    }
575
576    #[test]
577    fn dm_target_title_uses_display_name_when_set() {
578        // T-160: DM modal title swaps in `display_name` when the
579        // target agent has one in the team snapshot.
580        use crate::data::{AgentInfo, TeamSnapshot};
581        use team_core::supervisor::AgentState;
582        let agent = AgentInfo {
583            id: "writing:dev1".into(),
584            agent: "dev1".into(),
585            project: "writing".into(),
586            tmux_session: "a-writing-dev1".into(),
587            state: AgentState::Unknown,
588            unread_mail: 0,
589            pending_approvals: 0,
590            is_manager: false,
591            display_name: Some("Dev 1 (Drafter)".into()),
592            rate_limit_resets_at: None,
593            reports_to: None,
594        };
595        let team = TeamSnapshot {
596            root: std::path::PathBuf::from("/tmp"),
597            team_name: "t".into(),
598            agents: vec![agent],
599            channels: vec![],
600        };
601        let t = ComposeTarget::Dm {
602            agent_id: "writing:dev1".into(),
603            project_id: "writing".into(),
604        };
605        assert_eq!(t.title(&team), "→ Dev 1 (Drafter)");
606    }
607
608    #[test]
609    fn broadcast_target_title_strips_project_prefix() {
610        let team = empty_team();
611        let t = ComposeTarget::Broadcast {
612            channel_id: "writing:editorial".into(),
613            project_id: "writing".into(),
614        };
615        assert_eq!(t.title(&team), "→ #editorial");
616    }
617
618    #[test]
619    fn editor_starts_in_insert_mode() {
620        let e = Editor::default();
621        assert_eq!(e.mode, VimMode::Insert);
622        assert!(e.is_empty());
623    }
624
625    #[test]
626    fn typing_chars_appends_to_line() {
627        let mut e = Editor::default();
628        for c in "hello".chars() {
629            e.apply_key(k(KeyCode::Char(c)));
630        }
631        assert_eq!(e.lines, vec!["hello"]);
632        assert_eq!(e.cursor_col, 5);
633        assert_eq!(e.body(), "hello");
634    }
635
636    #[test]
637    fn enter_splits_line() {
638        let mut e = Editor::default();
639        for c in "hi".chars() {
640            e.apply_key(k(KeyCode::Char(c)));
641        }
642        e.apply_key(k(KeyCode::Enter));
643        for c in "yo".chars() {
644            e.apply_key(k(KeyCode::Char(c)));
645        }
646        assert_eq!(e.lines, vec!["hi", "yo"]);
647        assert_eq!(e.body(), "hi\nyo");
648    }
649
650    #[test]
651    fn backspace_at_line_start_joins_with_previous() {
652        let mut e = Editor::default();
653        for c in "ab".chars() {
654            e.apply_key(k(KeyCode::Char(c)));
655        }
656        e.apply_key(k(KeyCode::Enter));
657        for c in "cd".chars() {
658            e.apply_key(k(KeyCode::Char(c)));
659        }
660        // Cursor at start of line 2 → Backspace joins.
661        e.cursor_col = 0;
662        e.apply_key(k(KeyCode::Backspace));
663        assert_eq!(e.lines, vec!["abcd"]);
664        assert_eq!(e.cursor_row, 0);
665        assert_eq!(e.cursor_col, 2);
666    }
667
668    #[test]
669    fn esc_from_insert_drops_to_normal() {
670        let mut e = Editor::default();
671        e.apply_key(k(KeyCode::Esc));
672        assert_eq!(e.mode, VimMode::Normal);
673        assert!(e.esc_armed);
674    }
675
676    #[test]
677    fn second_esc_cancels_from_any_mode() {
678        let mut e = Editor::default();
679        // From Insert: first Esc → Normal+armed; second Esc → Cancel.
680        e.apply_key(k(KeyCode::Esc));
681        assert_eq!(e.apply_key(k(KeyCode::Esc)), EditorAction::Cancel);
682
683        // From Normal: first Esc arms; second Esc cancels.
684        let mut e = Editor {
685            mode: VimMode::Normal,
686            ..Editor::default()
687        };
688        assert_eq!(e.apply_key(k(KeyCode::Esc)), EditorAction::Continue);
689        assert_eq!(e.apply_key(k(KeyCode::Esc)), EditorAction::Cancel);
690    }
691
692    #[test]
693    fn ctrl_enter_sends_from_any_mode() {
694        let mut e = Editor::default();
695        for c in "hi".chars() {
696            e.apply_key(k(KeyCode::Char(c)));
697        }
698        assert_eq!(e.apply_key(k_ctrl(KeyCode::Enter)), EditorAction::Send);
699    }
700
701    #[test]
702    fn normal_mode_enter_sends() {
703        // T-313: plain Enter in Normal mode is the terminal-universal
704        // submit (Esc → Enter). No modifier, no kitty/modifyOtherKeys
705        // dependency — works where Alt/Ctrl+Enter is eaten.
706        let mut e = Editor::default();
707        for c in "hi".chars() {
708            e.apply_key(k(KeyCode::Char(c)));
709        }
710        e.apply_key(k(KeyCode::Esc)); // Insert → Normal
711        assert_eq!(e.apply_key(k(KeyCode::Enter)), EditorAction::Send);
712    }
713
714    #[test]
715    fn insert_mode_enter_still_inserts_newline_not_send() {
716        // T-313 regression pin: the universal submit is Normal-mode
717        // only. Plain Enter while typing must still add a line, or
718        // multi-line compose breaks.
719        let mut e = Editor::default();
720        for c in "ab".chars() {
721            e.apply_key(k(KeyCode::Char(c)));
722        }
723        assert_eq!(e.apply_key(k(KeyCode::Enter)), EditorAction::Continue);
724        assert_eq!(e.lines.len(), 2, "Insert Enter must split the line");
725    }
726
727    #[test]
728    fn alt_enter_still_sends_for_kitty_protocol_terminals() {
729        // T-313: don't regress the advanced-terminal fast path.
730        let mut e = Editor::default();
731        let alt_enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT);
732        assert_eq!(e.apply_key(alt_enter), EditorAction::Send);
733    }
734
735    #[test]
736    fn ex_wq_sends() {
737        let mut e = Editor::default();
738        for c in "hi".chars() {
739            e.apply_key(k(KeyCode::Char(c)));
740        }
741        // Esc → Normal, then `:wq` → Send.
742        e.apply_key(k(KeyCode::Esc));
743        e.apply_key(k(KeyCode::Char(':')));
744        for c in "wq".chars() {
745            e.apply_key(k(KeyCode::Char(c)));
746        }
747        assert_eq!(e.apply_key(k(KeyCode::Enter)), EditorAction::Send);
748    }
749
750    #[test]
751    fn ex_q_cancels() {
752        let mut e = Editor::default();
753        e.apply_key(k(KeyCode::Esc));
754        e.apply_key(k(KeyCode::Char(':')));
755        e.apply_key(k(KeyCode::Char('q')));
756        assert_eq!(e.apply_key(k(KeyCode::Enter)), EditorAction::Cancel);
757    }
758
759    #[test]
760    fn normal_i_re_enters_insert() {
761        let mut e = Editor::default();
762        e.apply_key(k(KeyCode::Esc));
763        // Non-Esc key clears the arm.
764        e.apply_key(k(KeyCode::Char('i')));
765        assert_eq!(e.mode, VimMode::Insert);
766        assert!(!e.esc_armed);
767    }
768
769    #[test]
770    fn hjkl_navigate_in_normal_mode() {
771        let mut e = Editor::default();
772        for c in "abc".chars() {
773            e.apply_key(k(KeyCode::Char(c)));
774        }
775        e.apply_key(k(KeyCode::Esc));
776        e.apply_key(k(KeyCode::Char('0')));
777        assert_eq!(e.cursor_col, 0);
778        e.apply_key(k(KeyCode::Char('l')));
779        e.apply_key(k(KeyCode::Char('l')));
780        assert_eq!(e.cursor_col, 2);
781        e.apply_key(k(KeyCode::Char('h')));
782        assert_eq!(e.cursor_col, 1);
783    }
784
785    #[test]
786    fn body_strips_trailing_blank_lines() {
787        let mut e = Editor::default();
788        for c in "x".chars() {
789            e.apply_key(k(KeyCode::Char(c)));
790        }
791        e.apply_key(k(KeyCode::Enter));
792        e.apply_key(k(KeyCode::Enter));
793        // body is `x\n\n` — strip both trailing newlines.
794        assert_eq!(e.body(), "x");
795    }
796}