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 _ => {}
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 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 while i < line.len() && is_word_byte(line[i]) {
345 i += 1;
346 }
347 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 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 while i > 0 && !is_word_byte(line[i - 1]) {
371 i -= 1;
372 }
373 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 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 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
452fn 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 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 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 assert_eq!(t.title(&team), "→ writing:dev1");
567 }
568
569 #[test]
570 fn dm_target_title_uses_display_name_when_set() {
571 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 rate_limit_resets_at: None,
586 };
587 let team = TeamSnapshot {
588 root: std::path::PathBuf::from("/tmp"),
589 team_name: "t".into(),
590 agents: vec![agent],
591 channels: vec![],
592 };
593 let t = ComposeTarget::Dm {
594 agent_id: "writing:dev1".into(),
595 project_id: "writing".into(),
596 };
597 assert_eq!(t.title(&team), "→ Dev 1 (Drafter)");
598 }
599
600 #[test]
601 fn broadcast_target_title_strips_project_prefix() {
602 let team = empty_team();
603 let t = ComposeTarget::Broadcast {
604 channel_id: "writing:editorial".into(),
605 project_id: "writing".into(),
606 };
607 assert_eq!(t.title(&team), "→ #editorial");
608 }
609
610 #[test]
611 fn editor_starts_in_insert_mode() {
612 let e = Editor::default();
613 assert_eq!(e.mode, VimMode::Insert);
614 assert!(e.is_empty());
615 }
616
617 #[test]
618 fn typing_chars_appends_to_line() {
619 let mut e = Editor::default();
620 for c in "hello".chars() {
621 e.apply_key(k(KeyCode::Char(c)));
622 }
623 assert_eq!(e.lines, vec!["hello"]);
624 assert_eq!(e.cursor_col, 5);
625 assert_eq!(e.body(), "hello");
626 }
627
628 #[test]
629 fn enter_splits_line() {
630 let mut e = Editor::default();
631 for c in "hi".chars() {
632 e.apply_key(k(KeyCode::Char(c)));
633 }
634 e.apply_key(k(KeyCode::Enter));
635 for c in "yo".chars() {
636 e.apply_key(k(KeyCode::Char(c)));
637 }
638 assert_eq!(e.lines, vec!["hi", "yo"]);
639 assert_eq!(e.body(), "hi\nyo");
640 }
641
642 #[test]
643 fn backspace_at_line_start_joins_with_previous() {
644 let mut e = Editor::default();
645 for c in "ab".chars() {
646 e.apply_key(k(KeyCode::Char(c)));
647 }
648 e.apply_key(k(KeyCode::Enter));
649 for c in "cd".chars() {
650 e.apply_key(k(KeyCode::Char(c)));
651 }
652 e.cursor_col = 0;
654 e.apply_key(k(KeyCode::Backspace));
655 assert_eq!(e.lines, vec!["abcd"]);
656 assert_eq!(e.cursor_row, 0);
657 assert_eq!(e.cursor_col, 2);
658 }
659
660 #[test]
661 fn esc_from_insert_drops_to_normal() {
662 let mut e = Editor::default();
663 e.apply_key(k(KeyCode::Esc));
664 assert_eq!(e.mode, VimMode::Normal);
665 assert!(e.esc_armed);
666 }
667
668 #[test]
669 fn second_esc_cancels_from_any_mode() {
670 let mut e = Editor::default();
671 e.apply_key(k(KeyCode::Esc));
673 assert_eq!(e.apply_key(k(KeyCode::Esc)), EditorAction::Cancel);
674
675 let mut e = Editor {
677 mode: VimMode::Normal,
678 ..Editor::default()
679 };
680 assert_eq!(e.apply_key(k(KeyCode::Esc)), EditorAction::Continue);
681 assert_eq!(e.apply_key(k(KeyCode::Esc)), EditorAction::Cancel);
682 }
683
684 #[test]
685 fn ctrl_enter_sends_from_any_mode() {
686 let mut e = Editor::default();
687 for c in "hi".chars() {
688 e.apply_key(k(KeyCode::Char(c)));
689 }
690 assert_eq!(e.apply_key(k_ctrl(KeyCode::Enter)), EditorAction::Send);
691 }
692
693 #[test]
694 fn ex_wq_sends() {
695 let mut e = Editor::default();
696 for c in "hi".chars() {
697 e.apply_key(k(KeyCode::Char(c)));
698 }
699 e.apply_key(k(KeyCode::Esc));
701 e.apply_key(k(KeyCode::Char(':')));
702 for c in "wq".chars() {
703 e.apply_key(k(KeyCode::Char(c)));
704 }
705 assert_eq!(e.apply_key(k(KeyCode::Enter)), EditorAction::Send);
706 }
707
708 #[test]
709 fn ex_q_cancels() {
710 let mut e = Editor::default();
711 e.apply_key(k(KeyCode::Esc));
712 e.apply_key(k(KeyCode::Char(':')));
713 e.apply_key(k(KeyCode::Char('q')));
714 assert_eq!(e.apply_key(k(KeyCode::Enter)), EditorAction::Cancel);
715 }
716
717 #[test]
718 fn normal_i_re_enters_insert() {
719 let mut e = Editor::default();
720 e.apply_key(k(KeyCode::Esc));
721 e.apply_key(k(KeyCode::Char('i')));
723 assert_eq!(e.mode, VimMode::Insert);
724 assert!(!e.esc_armed);
725 }
726
727 #[test]
728 fn hjkl_navigate_in_normal_mode() {
729 let mut e = Editor::default();
730 for c in "abc".chars() {
731 e.apply_key(k(KeyCode::Char(c)));
732 }
733 e.apply_key(k(KeyCode::Esc));
734 e.apply_key(k(KeyCode::Char('0')));
735 assert_eq!(e.cursor_col, 0);
736 e.apply_key(k(KeyCode::Char('l')));
737 e.apply_key(k(KeyCode::Char('l')));
738 assert_eq!(e.cursor_col, 2);
739 e.apply_key(k(KeyCode::Char('h')));
740 assert_eq!(e.cursor_col, 1);
741 }
742
743 #[test]
744 fn body_strips_trailing_blank_lines() {
745 let mut e = Editor::default();
746 for c in "x".chars() {
747 e.apply_key(k(KeyCode::Char(c)));
748 }
749 e.apply_key(k(KeyCode::Enter));
750 e.apply_key(k(KeyCode::Enter));
751 assert_eq!(e.body(), "x");
753 }
754}