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) -> String {
48 match self {
49 ComposeTarget::Dm { agent_id, .. } => format!("→ {agent_id}"),
50 ComposeTarget::Broadcast { channel_id, .. } => {
51 let short = channel_id
52 .rsplit_once(':')
53 .map(|(_, n)| n)
54 .unwrap_or(channel_id);
55 format!("→ #{short}")
56 }
57 }
58 }
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum VimMode {
63 Normal,
64 Insert,
65 Ex,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct Editor {
72 pub lines: Vec<String>,
73 pub cursor_row: usize,
74 pub cursor_col: usize,
75 pub mode: VimMode,
76 pub ex_buffer: String,
77 pub esc_armed: bool,
81 pub yank: Vec<String>,
85 pub pending_op: Option<char>,
90}
91
92impl Default for Editor {
93 fn default() -> Self {
94 Self {
95 lines: vec![String::new()],
96 cursor_row: 0,
97 cursor_col: 0,
98 mode: VimMode::Insert,
102 ex_buffer: String::new(),
103 esc_armed: false,
104 yank: Vec::new(),
105 pending_op: None,
106 }
107 }
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub enum EditorAction {
112 Continue,
114 Send,
116 Cancel,
118}
119
120impl Editor {
121 pub fn body(&self) -> String {
125 let mut out = self.lines.join("\n");
126 while out.ends_with('\n') {
127 out.pop();
128 }
129 out
130 }
131
132 pub fn is_empty(&self) -> bool {
133 self.lines.iter().all(|l| l.is_empty())
134 }
135
136 pub fn apply_key(&mut self, k: KeyEvent) -> EditorAction {
140 if k.kind != KeyEventKind::Press {
141 return EditorAction::Continue;
142 }
143
144 if k.code == KeyCode::Enter
151 && (k.modifiers.contains(KeyModifiers::ALT)
152 || k.modifiers.contains(KeyModifiers::CONTROL))
153 {
154 return EditorAction::Send;
155 }
156
157 if k.code == KeyCode::Esc {
161 return self.handle_esc();
162 }
163 self.esc_armed = false;
164
165 match self.mode {
166 VimMode::Insert => self.apply_insert(k),
167 VimMode::Normal => self.apply_normal(k),
168 VimMode::Ex => self.apply_ex(k),
169 }
170 }
171
172 fn handle_esc(&mut self) -> EditorAction {
173 if self.esc_armed {
175 return EditorAction::Cancel;
176 }
177 self.esc_armed = true;
178 match self.mode {
179 VimMode::Insert | VimMode::Ex => {
180 self.mode = VimMode::Normal;
181 self.ex_buffer.clear();
182 }
183 VimMode::Normal => {
184 }
186 }
187 EditorAction::Continue
188 }
189
190 fn apply_insert(&mut self, k: KeyEvent) -> EditorAction {
191 match k.code {
192 KeyCode::Char(c) => {
193 let line = &mut self.lines[self.cursor_row];
194 let col = self.cursor_col.min(line.len());
195 line.insert(col, c);
196 self.cursor_col = col + 1;
197 }
198 KeyCode::Enter => {
199 let line = &mut self.lines[self.cursor_row];
200 let col = self.cursor_col.min(line.len());
201 let tail = line.split_off(col);
202 self.cursor_row += 1;
203 self.lines.insert(self.cursor_row, tail);
204 self.cursor_col = 0;
205 }
206 KeyCode::Backspace => {
207 if self.cursor_col > 0 {
208 let line = &mut self.lines[self.cursor_row];
209 let col = self.cursor_col.min(line.len());
210 line.remove(col - 1);
211 self.cursor_col = col - 1;
212 } else if self.cursor_row > 0 {
213 let removed = self.lines.remove(self.cursor_row);
214 self.cursor_row -= 1;
215 let prev_len = self.lines[self.cursor_row].len();
216 self.lines[self.cursor_row].push_str(&removed);
217 self.cursor_col = prev_len;
218 }
219 }
220 KeyCode::Left => self.move_left(),
221 KeyCode::Right => self.move_right(),
222 KeyCode::Up => self.move_up(),
223 KeyCode::Down => self.move_down(),
224 _ => {}
225 }
226 EditorAction::Continue
227 }
228
229 fn apply_normal(&mut self, k: KeyEvent) -> EditorAction {
230 if let Some(op) = self.pending_op {
235 self.pending_op = None;
236 match (op, k.code) {
237 ('d', KeyCode::Char('d')) => {
238 self.delete_line();
239 return EditorAction::Continue;
240 }
241 ('y', KeyCode::Char('y')) => {
242 self.yank_line();
243 return EditorAction::Continue;
244 }
245 _ => {} }
247 }
248 match k.code {
249 KeyCode::Char('i') => self.mode = VimMode::Insert,
250 KeyCode::Char('a') => {
251 self.move_right_or_eol();
252 self.mode = VimMode::Insert;
253 }
254 KeyCode::Char('o') => {
255 self.cursor_row += 1;
256 self.lines.insert(self.cursor_row, String::new());
257 self.cursor_col = 0;
258 self.mode = VimMode::Insert;
259 }
260 KeyCode::Char('h') | KeyCode::Left => self.move_left(),
261 KeyCode::Char('l') | KeyCode::Right => self.move_right(),
262 KeyCode::Char('j') | KeyCode::Down => self.move_down(),
263 KeyCode::Char('k') | KeyCode::Up => self.move_up(),
264 KeyCode::Char('0') => self.cursor_col = 0,
265 KeyCode::Char('$') => {
266 self.cursor_col = self.lines[self.cursor_row].len();
267 }
268 KeyCode::Char(':') => {
269 self.mode = VimMode::Ex;
270 self.ex_buffer.clear();
271 }
272 KeyCode::Char('w') => self.move_word_forward(),
277 KeyCode::Char('b') => self.move_word_back(),
278 KeyCode::Char('e') => self.move_word_end(),
279 KeyCode::Char('d') => self.pending_op = Some('d'),
283 KeyCode::Char('y') => self.pending_op = Some('y'),
284 KeyCode::Char('p') => self.paste_below(),
285 _ => {}
286 }
287 EditorAction::Continue
288 }
289
290 fn apply_ex(&mut self, k: KeyEvent) -> EditorAction {
291 match k.code {
292 KeyCode::Char(c) => {
293 self.ex_buffer.push(c);
294 EditorAction::Continue
295 }
296 KeyCode::Backspace => {
297 self.ex_buffer.pop();
298 EditorAction::Continue
299 }
300 KeyCode::Enter => {
301 let cmd = std::mem::take(&mut self.ex_buffer);
302 self.mode = VimMode::Normal;
303 match cmd.trim() {
304 "wq" | "x" => EditorAction::Send,
305 "q" | "q!" => EditorAction::Cancel,
306 "w" => EditorAction::Continue,
307 _ => EditorAction::Continue,
308 }
309 }
310 _ => EditorAction::Continue,
311 }
312 }
313
314 fn move_left(&mut self) {
315 if self.cursor_col > 0 {
316 self.cursor_col -= 1;
317 }
318 }
319 fn move_right(&mut self) {
320 let len = self.lines[self.cursor_row].len();
321 if self.cursor_col < len {
322 self.cursor_col += 1;
323 }
324 }
325 fn move_right_or_eol(&mut self) {
326 let len = self.lines[self.cursor_row].len();
328 self.cursor_col = (self.cursor_col + 1).min(len);
329 }
330 fn move_word_forward(&mut self) {
331 let line = self.lines[self.cursor_row].as_bytes();
332 let mut i = self.cursor_col;
333 while i < line.len() && is_word_byte(line[i]) {
335 i += 1;
336 }
337 while i < line.len() && !is_word_byte(line[i]) {
339 i += 1;
340 }
341 if i == self.cursor_col && self.cursor_row + 1 < self.lines.len() {
342 self.cursor_row += 1;
344 self.cursor_col = 0;
345 } else {
346 self.cursor_col = i;
347 }
348 }
349 fn move_word_back(&mut self) {
350 if self.cursor_col == 0 {
351 if self.cursor_row > 0 {
352 self.cursor_row -= 1;
353 self.cursor_col = self.lines[self.cursor_row].len();
354 }
355 return;
356 }
357 let line = self.lines[self.cursor_row].as_bytes();
358 let mut i = self.cursor_col;
359 while i > 0 && !is_word_byte(line[i - 1]) {
361 i -= 1;
362 }
363 while i > 0 && is_word_byte(line[i - 1]) {
365 i -= 1;
366 }
367 self.cursor_col = i;
368 }
369 fn move_word_end(&mut self) {
370 let line = self.lines[self.cursor_row].as_bytes();
371 let mut i = self.cursor_col;
372 if i < line.len() && !is_word_byte(line[i]) {
375 while i < line.len() && !is_word_byte(line[i]) {
376 i += 1;
377 }
378 } else if i < line.len()
379 && is_word_byte(line[i])
380 && (i + 1 >= line.len() || !is_word_byte(line[i + 1]))
381 {
382 i += 1;
385 while i < line.len() && !is_word_byte(line[i]) {
386 i += 1;
387 }
388 }
389 while i + 1 < line.len() && is_word_byte(line[i + 1]) {
390 i += 1;
391 }
392 if i < line.len() {
393 self.cursor_col = i;
394 }
395 }
396
397 fn delete_line(&mut self) {
398 if self.lines.is_empty() {
399 return;
400 }
401 let removed = self.lines.remove(self.cursor_row);
402 self.yank = vec![removed];
403 if self.lines.is_empty() {
404 self.lines.push(String::new());
405 }
406 if self.cursor_row >= self.lines.len() {
407 self.cursor_row = self.lines.len() - 1;
408 }
409 self.cursor_col = self.cursor_col.min(self.lines[self.cursor_row].len());
410 }
411 fn yank_line(&mut self) {
412 if let Some(line) = self.lines.get(self.cursor_row) {
413 self.yank = vec![line.clone()];
414 }
415 }
416 fn paste_below(&mut self) {
417 if self.yank.is_empty() {
418 return;
419 }
420 let yanked = self.yank.clone();
421 for (offset, line) in yanked.into_iter().enumerate() {
422 self.lines.insert(self.cursor_row + 1 + offset, line);
423 }
424 self.cursor_row += 1;
425 self.cursor_col = 0;
426 }
427
428 fn move_up(&mut self) {
429 if self.cursor_row > 0 {
430 self.cursor_row -= 1;
431 self.cursor_col = self.cursor_col.min(self.lines[self.cursor_row].len());
432 }
433 }
434 fn move_down(&mut self) {
435 if self.cursor_row + 1 < self.lines.len() {
436 self.cursor_row += 1;
437 self.cursor_col = self.cursor_col.min(self.lines[self.cursor_row].len());
438 }
439 }
440}
441
442fn is_word_byte(b: u8) -> bool {
446 b.is_ascii_alphanumeric() || b == b'_'
447}
448
449pub trait MessageSender: Send + Sync {
450 fn send_dm(&self, root: &Path, agent_id: &str, body: &str) -> Result<()>;
451 fn broadcast(&self, root: &Path, channel_id: &str, body: &str) -> Result<()>;
452}
453
454#[derive(Debug, Default, Clone, Copy)]
455pub struct CliMessageSender;
456
457impl MessageSender for CliMessageSender {
458 fn send_dm(&self, root: &Path, agent_id: &str, body: &str) -> Result<()> {
459 let status = Command::new("teamctl")
460 .arg("--root")
461 .arg(root)
462 .args(["send", agent_id, body])
463 .status()
464 .with_context(|| format!("invoke teamctl send {agent_id}"))?;
465 if !status.success() {
466 anyhow::bail!("teamctl send {agent_id} exited {status}");
467 }
468 Ok(())
469 }
470
471 fn broadcast(&self, root: &Path, channel_id: &str, body: &str) -> Result<()> {
472 let short = channel_id
476 .rsplit_once(':')
477 .map(|(_, n)| n)
478 .unwrap_or(channel_id);
479 let target = format!("#{short}");
480 let status = Command::new("teamctl")
481 .arg("--root")
482 .arg(root)
483 .args(["broadcast", &target, body])
484 .status()
485 .with_context(|| format!("invoke teamctl broadcast {target}"))?;
486 if !status.success() {
487 anyhow::bail!("teamctl broadcast {target} exited {status}");
488 }
489 Ok(())
490 }
491}
492
493pub mod test_support {
494 use super::*;
495 use std::sync::Mutex;
496
497 #[derive(Default)]
498 pub struct MockMessageSender {
499 pub dm_calls: Mutex<Vec<(String, String)>>,
500 pub broadcast_calls: Mutex<Vec<(String, String)>>,
501 pub fail_next: Mutex<Option<String>>,
505 }
506
507 impl MessageSender for MockMessageSender {
508 fn send_dm(&self, _root: &Path, agent_id: &str, body: &str) -> Result<()> {
509 if let Some(err) = self.fail_next.lock().unwrap().take() {
510 anyhow::bail!(err);
511 }
512 self.dm_calls
513 .lock()
514 .unwrap()
515 .push((agent_id.into(), body.into()));
516 Ok(())
517 }
518 fn broadcast(&self, _root: &Path, channel_id: &str, body: &str) -> Result<()> {
519 if let Some(err) = self.fail_next.lock().unwrap().take() {
520 anyhow::bail!(err);
521 }
522 self.broadcast_calls
523 .lock()
524 .unwrap()
525 .push((channel_id.into(), body.into()));
526 Ok(())
527 }
528 }
529}
530
531#[cfg(test)]
532mod tests {
533 use super::*;
534
535 fn k(code: KeyCode) -> KeyEvent {
536 KeyEvent::new(code, KeyModifiers::NONE)
537 }
538
539 fn k_ctrl(code: KeyCode) -> KeyEvent {
540 KeyEvent::new(code, KeyModifiers::CONTROL)
541 }
542
543 #[test]
544 fn dm_target_title_renders_as_arrow_agent() {
545 let t = ComposeTarget::Dm {
546 agent_id: "writing:dev1".into(),
547 project_id: "writing".into(),
548 };
549 assert_eq!(t.title(), "→ writing:dev1");
550 }
551
552 #[test]
553 fn broadcast_target_title_strips_project_prefix() {
554 let t = ComposeTarget::Broadcast {
555 channel_id: "writing:editorial".into(),
556 project_id: "writing".into(),
557 };
558 assert_eq!(t.title(), "→ #editorial");
559 }
560
561 #[test]
562 fn editor_starts_in_insert_mode() {
563 let e = Editor::default();
564 assert_eq!(e.mode, VimMode::Insert);
565 assert!(e.is_empty());
566 }
567
568 #[test]
569 fn typing_chars_appends_to_line() {
570 let mut e = Editor::default();
571 for c in "hello".chars() {
572 e.apply_key(k(KeyCode::Char(c)));
573 }
574 assert_eq!(e.lines, vec!["hello"]);
575 assert_eq!(e.cursor_col, 5);
576 assert_eq!(e.body(), "hello");
577 }
578
579 #[test]
580 fn enter_splits_line() {
581 let mut e = Editor::default();
582 for c in "hi".chars() {
583 e.apply_key(k(KeyCode::Char(c)));
584 }
585 e.apply_key(k(KeyCode::Enter));
586 for c in "yo".chars() {
587 e.apply_key(k(KeyCode::Char(c)));
588 }
589 assert_eq!(e.lines, vec!["hi", "yo"]);
590 assert_eq!(e.body(), "hi\nyo");
591 }
592
593 #[test]
594 fn backspace_at_line_start_joins_with_previous() {
595 let mut e = Editor::default();
596 for c in "ab".chars() {
597 e.apply_key(k(KeyCode::Char(c)));
598 }
599 e.apply_key(k(KeyCode::Enter));
600 for c in "cd".chars() {
601 e.apply_key(k(KeyCode::Char(c)));
602 }
603 e.cursor_col = 0;
605 e.apply_key(k(KeyCode::Backspace));
606 assert_eq!(e.lines, vec!["abcd"]);
607 assert_eq!(e.cursor_row, 0);
608 assert_eq!(e.cursor_col, 2);
609 }
610
611 #[test]
612 fn esc_from_insert_drops_to_normal() {
613 let mut e = Editor::default();
614 e.apply_key(k(KeyCode::Esc));
615 assert_eq!(e.mode, VimMode::Normal);
616 assert!(e.esc_armed);
617 }
618
619 #[test]
620 fn second_esc_cancels_from_any_mode() {
621 let mut e = Editor::default();
622 e.apply_key(k(KeyCode::Esc));
624 assert_eq!(e.apply_key(k(KeyCode::Esc)), EditorAction::Cancel);
625
626 let mut e = Editor {
628 mode: VimMode::Normal,
629 ..Editor::default()
630 };
631 assert_eq!(e.apply_key(k(KeyCode::Esc)), EditorAction::Continue);
632 assert_eq!(e.apply_key(k(KeyCode::Esc)), EditorAction::Cancel);
633 }
634
635 #[test]
636 fn ctrl_enter_sends_from_any_mode() {
637 let mut e = Editor::default();
638 for c in "hi".chars() {
639 e.apply_key(k(KeyCode::Char(c)));
640 }
641 assert_eq!(e.apply_key(k_ctrl(KeyCode::Enter)), EditorAction::Send);
642 }
643
644 #[test]
645 fn ex_wq_sends() {
646 let mut e = Editor::default();
647 for c in "hi".chars() {
648 e.apply_key(k(KeyCode::Char(c)));
649 }
650 e.apply_key(k(KeyCode::Esc));
652 e.apply_key(k(KeyCode::Char(':')));
653 for c in "wq".chars() {
654 e.apply_key(k(KeyCode::Char(c)));
655 }
656 assert_eq!(e.apply_key(k(KeyCode::Enter)), EditorAction::Send);
657 }
658
659 #[test]
660 fn ex_q_cancels() {
661 let mut e = Editor::default();
662 e.apply_key(k(KeyCode::Esc));
663 e.apply_key(k(KeyCode::Char(':')));
664 e.apply_key(k(KeyCode::Char('q')));
665 assert_eq!(e.apply_key(k(KeyCode::Enter)), EditorAction::Cancel);
666 }
667
668 #[test]
669 fn normal_i_re_enters_insert() {
670 let mut e = Editor::default();
671 e.apply_key(k(KeyCode::Esc));
672 e.apply_key(k(KeyCode::Char('i')));
674 assert_eq!(e.mode, VimMode::Insert);
675 assert!(!e.esc_armed);
676 }
677
678 #[test]
679 fn hjkl_navigate_in_normal_mode() {
680 let mut e = Editor::default();
681 for c in "abc".chars() {
682 e.apply_key(k(KeyCode::Char(c)));
683 }
684 e.apply_key(k(KeyCode::Esc));
685 e.apply_key(k(KeyCode::Char('0')));
686 assert_eq!(e.cursor_col, 0);
687 e.apply_key(k(KeyCode::Char('l')));
688 e.apply_key(k(KeyCode::Char('l')));
689 assert_eq!(e.cursor_col, 2);
690 e.apply_key(k(KeyCode::Char('h')));
691 assert_eq!(e.cursor_col, 1);
692 }
693
694 #[test]
695 fn body_strips_trailing_blank_lines() {
696 let mut e = Editor::default();
697 for c in "x".chars() {
698 e.apply_key(k(KeyCode::Char(c)));
699 }
700 e.apply_key(k(KeyCode::Enter));
701 e.apply_key(k(KeyCode::Enter));
702 assert_eq!(e.body(), "x");
704 }
705}