1use 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 {
35 agent_id: String,
36 project_id: String,
37 },
38 Broadcast {
41 channel_id: String,
42 project_id: String,
43 },
44}
45
46impl ComposeTarget {
47 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 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 pub esc_armed: bool,
91 pub yank: Vec<String>,
95 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 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 Continue,
124 Send,
126 Cancel,
128}
129
130impl Editor {
131 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 pub fn apply_key(&mut self, k: KeyEvent) -> EditorAction {
150 if k.kind != KeyEventKind::Press {
151 return EditorAction::Continue;
152 }
153
154 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 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 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 }
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 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 _ => {} }
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 KeyCode::Char('w') => self.move_word_forward(),
287 KeyCode::Char('b') => self.move_word_back(),
288 KeyCode::Char('e') => self.move_word_end(),
289 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 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 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 while i < line.len() && is_word_byte(line[i]) {
352 i += 1;
353 }
354 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 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 while i > 0 && !is_word_byte(line[i - 1]) {
378 i -= 1;
379 }
380 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 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 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
459fn 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 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 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 assert_eq!(t.title(&team), "→ writing:dev1");
574 }
575
576 #[test]
577 fn dm_target_title_uses_display_name_when_set() {
578 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 last_activity_at: None,
594 reports_to: None,
595 };
596 let team = TeamSnapshot {
597 root: std::path::PathBuf::from("/tmp"),
598 team_name: "t".into(),
599 agents: vec![agent],
600 channels: vec![],
601 };
602 let t = ComposeTarget::Dm {
603 agent_id: "writing:dev1".into(),
604 project_id: "writing".into(),
605 };
606 assert_eq!(t.title(&team), "→ Dev 1 (Drafter)");
607 }
608
609 #[test]
610 fn broadcast_target_title_strips_project_prefix() {
611 let team = empty_team();
612 let t = ComposeTarget::Broadcast {
613 channel_id: "writing:editorial".into(),
614 project_id: "writing".into(),
615 };
616 assert_eq!(t.title(&team), "→ #editorial");
617 }
618
619 #[test]
620 fn editor_starts_in_insert_mode() {
621 let e = Editor::default();
622 assert_eq!(e.mode, VimMode::Insert);
623 assert!(e.is_empty());
624 }
625
626 #[test]
627 fn typing_chars_appends_to_line() {
628 let mut e = Editor::default();
629 for c in "hello".chars() {
630 e.apply_key(k(KeyCode::Char(c)));
631 }
632 assert_eq!(e.lines, vec!["hello"]);
633 assert_eq!(e.cursor_col, 5);
634 assert_eq!(e.body(), "hello");
635 }
636
637 #[test]
638 fn enter_splits_line() {
639 let mut e = Editor::default();
640 for c in "hi".chars() {
641 e.apply_key(k(KeyCode::Char(c)));
642 }
643 e.apply_key(k(KeyCode::Enter));
644 for c in "yo".chars() {
645 e.apply_key(k(KeyCode::Char(c)));
646 }
647 assert_eq!(e.lines, vec!["hi", "yo"]);
648 assert_eq!(e.body(), "hi\nyo");
649 }
650
651 #[test]
652 fn backspace_at_line_start_joins_with_previous() {
653 let mut e = Editor::default();
654 for c in "ab".chars() {
655 e.apply_key(k(KeyCode::Char(c)));
656 }
657 e.apply_key(k(KeyCode::Enter));
658 for c in "cd".chars() {
659 e.apply_key(k(KeyCode::Char(c)));
660 }
661 e.cursor_col = 0;
663 e.apply_key(k(KeyCode::Backspace));
664 assert_eq!(e.lines, vec!["abcd"]);
665 assert_eq!(e.cursor_row, 0);
666 assert_eq!(e.cursor_col, 2);
667 }
668
669 #[test]
670 fn esc_from_insert_drops_to_normal() {
671 let mut e = Editor::default();
672 e.apply_key(k(KeyCode::Esc));
673 assert_eq!(e.mode, VimMode::Normal);
674 assert!(e.esc_armed);
675 }
676
677 #[test]
678 fn second_esc_cancels_from_any_mode() {
679 let mut e = Editor::default();
680 e.apply_key(k(KeyCode::Esc));
682 assert_eq!(e.apply_key(k(KeyCode::Esc)), EditorAction::Cancel);
683
684 let mut e = Editor {
686 mode: VimMode::Normal,
687 ..Editor::default()
688 };
689 assert_eq!(e.apply_key(k(KeyCode::Esc)), EditorAction::Continue);
690 assert_eq!(e.apply_key(k(KeyCode::Esc)), EditorAction::Cancel);
691 }
692
693 #[test]
694 fn ctrl_enter_sends_from_any_mode() {
695 let mut e = Editor::default();
696 for c in "hi".chars() {
697 e.apply_key(k(KeyCode::Char(c)));
698 }
699 assert_eq!(e.apply_key(k_ctrl(KeyCode::Enter)), EditorAction::Send);
700 }
701
702 #[test]
703 fn normal_mode_enter_sends() {
704 let mut e = Editor::default();
708 for c in "hi".chars() {
709 e.apply_key(k(KeyCode::Char(c)));
710 }
711 e.apply_key(k(KeyCode::Esc)); assert_eq!(e.apply_key(k(KeyCode::Enter)), EditorAction::Send);
713 }
714
715 #[test]
716 fn insert_mode_enter_still_inserts_newline_not_send() {
717 let mut e = Editor::default();
721 for c in "ab".chars() {
722 e.apply_key(k(KeyCode::Char(c)));
723 }
724 assert_eq!(e.apply_key(k(KeyCode::Enter)), EditorAction::Continue);
725 assert_eq!(e.lines.len(), 2, "Insert Enter must split the line");
726 }
727
728 #[test]
729 fn alt_enter_still_sends_for_kitty_protocol_terminals() {
730 let mut e = Editor::default();
732 let alt_enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT);
733 assert_eq!(e.apply_key(alt_enter), EditorAction::Send);
734 }
735
736 #[test]
737 fn ex_wq_sends() {
738 let mut e = Editor::default();
739 for c in "hi".chars() {
740 e.apply_key(k(KeyCode::Char(c)));
741 }
742 e.apply_key(k(KeyCode::Esc));
744 e.apply_key(k(KeyCode::Char(':')));
745 for c in "wq".chars() {
746 e.apply_key(k(KeyCode::Char(c)));
747 }
748 assert_eq!(e.apply_key(k(KeyCode::Enter)), EditorAction::Send);
749 }
750
751 #[test]
752 fn ex_q_cancels() {
753 let mut e = Editor::default();
754 e.apply_key(k(KeyCode::Esc));
755 e.apply_key(k(KeyCode::Char(':')));
756 e.apply_key(k(KeyCode::Char('q')));
757 assert_eq!(e.apply_key(k(KeyCode::Enter)), EditorAction::Cancel);
758 }
759
760 #[test]
761 fn normal_i_re_enters_insert() {
762 let mut e = Editor::default();
763 e.apply_key(k(KeyCode::Esc));
764 e.apply_key(k(KeyCode::Char('i')));
766 assert_eq!(e.mode, VimMode::Insert);
767 assert!(!e.esc_armed);
768 }
769
770 #[test]
771 fn hjkl_navigate_in_normal_mode() {
772 let mut e = Editor::default();
773 for c in "abc".chars() {
774 e.apply_key(k(KeyCode::Char(c)));
775 }
776 e.apply_key(k(KeyCode::Esc));
777 e.apply_key(k(KeyCode::Char('0')));
778 assert_eq!(e.cursor_col, 0);
779 e.apply_key(k(KeyCode::Char('l')));
780 e.apply_key(k(KeyCode::Char('l')));
781 assert_eq!(e.cursor_col, 2);
782 e.apply_key(k(KeyCode::Char('h')));
783 assert_eq!(e.cursor_col, 1);
784 }
785
786 #[test]
787 fn body_strips_trailing_blank_lines() {
788 let mut e = Editor::default();
789 for c in "x".chars() {
790 e.apply_key(k(KeyCode::Char(c)));
791 }
792 e.apply_key(k(KeyCode::Enter));
793 e.apply_key(k(KeyCode::Enter));
794 assert_eq!(e.body(), "x");
796 }
797}