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 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 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 e.apply_key(k(KeyCode::Esc));
681 assert_eq!(e.apply_key(k(KeyCode::Esc)), EditorAction::Cancel);
682
683 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 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)); 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 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 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 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 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 assert_eq!(e.body(), "x");
795 }
796}