1use std::time::{Duration, Instant};
12
13use anyhow::Result;
14use crossterm::event::{self, Event, KeyCode, KeyEventKind};
15use ratatui::backend::Backend;
16use ratatui::buffer::Buffer;
17use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
18use ratatui::style::{Modifier, Style};
19use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget, Wrap};
20use ratatui::{Frame, Terminal};
21
22use crate::approvals::{
23 Approval, ApprovalDecider, ApprovalSource, BrokerApprovalSource, CliApprovalDecider, Decision,
24};
25use crate::compose::{CliMessageSender, ComposeTarget, Editor, EditorAction, MessageSender};
26use crate::data::TeamSnapshot;
27use crate::keysender::{encode_key, KeySender, ScrollDirection, TmuxKeySender};
28use crate::layouts;
29use crate::mailbox::{
30 BrokerMailboxSource, MailboxBuffers, MailboxInputKind, MailboxSource, MailboxTab, MessageRow,
31};
32use crate::pane::{PaneSource, TmuxPaneSource};
33use crate::splash;
34use crate::status_bar;
35use crate::statusline;
36use crate::theme::{detect_capabilities, Capabilities};
37use crate::triptych::{self, MainLayout, Pane};
38use crate::tutorial;
39use crate::watch::Watch;
40
41const SPLASH_AUTO_DISMISS: Duration = Duration::from_secs(3);
42const POLL_INTERVAL: Duration = Duration::from_millis(50);
43const REFRESH_INTERVAL: Duration = Duration::from_secs(1);
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum Stage {
49 Splash,
50 Triptych,
51 QuitConfirm,
52 ApprovalsModal,
57 ComposeModal,
62 HelpOverlay,
65 Tutorial,
70 StreamKeys,
78 MailboxDetailModal,
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum SplitOrientation {
96 Vertical,
97 Horizontal,
98}
99
100pub struct App {
101 pub stage: Stage,
102 pub previous_stage: Stage,
104 pub focused_pane: Pane,
105 pub team: TeamSnapshot,
106 pub selected_agent: Option<usize>,
110 pub detail_buffer: Vec<String>,
114 pub version: &'static str,
115 pub capabilities: Capabilities,
116 pub splash_started: Instant,
117 pub last_refresh: Instant,
120 pub running: bool,
121 pub tutorial_completed: bool,
125 pub mailbox_tab: MailboxTab,
133 pub mailbox: MailboxBuffers,
137 pub mailbox_input_mode: Option<MailboxInputKind>,
144 pub mailbox_input_snapshot: String,
148 pub mailbox_detail_modal: Option<MessageRow>,
157 pub mailbox_detail_scroll: u16,
162 pub now_secs: f64,
172 pub pending_approvals: Vec<Approval>,
175 pub selected_approval: usize,
179 pub approval_error: Option<String>,
183 pub compose_target: Option<ComposeTarget>,
187 pub compose_editor: Editor,
191 pub compose_error: Option<String>,
195 pub layout: MainLayout,
198 pub wall_scroll: usize,
202 pub selected_channel: Option<usize>,
206 pub detail_splits: Vec<(String, SplitOrientation)>,
212 pub selected_split: usize,
213 pub pending_chord: Option<KeyCode>,
219 pub tutorial_pending_for_team: bool,
223 pub spinner_frame: usize,
226 pub tutorial_step: usize,
229 pub compose_picker_open: bool,
234 pub compose_picker_index: usize,
236 pub compose_attach_input_open: bool,
243 pub compose_attach_buffer: String,
246 pub last_synced_pane_sizes: std::collections::HashMap<String, (u16, u16)>,
254 pub sysinfo: sysinfo::System,
262 pub rate_limit_indicator_enabled: bool,
270}
271
272const MAX_DETAIL_LINES: usize = 2000;
273
274impl App {
275 pub fn new() -> Self {
281 Self {
282 stage: Stage::Splash,
283 previous_stage: Stage::Splash,
284 focused_pane: Pane::Roster,
285 team: TeamSnapshot::empty(std::path::PathBuf::new()),
286 selected_agent: None,
287 detail_buffer: Vec::new(),
288 version: env!("CARGO_PKG_VERSION"),
289 capabilities: detect_capabilities(),
290 splash_started: Instant::now(),
291 last_refresh: Instant::now() - REFRESH_INTERVAL,
292 running: true,
293 tutorial_completed: tutorial::is_completed(),
294 mailbox_tab: MailboxTab::Inbox,
295 mailbox: MailboxBuffers::default(),
296 mailbox_input_mode: None,
297 mailbox_input_snapshot: String::new(),
298 mailbox_detail_modal: None,
299 mailbox_detail_scroll: 0,
300 now_secs: 0.0,
301 pending_approvals: Vec::new(),
302 selected_approval: 0,
303 approval_error: None,
304 compose_target: None,
305 compose_editor: Editor::default(),
306 compose_error: None,
307 layout: MainLayout::Triptych,
308 wall_scroll: 0,
309 selected_channel: None,
310 detail_splits: Vec::new(),
311 selected_split: 0,
312 compose_picker_open: false,
313 compose_picker_index: 0,
314 compose_attach_input_open: false,
315 compose_attach_buffer: String::new(),
316 pending_chord: None,
317 tutorial_pending_for_team: false,
318 spinner_frame: 0,
319 tutorial_step: 0,
320 last_synced_pane_sizes: std::collections::HashMap::new(),
321 sysinfo: sysinfo::System::new(),
327 rate_limit_indicator_enabled: std::env::var_os("TEAMCTL_UI_RATE_LIMIT_INDICATOR")
335 .is_some(),
336 }
337 }
338
339 pub fn enter_help_overlay(&mut self) {
342 self.previous_stage = self.stage;
343 self.stage = Stage::HelpOverlay;
344 }
345 pub fn close_help_overlay(&mut self) {
346 self.stage = self.previous_stage;
347 }
348 pub fn enter_tutorial(&mut self) {
349 self.previous_stage = self.stage;
350 self.stage = Stage::Tutorial;
351 self.tutorial_step = 0;
352 }
353 pub fn close_tutorial(&mut self) {
354 self.stage = self.previous_stage;
355 self.tutorial_pending_for_team = false;
356 if !self.team.root.as_os_str().is_empty() {
357 let _ = crate::onboarding::mark_completed(&self.team.root);
358 }
359 }
360 pub fn tutorial_advance(&mut self) {
361 let len = crate::onboarding::STEPS.len();
362 if len == 0 {
363 self.close_tutorial();
364 return;
365 }
366 if self.tutorial_step + 1 >= len {
367 self.close_tutorial();
368 } else {
369 self.tutorial_step += 1;
370 }
371 }
372 pub fn tutorial_back(&mut self) {
373 self.tutorial_step = self.tutorial_step.saturating_sub(1);
374 }
375
376 pub fn toggle_wall_layout(&mut self) {
377 self.layout = self.layout.toggle_wall();
378 }
379 pub fn toggle_mailbox_first_layout(&mut self) {
380 self.layout = self.layout.toggle_mailbox_first();
381 if matches!(self.layout, MainLayout::MailboxFirst) && self.selected_channel.is_none() {
384 self.selected_channel = if self.team.channels.is_empty() {
385 None
386 } else {
387 Some(0)
388 };
389 }
390 }
391 pub fn wall_scroll_up(&mut self) {
392 self.wall_scroll = self
393 .wall_scroll
394 .saturating_sub(crate::layouts::WALL_TILE_CAP);
395 }
396 pub fn wall_scroll_down(&mut self) {
397 let next = self.wall_scroll + crate::layouts::WALL_TILE_CAP;
398 if next < self.team.agents.len() {
399 self.wall_scroll = next;
400 }
401 }
402 pub fn select_next_channel(&mut self) {
403 if self.team.channels.is_empty() {
404 return;
405 }
406 self.selected_channel = Some(match self.selected_channel {
407 None => 0,
408 Some(i) => (i + 1) % self.team.channels.len(),
409 });
410 }
411 pub fn select_prev_channel(&mut self) {
412 if self.team.channels.is_empty() {
413 return;
414 }
415 self.selected_channel = Some(match self.selected_channel {
416 None | Some(0) => self.team.channels.len() - 1,
417 Some(i) => i - 1,
418 });
419 }
420
421 pub fn add_detail_split_vertical(&mut self) {
425 self.add_detail_split_with_orientation(SplitOrientation::Vertical);
426 }
427 pub fn add_detail_split_horizontal(&mut self) {
429 self.add_detail_split_with_orientation(SplitOrientation::Horizontal);
430 }
431 fn add_detail_split_with_orientation(&mut self, orientation: SplitOrientation) {
432 let Some(id) = self.selected_agent_id() else {
433 return;
434 };
435 if self.detail_splits.len() >= 4 {
436 return;
437 }
438 self.detail_splits.push((id, orientation));
439 self.selected_split = self.detail_splits.len() - 1;
440 }
441 pub fn add_detail_split(&mut self) {
446 self.add_detail_split_vertical();
447 }
448 pub fn close_focused_split(&mut self) {
449 if self.detail_splits.is_empty() {
450 return;
451 }
452 let i = self.selected_split.min(self.detail_splits.len() - 1);
453 self.detail_splits.remove(i);
454 self.selected_split = i.saturating_sub(1);
455 }
456 pub fn cycle_split_next(&mut self) {
457 if self.detail_splits.is_empty() {
458 return;
459 }
460 self.selected_split = (self.selected_split + 1) % self.detail_splits.len();
461 }
462 pub fn cycle_split_prev(&mut self) {
463 if self.detail_splits.is_empty() {
464 return;
465 }
466 self.selected_split = if self.selected_split == 0 {
467 self.detail_splits.len() - 1
468 } else {
469 self.selected_split - 1
470 };
471 }
472
473 pub fn enter_compose_broadcast_with_picker(&mut self) {
478 if self.team.channels.is_empty() {
479 self.enter_compose_broadcast();
483 return;
484 }
485 let project_id = self
486 .team
487 .channels
488 .first()
489 .map(|c| c.project_id.clone())
490 .unwrap_or_default();
491 self.previous_stage = self.stage;
492 self.stage = Stage::ComposeModal;
493 self.compose_target = Some(ComposeTarget::Broadcast {
494 channel_id: format!("{project_id}:all"),
495 project_id,
496 });
497 self.compose_editor = Editor::default();
498 self.compose_error = None;
499 self.compose_picker_open = true;
500 self.compose_picker_index = 0;
501 }
502 pub fn picker_next(&mut self) {
503 if self.team.channels.is_empty() {
504 return;
505 }
506 self.compose_picker_index = (self.compose_picker_index + 1) % self.team.channels.len();
507 }
508 pub fn picker_prev(&mut self) {
509 if self.team.channels.is_empty() {
510 return;
511 }
512 self.compose_picker_index = if self.compose_picker_index == 0 {
513 self.team.channels.len() - 1
514 } else {
515 self.compose_picker_index - 1
516 };
517 }
518 pub fn picker_confirm(&mut self) {
519 if let Some(ch) = self.team.channels.get(self.compose_picker_index) {
520 self.compose_target = Some(ComposeTarget::Broadcast {
521 channel_id: ch.id.clone(),
522 project_id: ch.project_id.clone(),
523 });
524 }
525 self.compose_picker_open = false;
526 }
527
528 pub fn open_compose_attach_input(&mut self) {
531 self.compose_attach_input_open = true;
532 self.compose_attach_buffer.clear();
533 }
534
535 pub fn confirm_compose_attach_input(&mut self) {
541 let path = self.compose_attach_buffer.trim().to_string();
542 if !path.is_empty() {
543 let marker = format!("📎 attachment: {path}");
544 if let Some(last) = self.compose_editor.lines.last_mut() {
549 if !last.is_empty() {
550 self.compose_editor.lines.push(marker);
551 } else {
552 *last = marker;
553 }
554 } else {
555 self.compose_editor.lines.push(marker);
556 }
557 self.compose_editor.cursor_row = self.compose_editor.lines.len() - 1;
560 self.compose_editor.cursor_col = self
561 .compose_editor
562 .lines
563 .last()
564 .map(|l| l.len())
565 .unwrap_or(0);
566 }
567 self.close_compose_attach_input();
568 }
569
570 pub fn close_compose_attach_input(&mut self) {
571 self.compose_attach_input_open = false;
572 self.compose_attach_buffer.clear();
573 }
574
575 pub fn cycle_mailbox_tab(&mut self) {
576 self.mailbox_tab = self.mailbox_tab.next();
577 }
578
579 pub fn cycle_mailbox_tab_back(&mut self) {
580 self.mailbox_tab = self.mailbox_tab.prev();
581 }
582
583 pub fn mailbox_cursor_down(&mut self) {
588 self.mailbox.move_cursor_down(self.mailbox_tab);
589 }
590
591 pub fn mailbox_cursor_up(&mut self) {
592 self.mailbox.move_cursor_up(self.mailbox_tab);
593 }
594
595 pub fn mailbox_page_down(&mut self) {
596 self.mailbox.page_cursor_down(self.mailbox_tab);
597 }
598
599 pub fn mailbox_page_up(&mut self) {
600 self.mailbox.page_cursor_up(self.mailbox_tab);
601 }
602
603 pub fn mailbox_cursor_home(&mut self) {
604 self.mailbox.cursor_home(self.mailbox_tab);
605 }
606
607 pub fn mailbox_cursor_end(&mut self) {
608 self.mailbox.cursor_end(self.mailbox_tab);
609 }
610
611 pub fn open_mailbox_filter_input(&mut self) {
618 self.mailbox_input_snapshot = self.mailbox.filter_text(self.mailbox_tab).to_string();
619 self.mailbox_input_mode = Some(MailboxInputKind::Filter);
620 }
621
622 pub fn open_mailbox_search_input(&mut self) {
625 self.mailbox_input_snapshot = self.mailbox.search_text(self.mailbox_tab).to_string();
626 self.mailbox_input_mode = Some(MailboxInputKind::Search);
627 }
628
629 pub fn mailbox_input_push_char(&mut self, c: char) {
632 if let Some(kind) = self.mailbox_input_mode {
633 self.mailbox.input_push_char(self.mailbox_tab, kind, c);
634 }
635 }
636
637 pub fn mailbox_input_pop_char(&mut self) {
639 if let Some(kind) = self.mailbox_input_mode {
640 self.mailbox.input_pop_char(self.mailbox_tab, kind);
641 }
642 }
643
644 pub fn mailbox_input_confirm(&mut self) {
646 self.mailbox_input_mode = None;
647 self.mailbox_input_snapshot.clear();
648 }
649
650 pub fn mailbox_input_cancel(&mut self) {
654 if let Some(kind) = self.mailbox_input_mode {
655 let snapshot = std::mem::take(&mut self.mailbox_input_snapshot);
656 self.mailbox.set_input(self.mailbox_tab, kind, snapshot);
657 }
658 self.mailbox_input_mode = None;
659 self.mailbox_input_snapshot.clear();
660 }
661
662 pub fn open_mailbox_detail_modal(&mut self) {
672 let tab = self.mailbox_tab;
673 let visible = self.mailbox.visible_indices(tab);
674 if visible.is_empty() {
675 return;
676 }
677 let idx = self.mailbox.cursor(tab).selected_idx.min(visible.len() - 1);
678 let row_idx = visible[idx];
679 let row = self.mailbox.rows(tab).get(row_idx).cloned();
680 if let Some(row) = row {
681 self.mailbox_detail_modal = Some(row);
682 self.mailbox_detail_scroll = 0;
683 self.stage = Stage::MailboxDetailModal;
684 }
685 }
686
687 pub fn close_mailbox_detail_modal(&mut self) {
690 self.mailbox_detail_modal = None;
691 self.mailbox_detail_scroll = 0;
692 self.stage = Stage::Triptych;
693 }
694
695 pub fn mailbox_detail_scroll_down(&mut self) {
699 self.mailbox_detail_scroll = self.mailbox_detail_scroll.saturating_add(1);
704 }
705
706 pub fn mailbox_detail_scroll_up(&mut self) {
708 self.mailbox_detail_scroll = self.mailbox_detail_scroll.saturating_sub(1);
709 }
710
711 pub fn cycle_focus_back(&mut self) {
712 self.focused_pane = self.focused_pane.prev();
713 }
714
715 pub fn has_pending_approvals(&self) -> bool {
716 !self.pending_approvals.is_empty()
717 }
718
719 pub fn enter_approvals_modal(&mut self) {
720 if self.pending_approvals.is_empty() {
721 return;
722 }
723 self.previous_stage = self.stage;
724 self.stage = Stage::ApprovalsModal;
725 self.selected_approval = 0;
726 self.approval_error = None;
727 }
728
729 pub fn close_approvals_modal(&mut self) {
730 self.stage = self.previous_stage;
731 self.approval_error = None;
732 }
733
734 pub fn cycle_approval_next(&mut self) {
735 if self.pending_approvals.is_empty() {
736 return;
737 }
738 self.selected_approval = (self.selected_approval + 1) % self.pending_approvals.len();
739 }
740
741 pub fn cycle_approval_prev(&mut self) {
742 if self.pending_approvals.is_empty() {
743 return;
744 }
745 self.selected_approval = if self.selected_approval == 0 {
746 self.pending_approvals.len() - 1
747 } else {
748 self.selected_approval - 1
749 };
750 }
751
752 pub fn focused_approval(&self) -> Option<&Approval> {
753 self.pending_approvals.get(self.selected_approval)
754 }
755
756 pub fn replace_approvals(&mut self, approvals: Vec<Approval>) {
762 self.pending_approvals = approvals;
763 if self.pending_approvals.is_empty() {
764 if matches!(self.stage, Stage::ApprovalsModal) {
765 self.close_approvals_modal();
766 }
767 self.selected_approval = 0;
768 } else if self.selected_approval >= self.pending_approvals.len() {
769 self.selected_approval = self.pending_approvals.len() - 1;
770 }
771 }
772
773 pub fn apply_decision<D: ApprovalDecider>(&mut self, decider: &D, kind: Decision, note: &str) {
780 let Some(approval) = self.focused_approval().cloned() else {
781 return;
782 };
783 match decider.decide(&self.team.root, approval.id, kind, note) {
784 Ok(()) => {
785 self.pending_approvals.retain(|a| a.id != approval.id);
786 self.approval_error = None;
787 if self.pending_approvals.is_empty() {
788 self.close_approvals_modal();
789 } else if self.selected_approval >= self.pending_approvals.len() {
790 self.selected_approval = self.pending_approvals.len() - 1;
791 }
792 }
793 Err(err) => {
794 self.approval_error = Some(err.to_string());
795 }
796 }
797 }
798
799 pub fn enter_compose_dm_for_focused(&mut self) {
802 let Some(info) = self
803 .selected_agent
804 .and_then(|i| self.team.agents.get(i))
805 .cloned()
806 else {
807 return;
808 };
809 self.previous_stage = self.stage;
810 self.stage = Stage::ComposeModal;
811 self.compose_target = Some(ComposeTarget::Dm {
812 agent_id: info.id.clone(),
813 project_id: info.project.clone(),
814 });
815 self.compose_editor = Editor::default();
816 self.compose_error = None;
817 }
818
819 pub fn enter_compose_broadcast(&mut self) {
827 let project_id = self
828 .selected_agent
829 .and_then(|i| self.team.agents.get(i))
830 .map(|a| a.project.clone())
831 .or_else(|| self.team.agents.first().map(|a| a.project.clone()));
832 let Some(project_id) = project_id else {
833 return;
834 };
835 let channel_id = format!("{project_id}:all");
836 self.previous_stage = self.stage;
837 self.stage = Stage::ComposeModal;
838 self.compose_target = Some(ComposeTarget::Broadcast {
839 channel_id,
840 project_id,
841 });
842 self.compose_editor = Editor::default();
843 self.compose_error = None;
844 }
845
846 pub fn close_compose_modal(&mut self) {
847 self.stage = self.previous_stage;
848 self.compose_target = None;
849 self.compose_editor = Editor::default();
850 self.compose_error = None;
851 self.compose_attach_input_open = false;
854 self.compose_attach_buffer.clear();
855 }
856
857 pub fn apply_send<S: MessageSender, M: MailboxSource>(
863 &mut self,
864 sender: &S,
865 mailbox_source: &M,
866 ) {
867 let Some(target) = self.compose_target.clone() else {
868 return;
869 };
870 let body = self.compose_editor.body();
871 if body.is_empty() {
872 self.compose_error = Some("body is empty".into());
873 return;
874 }
875 let result = match &target {
876 ComposeTarget::Dm { agent_id, .. } => sender.send_dm(&self.team.root, agent_id, &body),
877 ComposeTarget::Broadcast { channel_id, .. } => {
878 sender.broadcast(&self.team.root, channel_id, &body)
879 }
880 };
881 match result {
882 Ok(()) => {
883 self.close_compose_modal();
884 refresh_mailbox(self, mailbox_source);
887 }
888 Err(err) => {
889 self.compose_error = Some(err.to_string());
890 }
891 }
892 }
893
894 pub fn dismiss_splash(&mut self) {
895 if matches!(self.stage, Stage::Splash) {
896 self.stage = Stage::Triptych;
897 self.previous_stage = Stage::Triptych;
898 }
899 }
900
901 pub fn cycle_focus(&mut self) {
902 self.focused_pane = self.focused_pane.next();
903 }
904
905 pub fn select_prev(&mut self) {
911 if self.team.agents.is_empty() {
912 self.selected_agent = None;
913 return;
914 }
915 let prior = self.selected_agent_id();
916 self.selected_agent = Some(match self.selected_agent {
917 None | Some(0) => self.team.agents.len() - 1,
918 Some(i) => i - 1,
919 });
920 if prior != self.selected_agent_id() {
921 self.mailbox.reset();
922 }
923 }
924
925 pub fn select_next(&mut self) {
928 if self.team.agents.is_empty() {
929 self.selected_agent = None;
930 return;
931 }
932 let prior = self.selected_agent_id();
933 self.selected_agent = Some(match self.selected_agent {
934 None => 0,
935 Some(i) => (i + 1) % self.team.agents.len(),
936 });
937 if prior != self.selected_agent_id() {
938 self.mailbox.reset();
939 }
940 }
941
942 pub fn selected_agent_id(&self) -> Option<String> {
944 self.selected_agent
945 .and_then(|i| self.team.agents.get(i))
946 .map(|a| a.id.clone())
947 }
948
949 pub fn enter_quit_confirm(&mut self) {
950 self.previous_stage = self.stage;
951 self.stage = Stage::QuitConfirm;
952 }
953
954 pub fn cancel_quit(&mut self) {
955 self.stage = self.previous_stage;
956 }
957
958 pub fn confirm_quit(&mut self) {
959 self.running = false;
960 }
961
962 pub fn replace_team(&mut self, team: TeamSnapshot) {
969 let prior_id = self.selected_agent_id();
970 self.team = team;
971 self.selected_agent = match (prior_id.clone(), self.team.agents.is_empty()) {
972 (_, true) => None,
973 (Some(id), false) => self.team.agents.iter().position(|a| a.id == id).or(Some(0)),
974 (None, false) => Some(0),
975 };
976 if prior_id != self.selected_agent_id() {
977 self.mailbox.reset();
978 }
979 }
980
981 pub fn focused_session(&self) -> Option<&str> {
984 self.selected_agent
985 .and_then(|i| self.team.agents.get(i))
986 .map(|a| a.tmux_session.as_str())
987 }
988
989 pub fn stream_target_session(&self) -> Option<String> {
996 if self.detail_splits.is_empty() || self.selected_split == 0 {
997 return self.focused_session().map(|s| s.to_string());
998 }
999 let split_idx = self.selected_split - 1;
1000 let agent_id = self.detail_splits.get(split_idx).map(|(id, _)| id)?;
1001 self.team
1002 .agents
1003 .iter()
1004 .find(|a| &a.id == agent_id)
1005 .map(|a| a.tmux_session.clone())
1006 }
1007
1008 pub fn enter_stream_keys(&mut self) {
1013 if self.stream_target_session().is_none() {
1014 return;
1015 }
1016 self.previous_stage = self.stage;
1017 self.stage = Stage::StreamKeys;
1018 }
1019
1020 pub fn exit_stream_keys(&mut self) {
1024 self.stage = self.previous_stage;
1025 }
1026
1027 pub fn set_detail_buffer(&mut self, lines: Vec<String>) {
1029 let len = lines.len();
1030 let start = len.saturating_sub(MAX_DETAIL_LINES);
1031 self.detail_buffer = lines[start..].to_vec();
1032 }
1033}
1034
1035impl Default for App {
1036 fn default() -> Self {
1037 Self::new()
1038 }
1039}
1040
1041pub fn refresh<P: PaneSource, M: MailboxSource, A: ApprovalSource>(
1046 app: &mut App,
1047 pane_source: &P,
1048 mailbox_source: &M,
1049 approval_source: &A,
1050) {
1051 if let Ok(Some(snapshot)) = TeamSnapshot::discover_and_load() {
1052 app.replace_team(snapshot);
1053 }
1054 if let Some(session) = app.focused_session().map(|s| s.to_string()) {
1055 if let Ok(lines) = pane_source.capture(&session) {
1056 app.set_detail_buffer(lines);
1057 }
1058 } else {
1059 app.detail_buffer.clear();
1060 }
1061 refresh_mailbox(app, mailbox_source);
1062 refresh_approvals(app, approval_source);
1063 app.last_refresh = Instant::now();
1064}
1065
1066pub fn refresh_approvals<A: ApprovalSource>(app: &mut App, approval_source: &A) {
1072 let approvals = approval_source.pending().unwrap_or_default();
1073 app.replace_approvals(approvals);
1074}
1075
1076pub fn refresh_mailbox<M: MailboxSource>(app: &mut App, mailbox_source: &M) {
1081 let Some(agent_id) = app.selected_agent_id() else {
1082 return;
1085 };
1086 let project_id = app
1087 .selected_agent
1088 .and_then(|i| app.team.agents.get(i))
1089 .map(|a| a.project.clone())
1090 .unwrap_or_default();
1091 if let Ok(batch) = mailbox_source.inbox(&agent_id, app.mailbox.inbox_after) {
1092 app.mailbox.extend(MailboxTab::Inbox, batch);
1093 }
1094 if let Ok(batch) = mailbox_source.sent(&agent_id, app.mailbox.sent_after) {
1095 app.mailbox.extend(MailboxTab::Sent, batch);
1096 }
1097 if let Ok(batch) = mailbox_source.channel_feed(&agent_id, app.mailbox.channel_after) {
1098 app.mailbox.extend(MailboxTab::Channel, batch);
1099 }
1100 if let Ok(batch) = mailbox_source.wire(&project_id, app.mailbox.wire_after) {
1101 app.mailbox.extend(MailboxTab::Wire, batch);
1102 }
1103}
1104
1105pub fn run<B: Backend>(terminal: &mut Terminal<B>) -> Result<()> {
1106 let mut app = App::new();
1107 let pane_source = TmuxPaneSource;
1108 let decider = CliApprovalDecider;
1109 let sender = CliMessageSender;
1110 let key_sender = TmuxKeySender;
1111 let pane_resizer = crate::pane_resize::TmuxPaneResizer;
1112 refresh_with_default_sources(&mut app, &pane_source);
1115 let mut watch = Watch::try_new(&app.team.root.join("state"));
1116 while app.running {
1117 app.now_secs = chrono::Utc::now().timestamp() as f64;
1122 terminal.draw(|f| draw(f, &app))?;
1123 let term_sz = terminal.size()?;
1131 let term_area = ratatui::layout::Rect::new(0, 0, term_sz.width, term_sz.height);
1132 sync_focused_pane_size_to(&mut app, term_area, &pane_resizer);
1133 if event::poll(POLL_INTERVAL)? {
1134 let db_path = app.team.root.join("state/mailbox.db");
1138 let mailbox_source = BrokerMailboxSource::new(db_path);
1139 handle_event(
1140 &mut app,
1141 event::read()?,
1142 &decider,
1143 &sender,
1144 &mailbox_source,
1145 &key_sender,
1146 );
1147 }
1148 if matches!(app.stage, Stage::Splash) && app.splash_started.elapsed() >= SPLASH_AUTO_DISMISS
1149 {
1150 app.dismiss_splash();
1151 }
1152 let dirty = watch.take_dirty();
1159 if dirty || app.last_refresh.elapsed() >= REFRESH_INTERVAL {
1160 let prior_root = app.team.root.clone();
1161 refresh_with_default_sources(&mut app, &pane_source);
1162 if app.team.root != prior_root {
1165 watch = Watch::try_new(&app.team.root.join("state"));
1166 }
1167 }
1168 }
1169 Ok(())
1170}
1171
1172pub fn sync_focused_pane_size_to<R: crate::pane_resize::PaneResizer>(
1187 app: &mut App,
1188 total_area: ratatui::layout::Rect,
1189 resizer: &R,
1190) {
1191 if !matches!(app.layout, MainLayout::Triptych) {
1192 return;
1193 }
1194 let Some(detail) =
1195 crate::pane_resize::triptych_detail_area(total_area, app.has_pending_approvals())
1196 else {
1197 return;
1198 };
1199 let Some(session) = app.focused_session().map(|s| s.to_string()) else {
1200 return;
1201 };
1202 let target = (detail.width, detail.height);
1203 if !crate::pane_resize::should_sync(&app.last_synced_pane_sizes, &session, target) {
1204 return;
1205 }
1206 resizer.resize(&session, target.0, target.1);
1207 app.last_synced_pane_sizes.insert(session, target);
1208}
1209
1210fn refresh_with_default_sources<P: PaneSource>(app: &mut App, pane_source: &P) {
1215 if let Ok(Some(snapshot)) = TeamSnapshot::discover_and_load() {
1216 app.replace_team(snapshot);
1217 }
1218 let db_path = app.team.root.join("state/mailbox.db");
1219 let mailbox_source = BrokerMailboxSource::new(db_path.clone());
1220 let approval_source = BrokerApprovalSource::new(db_path);
1221 if let Some(session) = app.focused_session().map(|s| s.to_string()) {
1222 if let Ok(lines) = pane_source.capture(&session) {
1223 app.set_detail_buffer(lines);
1224 }
1225 } else {
1226 app.detail_buffer.clear();
1227 }
1228 refresh_mailbox(app, &mailbox_source);
1229 refresh_approvals(app, &approval_source);
1230 app.sysinfo.refresh_cpu_usage();
1236 app.sysinfo.refresh_memory();
1237 app.last_refresh = Instant::now();
1238}
1239
1240pub fn draw(f: &mut Frame<'_>, app: &App) {
1241 let area = f.area();
1242 match app.stage {
1243 Stage::Splash => splash::draw(f, app),
1244 Stage::Triptych => draw_main(f, area, app),
1245 Stage::StreamKeys => draw_main(f, area, app),
1250 Stage::QuitConfirm => {
1251 draw_main(f, area, app);
1252 draw_quit_confirm(f, area);
1253 }
1254 Stage::ApprovalsModal => {
1255 draw_main(f, area, app);
1256 draw_approvals_modal(f, area, app);
1257 }
1258 Stage::ComposeModal => {
1259 draw_main(f, area, app);
1260 draw_compose_modal(f, area, app);
1261 }
1262 Stage::HelpOverlay => {
1263 draw_main(f, area, app);
1264 let buf = f.buffer_mut();
1265 render_help_overlay(area, buf, app);
1266 }
1267 Stage::Tutorial => {
1268 draw_main(f, area, app);
1269 let buf = f.buffer_mut();
1270 render_tutorial(area, buf, app);
1271 }
1272 Stage::MailboxDetailModal => {
1273 draw_main(f, area, app);
1274 let buf = f.buffer_mut();
1275 render_mailbox_detail_modal(area, buf, app);
1276 }
1277 }
1278}
1279
1280fn render_help_overlay(area: Rect, buf: &mut Buffer, app: &App) {
1281 let popup_w = 70u16.min(area.width.saturating_sub(4));
1282 let popup_h = 24u16.min(area.height.saturating_sub(2));
1283 let popup = centered_rect(popup_w, popup_h, area);
1284 Clear.render(popup, buf);
1285 let block = Block::default()
1286 .title("help · ? to close")
1287 .borders(Borders::ALL)
1288 .border_style(Style::default().fg(app.capabilities.accent()));
1289 let inner = block.inner(popup);
1290 block.render(popup, buf);
1291 let muted = Style::default().fg(app.capabilities.muted());
1292 let bold = Style::default().add_modifier(Modifier::BOLD);
1293 let mut lines: Vec<ratatui::text::Line<'_>> = Vec::new();
1294 for group in crate::help::ALL_GROUPS {
1295 lines.push(ratatui::text::Line::styled(group.title, bold));
1296 for b in group.bindings {
1297 lines.push(ratatui::text::Line::raw(format!(
1298 " {:<22} {}",
1299 b.chord, b.description
1300 )));
1301 }
1302 lines.push(ratatui::text::Line::styled("", muted));
1303 }
1304 Paragraph::new(lines).render(inner, buf);
1305}
1306
1307fn render_mailbox_detail_modal(area: Rect, buf: &mut Buffer, app: &App) {
1315 let Some(row) = app.mailbox_detail_modal.as_ref() else {
1316 return;
1317 };
1318 let popup_w = 80u16.min(area.width.saturating_sub(4));
1319 let popup_h = 24u16.min(area.height.saturating_sub(2));
1320 let popup = centered_rect(popup_w, popup_h, area);
1321 Clear.render(popup, buf);
1322 let title = format!("MESSAGE · id {} · Esc/q to close", row.id);
1323 let block = Block::default()
1324 .title(title)
1325 .borders(Borders::ALL)
1326 .border_style(Style::default().fg(app.capabilities.accent()));
1327 let inner = block.inner(popup);
1328 block.render(popup, buf);
1329 if inner.height == 0 {
1330 return;
1331 }
1332
1333 const META_LINES: u16 = 6;
1338 let meta_h = META_LINES.min(inner.height);
1339 let body_h = inner.height.saturating_sub(meta_h);
1340 let meta_area = Rect {
1341 x: inner.x,
1342 y: inner.y,
1343 width: inner.width,
1344 height: meta_h,
1345 };
1346 let body_area = Rect {
1347 x: inner.x,
1348 y: inner.y + meta_h,
1349 width: inner.width,
1350 height: body_h,
1351 };
1352
1353 let ts = chrono::DateTime::<chrono::Utc>::from_timestamp(
1358 row.sent_at as i64,
1359 ((row.sent_at.fract() * 1_000_000_000.0) as u32).min(999_999_999),
1360 )
1361 .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
1362 .unwrap_or_else(|| "—".to_string());
1363
1364 let muted = Style::default().fg(app.capabilities.muted());
1365 let meta_lines = vec![
1366 ratatui::text::Line::raw(format!("from: {}", row.sender)),
1367 ratatui::text::Line::raw(format!("to: {}", row.recipient)),
1368 ratatui::text::Line::raw(format!("kind: {}", crate::mailbox::kind_label(row))),
1369 ratatui::text::Line::raw(format!("time: {ts}")),
1370 ratatui::text::Line::raw(format!(
1371 "transport: {}",
1372 crate::mailbox::transport_label(row)
1373 )),
1374 ratatui::text::Line::styled("", muted),
1375 ];
1376 Paragraph::new(meta_lines)
1377 .style(Style::default())
1378 .render(meta_area, buf);
1379
1380 Paragraph::new(row.text.clone())
1386 .wrap(Wrap { trim: false })
1387 .scroll((app.mailbox_detail_scroll, 0))
1388 .render(body_area, buf);
1389}
1390
1391fn render_tutorial(area: Rect, buf: &mut Buffer, app: &App) {
1392 let popup_w = 64u16.min(area.width.saturating_sub(4));
1393 let popup_h = 14u16.min(area.height.saturating_sub(2));
1394 let popup = centered_rect(popup_w, popup_h, area);
1395 Clear.render(popup, buf);
1396 let total = crate::onboarding::STEPS.len();
1397 let i = app.tutorial_step.min(total.saturating_sub(1));
1398 let step = &crate::onboarding::STEPS[i];
1399 let block = Block::default()
1400 .title(format!("tutorial · {}/{total}", i + 1))
1401 .borders(Borders::ALL)
1402 .border_style(Style::default().fg(app.capabilities.accent()));
1403 let inner = block.inner(popup);
1404 block.render(popup, buf);
1405 let muted = Style::default().fg(app.capabilities.muted());
1406 let lines = vec![
1407 ratatui::text::Line::styled(step.heading, Style::default().add_modifier(Modifier::BOLD)),
1408 ratatui::text::Line::raw(""),
1409 ratatui::text::Line::raw(step.body),
1410 ratatui::text::Line::raw(""),
1411 ratatui::text::Line::styled("any key next · k / ↑ / p back · Esc skip", muted),
1412 ];
1413 Paragraph::new(lines)
1419 .wrap(ratatui::widgets::Wrap { trim: true })
1420 .render(inner, buf);
1421}
1422
1423fn draw_main(f: &mut Frame<'_>, area: Rect, app: &App) {
1424 let chunks = Layout::default()
1429 .direction(Direction::Vertical)
1430 .constraints([
1431 Constraint::Min(3),
1432 Constraint::Length(1), Constraint::Length(1), ])
1435 .split(area);
1436 let buf = f.buffer_mut();
1437 match app.layout {
1438 crate::triptych::MainLayout::Triptych => {
1439 triptych::Triptych { app }.render(chunks[0], buf);
1440 }
1441 crate::triptych::MainLayout::Wall => {
1442 layouts::Wall { app }.render(chunks[0], buf);
1443 }
1444 crate::triptych::MainLayout::MailboxFirst => {
1445 layouts::MailboxFirst { app }.render(chunks[0], buf);
1446 }
1447 }
1448 statusline::Statusline { app }.render(chunks[1], buf);
1449 status_bar::StatusBar { app }.render(chunks[2], buf);
1450}
1451
1452fn draw_approvals_modal(f: &mut Frame<'_>, area: Rect, app: &App) {
1453 let buf = f.buffer_mut();
1454 render_approvals_modal(area, buf, app);
1455}
1456
1457fn draw_compose_modal(f: &mut Frame<'_>, area: Rect, app: &App) {
1458 let buf = f.buffer_mut();
1459 render_compose_modal(area, buf, app);
1460}
1461
1462fn render_compose_picker_body(inner: Rect, buf: &mut Buffer, app: &App) {
1463 let muted = Style::default().fg(app.capabilities.muted());
1464 let chunks = Layout::default()
1465 .direction(Direction::Vertical)
1466 .constraints([
1467 Constraint::Min(1),
1468 Constraint::Length(1),
1469 Constraint::Length(1),
1470 ])
1471 .split(inner);
1472 let lines: Vec<ratatui::text::Line<'_>> = if app.team.channels.is_empty() {
1473 vec![ratatui::text::Line::styled(
1474 "(no channels declared in team-compose)",
1475 muted,
1476 )]
1477 } else {
1478 app.team
1479 .channels
1480 .iter()
1481 .enumerate()
1482 .map(|(i, ch)| {
1483 let label = format!(" #{} ({})", ch.name, ch.project_id);
1484 let style = if i == app.compose_picker_index {
1485 Style::default()
1486 .fg(app.capabilities.accent())
1487 .add_modifier(Modifier::REVERSED)
1488 } else {
1489 Style::default()
1490 };
1491 ratatui::text::Line::styled(label, style)
1492 })
1493 .collect()
1494 };
1495 Paragraph::new(lines).render(chunks[0], buf);
1496 Paragraph::new("pick a channel to broadcast to")
1497 .style(muted)
1498 .render(chunks[1], buf);
1499 Paragraph::new("Enter pick · j/k navigate · Esc cancel")
1500 .style(muted)
1501 .render(chunks[2], buf);
1502}
1503
1504fn render_compose_modal(area: Rect, buf: &mut Buffer, app: &App) {
1505 let popup_w = 80u16.min(area.width.saturating_sub(4));
1506 let popup_h = 16u16.min(area.height.saturating_sub(2));
1507 let popup = centered_rect(popup_w, popup_h, area);
1508 Clear.render(popup, buf);
1509 let title = app
1510 .compose_target
1511 .as_ref()
1512 .map(|t| t.title(&app.team))
1513 .unwrap_or_else(|| "→ ?".into());
1514 let block = Block::default()
1515 .title(title)
1516 .borders(Borders::ALL)
1517 .border_style(Style::default().fg(app.capabilities.accent()));
1518 let inner = block.inner(popup);
1519 block.render(popup, buf);
1520
1521 if inner.height < 3 {
1522 return;
1523 }
1524 if app.compose_picker_open {
1528 render_compose_picker_body(inner, buf, app);
1529 return;
1530 }
1531 if app.compose_attach_input_open {
1532 render_compose_attach_input(inner, buf, app);
1533 return;
1534 }
1535 let chunks = Layout::default()
1538 .direction(Direction::Vertical)
1539 .constraints([
1540 Constraint::Min(1), Constraint::Length(1), Constraint::Length(1), ])
1544 .split(inner);
1545
1546 let muted = Style::default().fg(app.capabilities.muted());
1551 let body_lines: Vec<ratatui::text::Line<'_>> = app
1552 .compose_editor
1553 .lines
1554 .iter()
1555 .enumerate()
1556 .map(|(row, line)| {
1557 if row == app.compose_editor.cursor_row
1558 && app.compose_editor.mode == crate::compose::VimMode::Insert
1559 {
1560 let col = app.compose_editor.cursor_col.min(line.len());
1561 let (head, tail) = line.split_at(col);
1562 ratatui::text::Line::from(vec![
1563 ratatui::text::Span::raw(head.to_string()),
1564 ratatui::text::Span::styled(
1565 "▏",
1566 Style::default().fg(app.capabilities.accent()),
1567 ),
1568 ratatui::text::Span::raw(tail.to_string()),
1569 ])
1570 } else {
1571 ratatui::text::Line::raw(line.clone())
1572 }
1573 })
1574 .collect();
1575 Paragraph::new(body_lines).render(chunks[0], buf);
1576
1577 let error_line = match (&app.compose_error, app.compose_editor.mode) {
1578 (Some(e), _) => format!("error: {e}"),
1579 (None, crate::compose::VimMode::Ex) => format!(":{}", app.compose_editor.ex_buffer),
1580 (None, crate::compose::VimMode::Normal) => "-- NORMAL --".into(),
1581 (None, crate::compose::VimMode::Insert) => "-- INSERT --".into(),
1582 };
1583 let style = if app.compose_error.is_some() {
1584 Style::default().fg(app.capabilities.accent())
1585 } else {
1586 muted
1587 };
1588 Paragraph::new(error_line)
1589 .style(style)
1590 .render(chunks[1], buf);
1591
1592 Paragraph::new("Esc Enter send · Esc Esc cancel · Tab attach")
1593 .style(muted)
1594 .render(chunks[2], buf);
1595}
1596
1597fn render_compose_attach_input(inner: Rect, buf: &mut Buffer, app: &App) {
1602 let muted = Style::default().fg(app.capabilities.muted());
1603 let chunks = Layout::default()
1604 .direction(Direction::Vertical)
1605 .constraints([
1606 Constraint::Min(1),
1607 Constraint::Length(1),
1608 Constraint::Length(1),
1609 ])
1610 .split(inner);
1611 let line = ratatui::text::Line::from(vec![
1612 ratatui::text::Span::raw(format!("path: {}", app.compose_attach_buffer)),
1613 ratatui::text::Span::styled("▏", Style::default().fg(app.capabilities.accent())),
1614 ]);
1615 Paragraph::new(line).render(chunks[0], buf);
1616 Paragraph::new("type or paste an absolute path; the agent reads it via the broker")
1617 .style(muted)
1618 .render(chunks[1], buf);
1619 Paragraph::new("Enter confirm · Esc cancel")
1620 .style(muted)
1621 .render(chunks[2], buf);
1622}
1623
1624fn render_approvals_modal(area: Rect, buf: &mut Buffer, app: &App) {
1625 let popup_w = 80u16.min(area.width.saturating_sub(4));
1626 let popup_h = 18u16.min(area.height.saturating_sub(2));
1627 let popup = centered_rect(popup_w, popup_h, area);
1628 Clear.render(popup, buf);
1629 let n = app.pending_approvals.len();
1630 let i = app.selected_approval.min(n.saturating_sub(1));
1631 let title = format!("approvals · {}/{n}", i + 1);
1632 let block = Block::default()
1633 .title(title)
1634 .borders(Borders::ALL)
1635 .border_style(Style::default().fg(app.capabilities.accent()));
1636 let inner = block.inner(popup);
1637 block.render(popup, buf);
1638
1639 let muted = Style::default().fg(app.capabilities.muted());
1640 let bold = Style::default().add_modifier(Modifier::BOLD);
1641
1642 let Some(a) = app.focused_approval() else {
1643 Paragraph::new("(no pending approvals)")
1644 .style(muted)
1645 .alignment(Alignment::Center)
1646 .render(inner, buf);
1647 return;
1648 };
1649
1650 let mut lines: Vec<ratatui::text::Line<'_>> = vec![
1651 ratatui::text::Line::styled(format!("#{} {}", a.id, a.action), bold),
1652 ratatui::text::Line::styled(
1653 format!("from: {}", crate::data::agent_label(&app.team, &a.agent_id)),
1654 muted,
1655 ),
1656 ratatui::text::Line::raw(""),
1657 ratatui::text::Line::raw(a.summary.clone()),
1658 ];
1659 if !a.payload_json.is_empty() && a.payload_json != "{}" {
1660 lines.push(ratatui::text::Line::raw(""));
1661 lines.push(ratatui::text::Line::styled("payload:", muted));
1662 for chunk in a.payload_json.lines().take(4) {
1663 lines.push(ratatui::text::Line::raw(chunk.to_string()));
1664 }
1665 }
1666 if let Some(err) = &app.approval_error {
1667 lines.push(ratatui::text::Line::raw(""));
1668 lines.push(ratatui::text::Line::styled(
1669 format!("error: {err}"),
1670 Style::default().fg(app.capabilities.accent()),
1671 ));
1672 }
1673 lines.push(ratatui::text::Line::raw(""));
1674 lines.push(ratatui::text::Line::styled(
1675 "[y] approve · [Shift-N] deny · [j/k] cycle · [Esc] close",
1676 muted,
1677 ));
1678 Paragraph::new(lines).render(inner, buf);
1679}
1680
1681fn draw_quit_confirm(f: &mut Frame<'_>, area: Rect) {
1682 let popup_w = 36u16.min(area.width.saturating_sub(2));
1683 let popup_h = 5u16.min(area.height.saturating_sub(2));
1684 let popup = centered_rect(popup_w, popup_h, area);
1685 let buf = f.buffer_mut();
1686 Clear.render(popup, buf);
1687 Paragraph::new("Quit teamctl-ui? [y / n]")
1688 .alignment(Alignment::Center)
1689 .block(Block::default().borders(Borders::ALL).title("confirm"))
1690 .render(popup, buf);
1691}
1692
1693fn centered_rect(w: u16, h: u16, area: Rect) -> Rect {
1694 let x = area.x + area.width.saturating_sub(w) / 2;
1695 let y = area.y + area.height.saturating_sub(h) / 2;
1696 Rect {
1697 x,
1698 y,
1699 width: w,
1700 height: h,
1701 }
1702}
1703
1704pub fn handle_event<D: ApprovalDecider, S: MessageSender, M: MailboxSource, K: KeySender>(
1705 app: &mut App,
1706 ev: Event,
1707 decider: &D,
1708 sender: &S,
1709 mailbox_source: &M,
1710 key_sender: &K,
1711) {
1712 use crossterm::event::KeyModifiers;
1713 match ev {
1714 Event::Key(k) if k.kind == KeyEventKind::Press => match app.stage {
1715 Stage::Splash => app.dismiss_splash(),
1716 Stage::Triptych => match k.code {
1717 KeyCode::Enter if app.mailbox_input_mode.is_some() => app.mailbox_input_confirm(),
1726 KeyCode::Esc if app.mailbox_input_mode.is_some() => app.mailbox_input_cancel(),
1727 KeyCode::Backspace if app.mailbox_input_mode.is_some() => {
1728 app.mailbox_input_pop_char()
1729 }
1730 KeyCode::Char(c)
1738 if app.mailbox_input_mode.is_some()
1739 && (k.modifiers.is_empty() || k.modifiers == KeyModifiers::SHIFT) =>
1740 {
1741 app.mailbox_input_push_char(c)
1742 }
1743 _ if app.mailbox_input_mode.is_some() => {}
1744
1745 KeyCode::Char('q') if app.pending_chord == Some(KeyCode::Char('w')) => {
1750 app.pending_chord = None;
1751 app.close_focused_split();
1752 }
1753 KeyCode::Char('o') if app.pending_chord == Some(KeyCode::Char('w')) => {
1754 app.pending_chord = None;
1755 if !app.detail_splits.is_empty() {
1756 let keep = app.selected_split.min(app.detail_splits.len() - 1);
1757 let kept = app.detail_splits.remove(keep);
1758 app.detail_splits.clear();
1759 app.detail_splits.push(kept);
1760 app.selected_split = 0;
1761 }
1762 }
1763 KeyCode::Char('q') if k.modifiers.is_empty() => app.enter_quit_confirm(),
1764 KeyCode::Char('a') => app.enter_approvals_modal(),
1768 KeyCode::Char('@') => app.enter_compose_dm_for_focused(),
1773 KeyCode::Char('!') => app.enter_compose_broadcast_with_picker(),
1774 KeyCode::Char('w') | KeyCode::Char('W')
1784 if k.modifiers.contains(KeyModifiers::CONTROL)
1785 && !app.detail_splits.is_empty() =>
1786 {
1787 app.pending_chord = Some(KeyCode::Char('w'))
1788 }
1789 KeyCode::Char('w') | KeyCode::Char('W')
1794 if k.modifiers.contains(KeyModifiers::CONTROL) =>
1795 {
1796 app.toggle_wall_layout()
1797 }
1798 KeyCode::Char('m') | KeyCode::Char('M')
1799 if k.modifiers.contains(KeyModifiers::CONTROL) =>
1800 {
1801 app.toggle_mailbox_first_layout()
1802 }
1803 KeyCode::Char('|') if k.modifiers.contains(KeyModifiers::CONTROL) => {
1807 app.add_detail_split_vertical()
1808 }
1809 KeyCode::Char('-') if k.modifiers.contains(KeyModifiers::CONTROL) => {
1810 app.add_detail_split_horizontal()
1811 }
1812 KeyCode::Char('h')
1817 | KeyCode::Char('H')
1818 | KeyCode::Char('k')
1819 | KeyCode::Char('K')
1820 if k.modifiers.contains(KeyModifiers::CONTROL) =>
1821 {
1822 app.cycle_split_prev()
1823 }
1824 KeyCode::Char('l')
1825 | KeyCode::Char('L')
1826 | KeyCode::Char('j')
1827 | KeyCode::Char('J')
1828 if k.modifiers.contains(KeyModifiers::CONTROL) =>
1829 {
1830 app.cycle_split_next()
1831 }
1832 KeyCode::Char('q') | KeyCode::Char('Q')
1837 if k.modifiers.contains(KeyModifiers::CONTROL) =>
1838 {
1839 app.close_focused_split()
1840 }
1841 KeyCode::Char('e') | KeyCode::Char('E')
1849 if k.modifiers.contains(KeyModifiers::CONTROL)
1850 && app.focused_pane == Pane::Detail =>
1851 {
1852 app.enter_stream_keys()
1853 }
1854 KeyCode::Char('?')
1862 if k.modifiers.is_empty() || k.modifiers == KeyModifiers::SHIFT =>
1863 {
1864 app.enter_help_overlay()
1865 }
1866 KeyCode::Char('t') if k.modifiers.is_empty() => app.enter_tutorial(),
1867 KeyCode::BackTab => app.cycle_focus_back(),
1871 KeyCode::Tab if k.modifiers.contains(KeyModifiers::SHIFT) => app.cycle_focus_back(),
1872 KeyCode::Tab => app.cycle_focus(),
1880 KeyCode::Right if app.focused_pane == Pane::Mailbox => app.cycle_mailbox_tab(),
1887 KeyCode::Left if app.focused_pane == Pane::Mailbox => app.cycle_mailbox_tab_back(),
1888 KeyCode::Up | KeyCode::Char('k') if matches!(app.layout, MainLayout::Wall) => {
1893 app.wall_scroll_up()
1894 }
1895 KeyCode::Down | KeyCode::Char('j') if matches!(app.layout, MainLayout::Wall) => {
1896 app.wall_scroll_down()
1897 }
1898 KeyCode::Up | KeyCode::Char('k')
1901 if matches!(app.layout, MainLayout::MailboxFirst) =>
1902 {
1903 app.select_prev_channel()
1904 }
1905 KeyCode::Down | KeyCode::Char('j')
1906 if matches!(app.layout, MainLayout::MailboxFirst) =>
1907 {
1908 app.select_next_channel()
1909 }
1910 KeyCode::Up | KeyCode::Char('k') if app.focused_pane == Pane::Mailbox => {
1923 app.mailbox_cursor_up()
1924 }
1925 KeyCode::Down | KeyCode::Char('j') if app.focused_pane == Pane::Mailbox => {
1926 app.mailbox_cursor_down()
1927 }
1928 KeyCode::PageUp if app.focused_pane == Pane::Mailbox => app.mailbox_page_up(),
1929 KeyCode::PageDown if app.focused_pane == Pane::Mailbox => app.mailbox_page_down(),
1930 KeyCode::Home if app.focused_pane == Pane::Mailbox => app.mailbox_cursor_home(),
1931 KeyCode::End if app.focused_pane == Pane::Mailbox => app.mailbox_cursor_end(),
1932 KeyCode::Char('f') if app.focused_pane == Pane::Mailbox => {
1939 app.open_mailbox_filter_input()
1940 }
1941 KeyCode::Char('/') if app.focused_pane == Pane::Mailbox => {
1942 app.open_mailbox_search_input()
1943 }
1944 KeyCode::Enter if app.focused_pane == Pane::Mailbox => {
1955 app.open_mailbox_detail_modal()
1956 }
1957 KeyCode::Up | KeyCode::Char('k') if app.focused_pane == Pane::Roster => {
1961 app.select_prev()
1962 }
1963 KeyCode::Down | KeyCode::Char('j') if app.focused_pane == Pane::Roster => {
1964 app.select_next()
1965 }
1966 _ => {}
1967 },
1968 Stage::QuitConfirm => match k.code {
1969 KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => app.confirm_quit(),
1970 KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => app.cancel_quit(),
1971 _ => {}
1972 },
1973 Stage::ApprovalsModal => match k.code {
1974 KeyCode::Char('y') | KeyCode::Char('Y') => {
1983 app.apply_decision(decider, Decision::Approve, "")
1984 }
1985 KeyCode::Char('N') => app.apply_decision(decider, Decision::Deny, ""),
1986 KeyCode::Char('j') | KeyCode::Down => app.cycle_approval_next(),
1987 KeyCode::Char('k') | KeyCode::Up => app.cycle_approval_prev(),
1988 KeyCode::Esc | KeyCode::Char('q') => app.close_approvals_modal(),
1989 _ => {}
1990 },
1991 Stage::ComposeModal => {
1992 if app.compose_picker_open {
1996 match k.code {
1997 KeyCode::Down | KeyCode::Char('j') => app.picker_next(),
1998 KeyCode::Up | KeyCode::Char('k') => app.picker_prev(),
1999 KeyCode::Enter => app.picker_confirm(),
2000 KeyCode::Esc => {
2009 app.compose_picker_open = false;
2010 app.compose_picker_index = 0;
2011 }
2012 _ => {}
2013 }
2014 } else if app.compose_attach_input_open {
2015 match k.code {
2022 KeyCode::Char(c) => app.compose_attach_buffer.push(c),
2023 KeyCode::Backspace => {
2024 app.compose_attach_buffer.pop();
2025 }
2026 KeyCode::Enter => app.confirm_compose_attach_input(),
2027 KeyCode::Esc => app.close_compose_attach_input(),
2028 _ => {}
2029 }
2030 } else if k.code == KeyCode::Tab {
2031 app.open_compose_attach_input();
2036 } else {
2037 match app.compose_editor.apply_key(k) {
2040 EditorAction::Continue => {}
2041 EditorAction::Send => app.apply_send(sender, mailbox_source),
2042 EditorAction::Cancel => app.close_compose_modal(),
2043 }
2044 }
2045 }
2046 Stage::HelpOverlay => match k.code {
2047 KeyCode::Esc | KeyCode::Char('?') | KeyCode::Char('q') => app.close_help_overlay(),
2048 _ => {}
2049 },
2050 Stage::MailboxDetailModal => match k.code {
2056 KeyCode::Esc | KeyCode::Char('q') => app.close_mailbox_detail_modal(),
2057 KeyCode::Char('j') | KeyCode::Down => app.mailbox_detail_scroll_down(),
2058 KeyCode::Char('k') | KeyCode::Up => app.mailbox_detail_scroll_up(),
2059 _ => {}
2060 },
2061 Stage::Tutorial => match k.code {
2062 KeyCode::Esc => app.close_tutorial(),
2063 KeyCode::Char('k') | KeyCode::Up | KeyCode::Char('p') => app.tutorial_back(),
2064 _ => app.tutorial_advance(),
2065 },
2066 Stage::StreamKeys => {
2074 if matches!(k.code, KeyCode::Esc) {
2075 app.exit_stream_keys();
2076 } else if let Some(session) = app.stream_target_session() {
2077 if let Some(encoded) = encode_key(k) {
2078 let _ = key_sender.send(&session, &encoded);
2083 }
2084 } else {
2085 app.exit_stream_keys();
2090 }
2091 }
2092 },
2093 Event::Resize(_, _) => {
2094 }
2096 Event::Mouse(m) if matches!(app.stage, Stage::Triptych) => {
2106 use crossterm::event::MouseEventKind;
2107 let direction = match m.kind {
2108 MouseEventKind::ScrollUp => Some(ScrollDirection::Up),
2109 MouseEventKind::ScrollDown => Some(ScrollDirection::Down),
2110 _ => None,
2111 };
2112 if let Some(dir) = direction {
2113 match app.focused_pane {
2114 Pane::Detail => {
2115 if let Some(session) = app.focused_session().map(|s| s.to_string()) {
2116 let _ = key_sender.scroll(&session, dir);
2121 }
2122 }
2123 Pane::Roster => match dir {
2124 ScrollDirection::Up => app.select_prev(),
2125 ScrollDirection::Down => app.select_next(),
2126 },
2127 Pane::Mailbox => match dir {
2128 ScrollDirection::Up => app.mailbox_cursor_up(),
2129 ScrollDirection::Down => app.mailbox_cursor_down(),
2130 },
2131 }
2132 }
2133 }
2134 _ => {}
2135 }
2136}
2137
2138pub fn render_to_buffer(app: &App, width: u16, height: u16) -> Buffer {
2142 let area = Rect::new(0, 0, width, height);
2143 let mut buf = Buffer::empty(area);
2144 match app.stage {
2145 Stage::Splash => splash::Splash { app }.render(area, &mut buf),
2146 Stage::Triptych => render_main(app, area, &mut buf),
2147 Stage::StreamKeys => render_main(app, area, &mut buf),
2148 Stage::QuitConfirm => {
2149 render_main(app, area, &mut buf);
2150 render_quit_confirm(area, &mut buf);
2151 }
2152 Stage::ApprovalsModal => {
2153 render_main(app, area, &mut buf);
2154 render_approvals_modal(area, &mut buf, app);
2155 }
2156 Stage::ComposeModal => {
2157 render_main(app, area, &mut buf);
2158 render_compose_modal(area, &mut buf, app);
2159 }
2160 Stage::HelpOverlay => {
2161 render_main(app, area, &mut buf);
2162 render_help_overlay(area, &mut buf, app);
2163 }
2164 Stage::Tutorial => {
2165 render_main(app, area, &mut buf);
2166 render_tutorial(area, &mut buf, app);
2167 }
2168 Stage::MailboxDetailModal => {
2169 render_main(app, area, &mut buf);
2170 render_mailbox_detail_modal(area, &mut buf, app);
2171 }
2172 }
2173 buf
2174}
2175
2176fn render_main(app: &App, area: Rect, buf: &mut Buffer) {
2177 let chunks = Layout::default()
2180 .direction(Direction::Vertical)
2181 .constraints([
2182 Constraint::Min(3),
2183 Constraint::Length(1), Constraint::Length(1), ])
2186 .split(area);
2187 match app.layout {
2188 crate::triptych::MainLayout::Triptych => {
2189 triptych::Triptych { app }.render(chunks[0], buf);
2190 }
2191 crate::triptych::MainLayout::Wall => {
2192 layouts::Wall { app }.render(chunks[0], buf);
2193 }
2194 crate::triptych::MainLayout::MailboxFirst => {
2195 layouts::MailboxFirst { app }.render(chunks[0], buf);
2196 }
2197 }
2198 statusline::Statusline { app }.render(chunks[1], buf);
2199 status_bar::StatusBar { app }.render(chunks[2], buf);
2200}
2201
2202fn render_quit_confirm(area: Rect, buf: &mut Buffer) {
2203 let popup_w = 36u16.min(area.width.saturating_sub(2));
2204 let popup_h = 5u16.min(area.height.saturating_sub(2));
2205 let popup = centered_rect(popup_w, popup_h, area);
2206 Clear.render(popup, buf);
2207 Paragraph::new("Quit teamctl-ui? [y / n]")
2208 .alignment(Alignment::Center)
2209 .block(Block::default().borders(Borders::ALL).title("confirm"))
2210 .render(popup, buf);
2211}
2212
2213#[cfg(test)]
2214mod tests {
2215 use super::*;
2216 use crate::data::AgentInfo;
2217 use crossterm::event::{KeyEvent, KeyEventState, KeyModifiers};
2218 use team_core::supervisor::AgentState;
2219
2220 fn key(code: KeyCode) -> Event {
2221 Event::Key(KeyEvent {
2222 code,
2223 modifiers: KeyModifiers::NONE,
2224 kind: KeyEventKind::Press,
2225 state: KeyEventState::NONE,
2226 })
2227 }
2228
2229 fn key_with(code: KeyCode, modifiers: KeyModifiers) -> Event {
2230 Event::Key(KeyEvent {
2231 code,
2232 modifiers,
2233 kind: KeyEventKind::Press,
2234 state: KeyEventState::NONE,
2235 })
2236 }
2237
2238 struct NoopDecider;
2240 impl crate::approvals::ApprovalDecider for NoopDecider {
2241 fn decide(
2242 &self,
2243 _root: &std::path::Path,
2244 _id: i64,
2245 _kind: crate::approvals::Decision,
2246 _note: &str,
2247 ) -> anyhow::Result<()> {
2248 Ok(())
2249 }
2250 }
2251
2252 struct NoopSender;
2254 impl crate::compose::MessageSender for NoopSender {
2255 fn send_dm(
2256 &self,
2257 _root: &std::path::Path,
2258 _agent: &str,
2259 _body: &str,
2260 ) -> anyhow::Result<()> {
2261 Ok(())
2262 }
2263 fn broadcast(
2264 &self,
2265 _root: &std::path::Path,
2266 _channel: &str,
2267 _body: &str,
2268 ) -> anyhow::Result<()> {
2269 Ok(())
2270 }
2271 }
2272
2273 struct EmptyMailbox;
2276 impl crate::mailbox::MailboxSource for EmptyMailbox {
2277 fn inbox(&self, _id: &str, _after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
2278 Ok(Vec::new())
2279 }
2280 fn sent(&self, _id: &str, _after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
2281 Ok(Vec::new())
2282 }
2283 fn channel_feed(
2284 &self,
2285 _id: &str,
2286 _after: i64,
2287 ) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
2288 Ok(Vec::new())
2289 }
2290 fn wire(&self, _id: &str, _after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
2291 Ok(Vec::new())
2292 }
2293 }
2294
2295 fn dispatch(app: &mut App, ev: Event) {
2298 super::handle_event(
2299 app,
2300 ev,
2301 &NoopDecider,
2302 &NoopSender,
2303 &EmptyMailbox,
2304 &crate::keysender::test_support::MockKeySender::default(),
2305 );
2306 }
2307
2308 fn agent(id: &str, state: AgentState) -> AgentInfo {
2309 AgentInfo {
2310 id: id.into(),
2311 agent: id
2312 .split_once(':')
2313 .map(|(_, a)| a.to_string())
2314 .unwrap_or_default(),
2315 project: id
2316 .split_once(':')
2317 .map(|(p, _)| p.to_string())
2318 .unwrap_or_default(),
2319 tmux_session: format!("t-{}", id.replace(':', "-")),
2320 state,
2321 unread_mail: 0,
2322 pending_approvals: 0,
2323 is_manager: false,
2324 display_name: None,
2325 rate_limit_resets_at: None,
2326 reports_to: None,
2327 }
2328 }
2329
2330 pub fn fixture_team(agents: Vec<AgentInfo>) -> TeamSnapshot {
2331 TeamSnapshot {
2332 root: std::path::PathBuf::from("/fixture"),
2333 team_name: "fixture".into(),
2334 agents,
2335 channels: Vec::new(),
2336 }
2337 }
2338
2339 #[test]
2340 fn splash_dismissed_by_any_key() {
2341 let mut app = App::new();
2342 assert_eq!(app.stage, Stage::Splash);
2343 dispatch(&mut app, key(KeyCode::Char(' ')));
2344 assert_eq!(app.stage, Stage::Triptych);
2345 }
2346
2347 #[test]
2348 fn tab_cycles_panes_uniformly_and_wraps_through_mailbox() {
2349 let mut app = App::new();
2356 app.dismiss_splash();
2357 assert_eq!(app.focused_pane, Pane::Roster);
2358 dispatch(&mut app, key(KeyCode::Tab));
2359 assert_eq!(app.focused_pane, Pane::Detail);
2360 dispatch(&mut app, key(KeyCode::Tab));
2361 assert_eq!(app.focused_pane, Pane::Mailbox);
2362 assert_eq!(
2363 app.mailbox_tab,
2364 MailboxTab::Inbox,
2365 "Tab into mailbox does NOT touch the active mailbox tab"
2366 );
2367 dispatch(&mut app, key(KeyCode::Tab));
2368 assert_eq!(
2369 app.focused_pane,
2370 Pane::Roster,
2371 "Tab from mailbox wraps to roster, not into mailbox subtabs"
2372 );
2373 assert_eq!(
2374 app.mailbox_tab,
2375 MailboxTab::Inbox,
2376 "mailbox tab still untouched"
2377 );
2378 }
2379
2380 #[test]
2381 fn arrow_keys_walk_mailbox_tabs_when_mailbox_focused() {
2382 let mut app = App::new();
2387 app.dismiss_splash();
2388 dispatch(&mut app, key(KeyCode::Tab));
2390 dispatch(&mut app, key(KeyCode::Tab));
2391 assert_eq!(app.focused_pane, Pane::Mailbox);
2392 assert_eq!(app.mailbox_tab, MailboxTab::Inbox);
2393
2394 dispatch(&mut app, key(KeyCode::Right));
2395 assert_eq!(app.mailbox_tab, MailboxTab::Sent);
2396 dispatch(&mut app, key(KeyCode::Right));
2397 assert_eq!(app.mailbox_tab, MailboxTab::Channel);
2398 dispatch(&mut app, key(KeyCode::Right));
2399 assert_eq!(app.mailbox_tab, MailboxTab::Wire);
2400 dispatch(&mut app, key(KeyCode::Right));
2401 assert_eq!(app.mailbox_tab, MailboxTab::Inbox, "→ wraps");
2402
2403 dispatch(&mut app, key(KeyCode::Left));
2404 assert_eq!(app.mailbox_tab, MailboxTab::Wire, "← walks back");
2405 }
2406
2407 #[test]
2408 fn arrow_keys_no_op_when_mailbox_not_focused() {
2409 let mut app = App::new();
2412 app.dismiss_splash();
2413 assert_eq!(app.focused_pane, Pane::Roster);
2414 let initial = app.mailbox_tab;
2415 dispatch(&mut app, key(KeyCode::Right));
2416 dispatch(&mut app, key(KeyCode::Left));
2417 assert_eq!(
2418 app.mailbox_tab, initial,
2419 "←/→ from non-mailbox panes must not flip the active tab"
2420 );
2421 }
2422
2423 #[test]
2424 fn brackets_no_longer_cycle_mailbox_tabs() {
2425 let mut app = App::new();
2430 app.dismiss_splash();
2431 dispatch(&mut app, key(KeyCode::Tab));
2432 dispatch(&mut app, key(KeyCode::Tab));
2433 assert_eq!(app.focused_pane, Pane::Mailbox);
2434 let initial = app.mailbox_tab;
2435
2436 dispatch(&mut app, key(KeyCode::Char(']')));
2437 dispatch(&mut app, key(KeyCode::Char('[')));
2438 assert_eq!(
2439 app.mailbox_tab, initial,
2440 "`[` / `]` must no longer cycle mailbox tabs (T-124 hard-swap)",
2441 );
2442 }
2443
2444 #[test]
2445 fn q_opens_confirm_then_n_cancels() {
2446 let mut app = App::new();
2447 app.dismiss_splash();
2448 dispatch(&mut app, key(KeyCode::Char('q')));
2449 assert_eq!(app.stage, Stage::QuitConfirm);
2450 dispatch(&mut app, key(KeyCode::Char('n')));
2451 assert_eq!(app.stage, Stage::Triptych);
2452 assert!(app.running, "n must not exit");
2453 }
2454
2455 #[test]
2456 fn q_then_y_exits() {
2457 let mut app = App::new();
2458 app.dismiss_splash();
2459 dispatch(&mut app, key(KeyCode::Char('q')));
2460 dispatch(&mut app, key(KeyCode::Char('y')));
2461 assert!(!app.running);
2462 }
2463
2464 #[test]
2465 fn esc_cancels_quit_confirm() {
2466 let mut app = App::new();
2467 app.dismiss_splash();
2468 app.enter_quit_confirm();
2469 dispatch(&mut app, key(KeyCode::Esc));
2470 assert_eq!(app.stage, Stage::Triptych);
2471 }
2472
2473 #[test]
2474 fn render_does_not_panic_at_minimal_size() {
2475 let app = App::new();
2476 let _ = render_to_buffer(&app, 20, 8);
2477 }
2478
2479 #[test]
2480 fn render_does_not_panic_at_huge_size() {
2481 let app = App::new();
2482 let _ = render_to_buffer(&app, 240, 80);
2483 }
2484
2485 #[test]
2486 fn select_next_wraps_through_team() {
2487 let mut app = App::new();
2488 app.replace_team(fixture_team(vec![
2489 agent("p:a", AgentState::Running),
2490 agent("p:b", AgentState::Running),
2491 agent("p:c", AgentState::Running),
2492 ]));
2493 assert_eq!(app.selected_agent, Some(0));
2494 app.select_next();
2495 assert_eq!(app.selected_agent, Some(1));
2496 app.select_next();
2497 assert_eq!(app.selected_agent, Some(2));
2498 app.select_next();
2499 assert_eq!(app.selected_agent, Some(0)); }
2501
2502 #[test]
2503 fn select_prev_wraps_at_top() {
2504 let mut app = App::new();
2505 app.replace_team(fixture_team(vec![
2506 agent("p:a", AgentState::Running),
2507 agent("p:b", AgentState::Running),
2508 ]));
2509 app.selected_agent = Some(0);
2510 app.select_prev();
2511 assert_eq!(app.selected_agent, Some(1));
2512 }
2513
2514 #[test]
2515 fn select_no_op_on_empty_team() {
2516 let mut app = App::new();
2517 app.select_next();
2518 assert_eq!(app.selected_agent, None);
2519 app.select_prev();
2520 assert_eq!(app.selected_agent, None);
2521 }
2522
2523 #[test]
2524 fn replace_team_preserves_selection_when_agent_still_present() {
2525 let mut app = App::new();
2526 app.replace_team(fixture_team(vec![
2527 agent("p:a", AgentState::Running),
2528 agent("p:b", AgentState::Running),
2529 ]));
2530 app.selected_agent = Some(1);
2531 app.replace_team(fixture_team(vec![
2532 agent("p:a", AgentState::Running),
2533 agent("p:b", AgentState::Stopped), ]));
2535 assert_eq!(app.selected_agent, Some(1), "selection follows the id");
2536 }
2537
2538 #[test]
2539 fn replace_team_resets_selection_when_agent_disappears() {
2540 let mut app = App::new();
2541 app.replace_team(fixture_team(vec![
2542 agent("p:a", AgentState::Running),
2543 agent("p:gone", AgentState::Running),
2544 ]));
2545 app.selected_agent = Some(1);
2546 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
2547 assert_eq!(app.selected_agent, Some(0), "falls back to first agent");
2548 }
2549
2550 #[test]
2551 fn switching_agent_resets_mailbox_buffers() {
2552 let mut app = App::new();
2556 app.replace_team(fixture_team(vec![
2557 agent("p:a", AgentState::Running),
2558 agent("p:b", AgentState::Running),
2559 ]));
2560 app.mailbox.extend(
2561 crate::mailbox::MailboxTab::Inbox,
2562 vec![crate::mailbox::MessageRow {
2563 id: 7,
2564 sender: "p:b".into(),
2565 recipient: "p:a".into(),
2566 text: "hi".into(),
2567 sent_at: 0.0,
2568 }],
2569 );
2570 assert_eq!(app.mailbox.inbox.len(), 1);
2571 assert_eq!(app.mailbox.inbox_after, 7);
2572 app.select_next();
2574 assert_eq!(app.selected_agent_id().as_deref(), Some("p:b"));
2575 assert!(app.mailbox.inbox.is_empty());
2576 assert_eq!(app.mailbox.inbox_after, 0);
2577 }
2578
2579 struct TripleFilterMock {
2584 inbox: Vec<crate::mailbox::MessageRow>,
2585 sent: Vec<crate::mailbox::MessageRow>,
2586 channel: Vec<crate::mailbox::MessageRow>,
2587 wire: Vec<crate::mailbox::MessageRow>,
2588 calls: std::sync::Mutex<Vec<(&'static str, String, i64)>>,
2589 }
2590 impl crate::mailbox::MailboxSource for TripleFilterMock {
2591 fn inbox(&self, id: &str, after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
2592 self.calls.lock().unwrap().push(("inbox", id.into(), after));
2593 Ok(self.inbox.clone())
2594 }
2595 fn sent(&self, id: &str, after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
2596 self.calls.lock().unwrap().push(("sent", id.into(), after));
2597 Ok(self.sent.clone())
2598 }
2599 fn channel_feed(
2600 &self,
2601 id: &str,
2602 after: i64,
2603 ) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
2604 self.calls
2605 .lock()
2606 .unwrap()
2607 .push(("channel", id.into(), after));
2608 Ok(self.channel.clone())
2609 }
2610 fn wire(&self, id: &str, after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
2611 self.calls.lock().unwrap().push(("wire", id.into(), after));
2612 Ok(self.wire.clone())
2613 }
2614 }
2615
2616 #[test]
2617 fn refresh_mailbox_fans_out_to_four_filters() {
2618 use crate::mailbox::MessageRow;
2619 let mut app = App::new();
2620 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
2621 let mock = TripleFilterMock {
2622 inbox: vec![MessageRow {
2623 id: 1,
2624 sender: "p:b".into(),
2625 recipient: "p:a".into(),
2626 text: "dm".into(),
2627 sent_at: 0.0,
2628 }],
2629 sent: vec![MessageRow {
2630 id: 4,
2631 sender: "p:a".into(),
2632 recipient: "p:b".into(),
2633 text: "outgoing dm".into(),
2634 sent_at: 0.0,
2635 }],
2636 channel: vec![MessageRow {
2637 id: 2,
2638 sender: "p:b".into(),
2639 recipient: "channel:p:editorial".into(),
2640 text: "ch".into(),
2641 sent_at: 0.0,
2642 }],
2643 wire: vec![MessageRow {
2644 id: 3,
2645 sender: "p:b".into(),
2646 recipient: "channel:p:all".into(),
2647 text: "wire".into(),
2648 sent_at: 0.0,
2649 }],
2650 calls: std::sync::Mutex::new(Vec::new()),
2651 };
2652 super::refresh_mailbox(&mut app, &mock);
2653 assert_eq!(app.mailbox.inbox.len(), 1);
2654 assert_eq!(app.mailbox.sent.len(), 1);
2655 assert_eq!(app.mailbox.channel.len(), 1);
2656 assert_eq!(app.mailbox.wire.len(), 1);
2657 let calls = mock.calls.lock().unwrap();
2658 assert!(calls.contains(&("inbox", "p:a".into(), 0)));
2661 assert!(calls.contains(&("sent", "p:a".into(), 0)));
2662 assert!(calls.contains(&("channel", "p:a".into(), 0)));
2663 assert!(calls.contains(&("wire", "p".into(), 0)));
2664 }
2665
2666 fn ap(id: i64) -> crate::approvals::Approval {
2667 crate::approvals::Approval {
2668 id,
2669 project_id: "p".into(),
2670 agent_id: "p:m".into(),
2671 action: "publish".into(),
2672 summary: format!("approval #{id}"),
2673 payload_json: String::new(),
2674 }
2675 }
2676
2677 #[test]
2678 fn has_pending_approvals_tracks_replace_calls() {
2679 let mut app = App::new();
2680 assert!(!app.has_pending_approvals());
2681 app.replace_approvals(vec![ap(1), ap(2)]);
2682 assert!(app.has_pending_approvals());
2683 app.replace_approvals(vec![]);
2684 assert!(!app.has_pending_approvals());
2685 }
2686
2687 #[test]
2688 fn enter_approvals_modal_no_op_when_queue_empty() {
2689 let mut app = App::new();
2690 app.dismiss_splash();
2691 app.enter_approvals_modal();
2692 assert_eq!(app.stage, Stage::Triptych, "no pending → no modal");
2693 }
2694
2695 #[test]
2696 fn a_chord_opens_modal_when_pending() {
2697 let mut app = App::new();
2698 app.dismiss_splash();
2699 app.replace_approvals(vec![ap(1), ap(2)]);
2700 dispatch(&mut app, key(KeyCode::Char('a')));
2701 assert_eq!(app.stage, Stage::ApprovalsModal);
2702 assert_eq!(app.selected_approval, 0);
2703 }
2704
2705 #[test]
2706 fn modal_cycle_jk_walks_approvals() {
2707 let mut app = App::new();
2708 app.dismiss_splash();
2709 app.replace_approvals(vec![ap(1), ap(2), ap(3)]);
2710 app.enter_approvals_modal();
2711 dispatch(&mut app, key(KeyCode::Char('j')));
2712 assert_eq!(app.selected_approval, 1);
2713 dispatch(&mut app, key(KeyCode::Char('j')));
2714 assert_eq!(app.selected_approval, 2);
2715 dispatch(&mut app, key(KeyCode::Char('j')));
2716 assert_eq!(app.selected_approval, 0, "wraps");
2717 dispatch(&mut app, key(KeyCode::Char('k')));
2718 assert_eq!(app.selected_approval, 2, "k wraps too");
2719 }
2720
2721 #[test]
2722 fn capital_y_routes_approve_through_decider() {
2723 use crate::approvals::test_support::MockApprovalDecider;
2724 let dec = MockApprovalDecider::default();
2725 let mut app = App::new();
2726 app.dismiss_splash();
2727 app.replace_approvals(vec![ap(7), ap(8)]);
2728 app.enter_approvals_modal();
2729 super::handle_event(
2730 &mut app,
2731 key(KeyCode::Char('Y')),
2732 &dec,
2733 &NoopSender,
2734 &EmptyMailbox,
2735 &crate::keysender::test_support::MockKeySender::default(),
2736 );
2737 let calls = dec.calls.lock().unwrap().clone();
2738 assert_eq!(calls.len(), 1);
2739 assert_eq!(calls[0].0, 7);
2740 assert_eq!(calls[0].1, crate::approvals::Decision::Approve);
2741 assert_eq!(app.pending_approvals.len(), 1);
2743 assert_eq!(app.pending_approvals[0].id, 8);
2744 }
2745
2746 #[test]
2747 fn capital_n_routes_deny_through_decider() {
2748 use crate::approvals::test_support::MockApprovalDecider;
2749 let dec = MockApprovalDecider::default();
2750 let mut app = App::new();
2751 app.dismiss_splash();
2752 app.replace_approvals(vec![ap(7)]);
2753 app.enter_approvals_modal();
2754 super::handle_event(
2755 &mut app,
2756 key(KeyCode::Char('N')),
2757 &dec,
2758 &NoopSender,
2759 &EmptyMailbox,
2760 &crate::keysender::test_support::MockKeySender::default(),
2761 );
2762 let calls = dec.calls.lock().unwrap().clone();
2763 assert_eq!(calls.len(), 1);
2764 assert_eq!(calls[0].1, crate::approvals::Decision::Deny);
2765 assert_eq!(app.stage, Stage::Triptych);
2767 }
2768
2769 #[test]
2770 fn esc_closes_approvals_modal() {
2771 let mut app = App::new();
2772 app.dismiss_splash();
2773 app.replace_approvals(vec![ap(1)]);
2774 app.enter_approvals_modal();
2775 dispatch(&mut app, key(KeyCode::Esc));
2776 assert_eq!(app.stage, Stage::Triptych);
2777 }
2778
2779 #[test]
2780 fn lowercase_y_routes_approve_through_decider() {
2781 use crate::approvals::test_support::MockApprovalDecider;
2785 let dec = MockApprovalDecider::default();
2786 let mut app = App::new();
2787 app.dismiss_splash();
2788 app.replace_approvals(vec![ap(7)]);
2789 app.enter_approvals_modal();
2790 super::handle_event(
2791 &mut app,
2792 key(KeyCode::Char('y')),
2793 &dec,
2794 &NoopSender,
2795 &EmptyMailbox,
2796 &crate::keysender::test_support::MockKeySender::default(),
2797 );
2798 let calls = dec.calls.lock().unwrap().clone();
2799 assert_eq!(calls.len(), 1);
2800 assert_eq!(calls[0].1, crate::approvals::Decision::Approve);
2801 }
2802
2803 #[test]
2804 fn lowercase_n_does_not_deny() {
2805 use crate::approvals::test_support::MockApprovalDecider;
2810 let dec = MockApprovalDecider::default();
2811 let mut app = App::new();
2812 app.dismiss_splash();
2813 app.replace_approvals(vec![ap(7)]);
2814 app.enter_approvals_modal();
2815 super::handle_event(
2816 &mut app,
2817 key(KeyCode::Char('n')),
2818 &dec,
2819 &NoopSender,
2820 &EmptyMailbox,
2821 &crate::keysender::test_support::MockKeySender::default(),
2822 );
2823 assert!(
2824 dec.calls.lock().unwrap().is_empty(),
2825 "lowercase n must not route through the decider"
2826 );
2827 assert_eq!(
2828 app.stage,
2829 Stage::ApprovalsModal,
2830 "stale lowercase n leaves the modal open"
2831 );
2832 }
2833
2834 #[test]
2835 fn shift_tab_cycles_panes_backward() {
2836 use crossterm::event::KeyModifiers;
2837 let mut app = App::new();
2838 app.dismiss_splash();
2839 assert_eq!(app.focused_pane, Pane::Roster);
2840 dispatch(&mut app, key(KeyCode::BackTab));
2843 assert_eq!(app.focused_pane, Pane::Mailbox);
2844 dispatch(&mut app, key_with(KeyCode::Tab, KeyModifiers::SHIFT));
2846 assert_eq!(app.focused_pane, Pane::Detail);
2847 }
2848
2849 #[test]
2850 fn at_chord_opens_compose_dm_to_focused_agent() {
2851 let mut app = App::new();
2852 app.replace_team(fixture_team(vec![
2853 agent("writing:manager", AgentState::Running),
2854 agent("writing:dev1", AgentState::Running),
2855 ]));
2856 app.dismiss_splash();
2857 app.select_next();
2858 dispatch(&mut app, key(KeyCode::Char('@')));
2859 assert_eq!(app.stage, Stage::ComposeModal);
2860 match app.compose_target.as_ref() {
2861 Some(crate::compose::ComposeTarget::Dm { agent_id, .. }) => {
2862 assert_eq!(agent_id, "writing:dev1");
2863 }
2864 other => panic!("expected DM target, got {other:?}"),
2865 }
2866 }
2867
2868 #[test]
2869 fn bang_chord_opens_compose_broadcast_to_all_channel() {
2870 let mut app = App::new();
2871 app.replace_team(fixture_team(vec![agent(
2872 "writing:manager",
2873 AgentState::Running,
2874 )]));
2875 app.dismiss_splash();
2876 dispatch(&mut app, key(KeyCode::Char('!')));
2877 assert_eq!(app.stage, Stage::ComposeModal);
2878 match app.compose_target.as_ref() {
2879 Some(crate::compose::ComposeTarget::Broadcast { channel_id, .. }) => {
2880 assert_eq!(channel_id, "writing:all");
2881 }
2882 other => panic!("expected Broadcast target, got {other:?}"),
2883 }
2884 }
2885
2886 #[test]
2887 fn send_routes_dm_through_mock_sender() {
2888 use crate::compose::test_support::MockMessageSender;
2889 let sender = MockMessageSender::default();
2890 let mailbox = EmptyMailbox;
2891 let mut app = App::new();
2892 app.replace_team(fixture_team(vec![agent(
2893 "writing:dev1",
2894 AgentState::Running,
2895 )]));
2896 app.dismiss_splash();
2897 app.enter_compose_dm_for_focused();
2898 for c in "ship it".chars() {
2899 super::handle_event(
2900 &mut app,
2901 key(KeyCode::Char(c)),
2902 &NoopDecider,
2903 &sender,
2904 &mailbox,
2905 &crate::keysender::test_support::MockKeySender::default(),
2906 );
2907 }
2908 super::handle_event(
2909 &mut app,
2910 key_with(KeyCode::Enter, crossterm::event::KeyModifiers::CONTROL),
2911 &NoopDecider,
2912 &sender,
2913 &mailbox,
2914 &crate::keysender::test_support::MockKeySender::default(),
2915 );
2916 let calls = sender.dm_calls.lock().unwrap().clone();
2917 assert_eq!(calls.len(), 1);
2918 assert_eq!(calls[0].0, "writing:dev1");
2919 assert_eq!(calls[0].1, "ship it");
2920 assert_eq!(app.stage, Stage::Triptych, "modal closes on send");
2921 }
2922
2923 #[test]
2924 fn esc_esc_cancels_compose_without_send() {
2925 use crate::compose::test_support::MockMessageSender;
2926 let sender = MockMessageSender::default();
2927 let mailbox = EmptyMailbox;
2928 let mut app = App::new();
2929 app.replace_team(fixture_team(vec![agent(
2930 "writing:dev1",
2931 AgentState::Running,
2932 )]));
2933 app.dismiss_splash();
2934 app.enter_compose_dm_for_focused();
2935 for c in "draft".chars() {
2936 super::handle_event(
2937 &mut app,
2938 key(KeyCode::Char(c)),
2939 &NoopDecider,
2940 &sender,
2941 &mailbox,
2942 &crate::keysender::test_support::MockKeySender::default(),
2943 );
2944 }
2945 super::handle_event(
2946 &mut app,
2947 key(KeyCode::Esc),
2948 &NoopDecider,
2949 &sender,
2950 &mailbox,
2951 &crate::keysender::test_support::MockKeySender::default(),
2952 );
2953 super::handle_event(
2954 &mut app,
2955 key(KeyCode::Esc),
2956 &NoopDecider,
2957 &sender,
2958 &mailbox,
2959 &crate::keysender::test_support::MockKeySender::default(),
2960 );
2961 assert_eq!(app.stage, Stage::Triptych);
2962 assert!(sender.dm_calls.lock().unwrap().is_empty());
2963 }
2964
2965 #[test]
2966 fn send_failure_surfaces_error_inline_keeps_modal_open() {
2967 use crate::compose::test_support::MockMessageSender;
2968 let sender = MockMessageSender::default();
2969 *sender.fail_next.lock().unwrap() = Some("rate limit".into());
2970 let mailbox = EmptyMailbox;
2971 let mut app = App::new();
2972 app.replace_team(fixture_team(vec![agent(
2973 "writing:dev1",
2974 AgentState::Running,
2975 )]));
2976 app.dismiss_splash();
2977 app.enter_compose_dm_for_focused();
2978 super::handle_event(
2979 &mut app,
2980 key(KeyCode::Char('x')),
2981 &NoopDecider,
2982 &sender,
2983 &mailbox,
2984 &crate::keysender::test_support::MockKeySender::default(),
2985 );
2986 super::handle_event(
2987 &mut app,
2988 key_with(KeyCode::Enter, crossterm::event::KeyModifiers::CONTROL),
2989 &NoopDecider,
2990 &sender,
2991 &mailbox,
2992 &crate::keysender::test_support::MockKeySender::default(),
2993 );
2994 assert_eq!(app.stage, Stage::ComposeModal, "modal stays open on err");
2995 assert!(app
2996 .compose_error
2997 .as_deref()
2998 .unwrap_or_default()
2999 .contains("rate limit"));
3000 }
3001
3002 fn channel(id: &str, project: &str) -> crate::data::ChannelInfo {
3003 crate::data::ChannelInfo {
3004 id: id.into(),
3005 name: id
3006 .rsplit_once(':')
3007 .map(|(_, n)| n.to_string())
3008 .unwrap_or_default(),
3009 project_id: project.into(),
3010 }
3011 }
3012
3013 fn fixture_team_with_channels(
3014 agents: Vec<AgentInfo>,
3015 channels: Vec<crate::data::ChannelInfo>,
3016 ) -> TeamSnapshot {
3017 TeamSnapshot {
3018 root: std::path::PathBuf::from("/fixture"),
3019 team_name: "fixture".into(),
3020 agents,
3021 channels,
3022 }
3023 }
3024
3025 #[test]
3026 fn ctrl_w_toggles_wall_layout() {
3027 use crossterm::event::KeyModifiers;
3028 let mut app = App::new();
3029 app.dismiss_splash();
3030 assert_eq!(app.layout, MainLayout::Triptych);
3031 dispatch(
3032 &mut app,
3033 key_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
3034 );
3035 assert_eq!(app.layout, MainLayout::Wall);
3036 dispatch(
3037 &mut app,
3038 key_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
3039 );
3040 assert_eq!(app.layout, MainLayout::Triptych);
3041 }
3042
3043 #[test]
3044 fn ctrl_m_toggles_mailbox_first_layout() {
3045 use crossterm::event::KeyModifiers;
3046 let mut app = App::new();
3047 app.dismiss_splash();
3048 dispatch(
3049 &mut app,
3050 key_with(KeyCode::Char('m'), KeyModifiers::CONTROL),
3051 );
3052 assert_eq!(app.layout, MainLayout::MailboxFirst);
3053 dispatch(
3054 &mut app,
3055 key_with(KeyCode::Char('m'), KeyModifiers::CONTROL),
3056 );
3057 assert_eq!(app.layout, MainLayout::Triptych);
3058 }
3059
3060 #[test]
3061 fn wall_scroll_pages_through_overflow_agents() {
3062 let mut app = App::new();
3063 let mut agents: Vec<_> = (1..=10)
3064 .map(|i| agent(&format!("p:agent-{i:02}"), AgentState::Running))
3065 .collect();
3066 for a in agents.iter_mut() {
3068 a.is_manager = false;
3069 }
3070 app.replace_team(fixture_team(agents));
3071 app.dismiss_splash();
3072 app.toggle_wall_layout();
3073 assert_eq!(app.wall_scroll, 0);
3074 app.wall_scroll_down();
3075 assert_eq!(app.wall_scroll, 4);
3076 app.wall_scroll_down();
3077 assert_eq!(app.wall_scroll, 8);
3078 app.wall_scroll_down();
3080 assert_eq!(app.wall_scroll, 8, "scroll capped at last full window");
3081 app.wall_scroll_up();
3082 assert_eq!(app.wall_scroll, 4);
3083 }
3084
3085 #[test]
3086 fn ctrl_pipe_adds_detail_split_capped_at_four() {
3087 use crossterm::event::KeyModifiers;
3088 let mut app = App::new();
3089 app.replace_team(fixture_team(vec![
3090 agent("p:a", AgentState::Running),
3091 agent("p:b", AgentState::Running),
3092 ]));
3093 app.dismiss_splash();
3094 for _ in 0..6 {
3095 dispatch(
3096 &mut app,
3097 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
3098 );
3099 }
3100 assert_eq!(app.detail_splits.len(), 4, "split count capped at 4");
3101 }
3102
3103 #[test]
3104 fn ctrl_q_closes_focused_split() {
3105 use crossterm::event::KeyModifiers;
3106 let mut app = App::new();
3107 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3108 app.dismiss_splash();
3109 dispatch(
3110 &mut app,
3111 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
3112 );
3113 dispatch(
3114 &mut app,
3115 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
3116 );
3117 assert_eq!(app.detail_splits.len(), 2);
3118 dispatch(
3119 &mut app,
3120 key_with(KeyCode::Char('Q'), KeyModifiers::CONTROL),
3121 );
3122 assert_eq!(app.detail_splits.len(), 1);
3123 }
3124
3125 #[test]
3126 fn ctrl_hjkl_cycles_splits() {
3127 use crossterm::event::KeyModifiers;
3128 let mut app = App::new();
3129 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3130 app.dismiss_splash();
3131 for _ in 0..3 {
3132 dispatch(
3133 &mut app,
3134 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
3135 );
3136 }
3137 assert_eq!(app.selected_split, 2);
3138 dispatch(
3139 &mut app,
3140 key_with(KeyCode::Char('l'), KeyModifiers::CONTROL),
3141 );
3142 assert_eq!(app.selected_split, 0, "wraps");
3143 dispatch(
3144 &mut app,
3145 key_with(KeyCode::Char('h'), KeyModifiers::CONTROL),
3146 );
3147 assert_eq!(app.selected_split, 2);
3148 }
3149
3150 #[test]
3151 fn wall_scroll_at_exactly_cap_agents_does_not_scroll() {
3152 let mut app = App::new();
3157 let agents: Vec<_> = (1..=4)
3158 .map(|i| agent(&format!("p:agent-{i}"), AgentState::Running))
3159 .collect();
3160 app.replace_team(fixture_team(agents));
3161 app.dismiss_splash();
3162 app.toggle_wall_layout();
3163 assert_eq!(app.wall_scroll, 0);
3164 app.wall_scroll_down();
3165 assert_eq!(app.wall_scroll, 0, "exactly-cap should not advance");
3166 app.wall_scroll_up();
3167 assert_eq!(app.wall_scroll, 0);
3168 }
3169
3170 #[test]
3171 fn wall_scroll_at_cap_plus_one_advances_then_stops() {
3172 let mut app = App::new();
3177 let agents: Vec<_> = (1..=5)
3178 .map(|i| agent(&format!("p:agent-{i}"), AgentState::Running))
3179 .collect();
3180 app.replace_team(fixture_team(agents));
3181 app.dismiss_splash();
3182 app.toggle_wall_layout();
3183 app.wall_scroll_down();
3184 assert_eq!(app.wall_scroll, 4, "first scroll exposes agent 5");
3185 app.wall_scroll_down();
3186 assert_eq!(app.wall_scroll, 4, "second scroll caps; nothing past");
3187 }
3188
3189 #[test]
3190 fn esc_in_picker_dismisses_overlay_only_keeps_modal_open() {
3191 let mut app = App::new();
3197 app.replace_team(fixture_team_with_channels(
3198 vec![agent("writing:manager", AgentState::Running)],
3199 vec![
3200 channel("writing:all", "writing"),
3201 channel("writing:editorial", "writing"),
3202 ],
3203 ));
3204 app.dismiss_splash();
3205 dispatch(&mut app, key(KeyCode::Char('!')));
3206 assert!(app.compose_picker_open);
3207 assert_eq!(app.stage, Stage::ComposeModal);
3208 dispatch(&mut app, key(KeyCode::Esc));
3209 assert!(!app.compose_picker_open, "picker dismissed");
3210 assert_eq!(app.stage, Stage::ComposeModal, "compose modal stays open");
3211 }
3212
3213 #[test]
3214 fn send_routes_broadcast_through_mock_sender_via_picker() {
3215 use crate::compose::test_support::MockMessageSender;
3221 let sender = MockMessageSender::default();
3222 let mailbox = EmptyMailbox;
3223 let mut app = App::new();
3224 app.replace_team(fixture_team_with_channels(
3225 vec![agent("writing:manager", AgentState::Running)],
3226 vec![
3227 channel("writing:all", "writing"),
3228 channel("writing:editorial", "writing"),
3229 channel("writing:critique", "writing"),
3230 ],
3231 ));
3232 app.dismiss_splash();
3233 super::handle_event(
3236 &mut app,
3237 key(KeyCode::Char('!')),
3238 &NoopDecider,
3239 &sender,
3240 &mailbox,
3241 &crate::keysender::test_support::MockKeySender::default(),
3242 );
3243 super::handle_event(
3244 &mut app,
3245 key(KeyCode::Char('j')),
3246 &NoopDecider,
3247 &sender,
3248 &mailbox,
3249 &crate::keysender::test_support::MockKeySender::default(),
3250 );
3251 super::handle_event(
3252 &mut app,
3253 key(KeyCode::Enter),
3254 &NoopDecider,
3255 &sender,
3256 &mailbox,
3257 &crate::keysender::test_support::MockKeySender::default(),
3258 );
3259 for c in "ship docs".chars() {
3260 super::handle_event(
3261 &mut app,
3262 key(KeyCode::Char(c)),
3263 &NoopDecider,
3264 &sender,
3265 &mailbox,
3266 &crate::keysender::test_support::MockKeySender::default(),
3267 );
3268 }
3269 super::handle_event(
3270 &mut app,
3271 key_with(KeyCode::Enter, crossterm::event::KeyModifiers::CONTROL),
3272 &NoopDecider,
3273 &sender,
3274 &mailbox,
3275 &crate::keysender::test_support::MockKeySender::default(),
3276 );
3277 let dm_calls = sender.dm_calls.lock().unwrap().clone();
3278 let bcast_calls = sender.broadcast_calls.lock().unwrap().clone();
3279 assert!(dm_calls.is_empty(), "broadcast must not route via send_dm");
3280 assert_eq!(bcast_calls.len(), 1);
3281 assert_eq!(
3282 bcast_calls[0].0, "writing:editorial",
3283 "channel id from picker selection"
3284 );
3285 assert_eq!(bcast_calls[0].1, "ship docs");
3286 assert_eq!(app.stage, Stage::Triptych, "modal closes on send");
3287 }
3288
3289 #[test]
3290 fn bang_chord_opens_picker_when_channels_available() {
3291 let mut app = App::new();
3292 app.replace_team(fixture_team_with_channels(
3293 vec![agent("writing:manager", AgentState::Running)],
3294 vec![
3295 channel("writing:all", "writing"),
3296 channel("writing:editorial", "writing"),
3297 channel("writing:critique", "writing"),
3298 ],
3299 ));
3300 app.dismiss_splash();
3301 dispatch(&mut app, key(KeyCode::Char('!')));
3302 assert_eq!(app.stage, Stage::ComposeModal);
3303 assert!(app.compose_picker_open);
3304 dispatch(&mut app, key(KeyCode::Char('j')));
3306 assert_eq!(app.compose_picker_index, 1);
3307 dispatch(&mut app, key(KeyCode::Enter));
3309 assert!(!app.compose_picker_open, "picker closes on confirm");
3310 match app.compose_target.as_ref() {
3311 Some(crate::compose::ComposeTarget::Broadcast { channel_id, .. }) => {
3312 assert_eq!(channel_id, "writing:editorial");
3313 }
3314 other => panic!("expected Broadcast target, got {other:?}"),
3315 }
3316 }
3317
3318 #[test]
3319 fn mailbox_first_layout_seeds_channel_selection_on_entry() {
3320 let mut app = App::new();
3321 app.replace_team(fixture_team_with_channels(
3322 vec![agent("writing:manager", AgentState::Running)],
3323 vec![
3324 channel("writing:all", "writing"),
3325 channel("writing:editorial", "writing"),
3326 ],
3327 ));
3328 app.dismiss_splash();
3329 assert!(app.selected_channel.is_none());
3330 app.toggle_mailbox_first_layout();
3331 assert_eq!(app.selected_channel, Some(0));
3332 }
3333
3334 #[test]
3335 fn help_overlay_opens_on_question_mark_closes_on_esc() {
3336 let mut app = App::new();
3337 app.dismiss_splash();
3338 dispatch(&mut app, key(KeyCode::Char('?')));
3339 assert_eq!(app.stage, Stage::HelpOverlay);
3340 dispatch(&mut app, key(KeyCode::Esc));
3341 assert_eq!(app.stage, Stage::Triptych);
3342 }
3343
3344 #[test]
3345 fn tutorial_opens_on_t_advances_and_closes() {
3346 let mut app = App::new();
3347 app.dismiss_splash();
3348 dispatch(&mut app, key(KeyCode::Char('t')));
3349 assert_eq!(app.stage, Stage::Tutorial);
3350 assert_eq!(app.tutorial_step, 0);
3351 dispatch(&mut app, key(KeyCode::Char(' ')));
3353 assert_eq!(app.tutorial_step, 1);
3354 dispatch(&mut app, key(KeyCode::Char('k')));
3356 assert_eq!(app.tutorial_step, 0);
3357 dispatch(&mut app, key(KeyCode::Esc));
3359 assert_eq!(app.stage, Stage::Triptych);
3360 }
3361
3362 #[test]
3363 fn tutorial_walk_back_at_step_zero_is_no_op() {
3364 let mut app = App::new();
3369 app.dismiss_splash();
3370 app.enter_tutorial();
3371 assert_eq!(app.tutorial_step, 0);
3372 dispatch(&mut app, key(KeyCode::Char('k')));
3373 assert_eq!(app.tutorial_step, 0, "step-0 walk-back is no-op");
3374 assert_eq!(app.stage, Stage::Tutorial);
3377 }
3378
3379 #[test]
3380 fn ctrl_pipe_adds_vertical_split_ctrl_minus_adds_horizontal() {
3381 use crossterm::event::KeyModifiers;
3382 let mut app = App::new();
3383 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3384 app.dismiss_splash();
3385 dispatch(
3386 &mut app,
3387 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
3388 );
3389 dispatch(
3390 &mut app,
3391 key_with(KeyCode::Char('-'), KeyModifiers::CONTROL),
3392 );
3393 assert_eq!(app.detail_splits.len(), 2);
3394 assert_eq!(app.detail_splits[0].1, SplitOrientation::Vertical);
3395 assert_eq!(app.detail_splits[1].1, SplitOrientation::Horizontal);
3396 }
3397
3398 #[test]
3399 fn ctrl_w_q_chord_prefix_closes_focused_split() {
3400 use crossterm::event::KeyModifiers;
3401 let mut app = App::new();
3402 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3403 app.dismiss_splash();
3404 dispatch(
3407 &mut app,
3408 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
3409 );
3410 dispatch(
3411 &mut app,
3412 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
3413 );
3414 dispatch(
3415 &mut app,
3416 key_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
3417 );
3418 assert_eq!(app.pending_chord, Some(KeyCode::Char('w')));
3419 dispatch(&mut app, key(KeyCode::Char('q')));
3422 assert_eq!(app.detail_splits.len(), 1);
3423 assert_eq!(app.stage, Stage::Triptych, "must not enter quit confirm");
3424 assert_eq!(app.pending_chord, None, "chord cleared");
3425 }
3426
3427 #[test]
3428 fn ctrl_w_o_chord_keeps_only_focused_split() {
3429 use crossterm::event::KeyModifiers;
3430 let mut app = App::new();
3431 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3432 app.dismiss_splash();
3433 for _ in 0..3 {
3434 dispatch(
3435 &mut app,
3436 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
3437 );
3438 }
3439 app.selected_split = 1;
3441 let kept_id = app.detail_splits[1].0.clone();
3442 dispatch(
3443 &mut app,
3444 key_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
3445 );
3446 dispatch(&mut app, key(KeyCode::Char('o')));
3447 assert_eq!(app.detail_splits.len(), 1);
3448 assert_eq!(app.detail_splits[0].0, kept_id);
3449 assert_eq!(app.selected_split, 0);
3450 }
3451
3452 #[test]
3453 fn add_detail_split_saturates_at_four_with_explicit_4_and_5_calls() {
3454 let mut app = App::new();
3459 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3460 for _ in 0..4 {
3461 app.add_detail_split();
3462 }
3463 assert_eq!(app.detail_splits.len(), 4);
3464 let snapshot_len = app.detail_splits.len();
3465 app.add_detail_split();
3466 assert_eq!(app.detail_splits.len(), snapshot_len, "5th call rejected");
3467 }
3468
3469 #[test]
3470 fn replace_approvals_clamps_selection_in_range() {
3471 let mut app = App::new();
3472 app.replace_approvals(vec![ap(1), ap(2), ap(3)]);
3473 app.selected_approval = 2;
3474 app.replace_approvals(vec![ap(1), ap(2)]);
3476 assert_eq!(app.selected_approval, 1, "clamps to last index");
3477 }
3478
3479 #[test]
3480 fn arrow_keys_navigate_only_when_roster_focused() {
3481 let mut app = App::new();
3482 app.replace_team(fixture_team(vec![
3483 agent("p:a", AgentState::Running),
3484 agent("p:b", AgentState::Running),
3485 ]));
3486 app.dismiss_splash();
3487 app.selected_agent = Some(0);
3489 dispatch(&mut app, key(KeyCode::Down));
3490 assert_eq!(app.selected_agent, Some(1));
3491 app.cycle_focus();
3493 dispatch(&mut app, key(KeyCode::Down));
3494 assert_eq!(
3495 app.selected_agent,
3496 Some(1),
3497 "non-roster focus ignores arrows"
3498 );
3499 }
3500
3501 fn stream_keys_fixture() -> App {
3507 let mut app = App::new();
3508 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3509 app.dismiss_splash();
3510 app.cycle_focus(); assert_eq!(app.focused_pane, Pane::Detail);
3512 assert_eq!(app.selected_agent, Some(0));
3513 app
3514 }
3515
3516 fn stream_dispatch(
3517 app: &mut App,
3518 ev: Event,
3519 key_sender: &crate::keysender::test_support::MockKeySender,
3520 ) {
3521 super::handle_event(
3522 app,
3523 ev,
3524 &NoopDecider,
3525 &NoopSender,
3526 &EmptyMailbox,
3527 key_sender,
3528 );
3529 }
3530
3531 #[test]
3532 fn ctrl_e_enters_stream_keys_when_detail_focused() {
3533 use crate::keysender::test_support::MockKeySender;
3534 use crossterm::event::KeyModifiers;
3535 let mut app = stream_keys_fixture();
3536 let ks = MockKeySender::default();
3537 stream_dispatch(
3538 &mut app,
3539 key_with(KeyCode::Char('e'), KeyModifiers::CONTROL),
3540 &ks,
3541 );
3542 assert_eq!(app.stage, Stage::StreamKeys);
3543 assert!(
3544 ks.calls.lock().unwrap().is_empty(),
3545 "the activation chord itself never forwards a keystroke"
3546 );
3547 }
3548
3549 #[test]
3550 fn ctrl_e_no_op_when_detail_not_focused() {
3551 use crate::keysender::test_support::MockKeySender;
3556 use crossterm::event::KeyModifiers;
3557 let mut app = App::new();
3558 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3559 app.dismiss_splash();
3560 assert_eq!(app.focused_pane, Pane::Roster);
3561 let ks = MockKeySender::default();
3562 stream_dispatch(
3563 &mut app,
3564 key_with(KeyCode::Char('e'), KeyModifiers::CONTROL),
3565 &ks,
3566 );
3567 assert_eq!(app.stage, Stage::Triptych);
3568 }
3569
3570 #[test]
3571 fn ctrl_e_no_op_when_no_agent_selected() {
3572 use crate::keysender::test_support::MockKeySender;
3575 use crossterm::event::KeyModifiers;
3576 let mut app = App::new();
3577 app.dismiss_splash();
3578 app.cycle_focus(); assert_eq!(app.selected_agent, None);
3580 let ks = MockKeySender::default();
3581 stream_dispatch(
3582 &mut app,
3583 key_with(KeyCode::Char('e'), KeyModifiers::CONTROL),
3584 &ks,
3585 );
3586 assert_eq!(app.stage, Stage::Triptych);
3587 }
3588
3589 #[test]
3590 fn esc_exits_stream_keys() {
3591 use crate::keysender::test_support::MockKeySender;
3592 let mut app = stream_keys_fixture();
3593 app.enter_stream_keys();
3594 assert_eq!(app.stage, Stage::StreamKeys);
3595 let ks = MockKeySender::default();
3596 stream_dispatch(&mut app, key(KeyCode::Esc), &ks);
3597 assert_eq!(app.stage, Stage::Triptych);
3598 assert!(
3599 ks.calls.lock().unwrap().is_empty(),
3600 "Esc is the exit chord — it must not forward as a keystroke"
3601 );
3602 }
3603
3604 #[test]
3605 fn stream_mode_forwards_printable_chars_to_target_session() {
3606 use crate::keysender::test_support::MockKeySender;
3607 let mut app = stream_keys_fixture();
3608 app.enter_stream_keys();
3609 let ks = MockKeySender::default();
3610 for c in "hi".chars() {
3611 stream_dispatch(&mut app, key(KeyCode::Char(c)), &ks);
3612 }
3613 let calls = ks.calls.lock().unwrap();
3614 assert_eq!(calls.len(), 2, "one tmux send-keys per keystroke");
3615 assert_eq!(calls[0].0, "t-p-a");
3618 assert_eq!(calls[0].1.args, vec!["-l".to_string(), "h".to_string()]);
3619 assert_eq!(calls[1].1.args, vec!["-l".to_string(), "i".to_string()]);
3620 }
3621
3622 #[test]
3623 fn stream_mode_passes_ctrl_c_through_to_agent() {
3624 use crate::keysender::test_support::MockKeySender;
3628 use crossterm::event::KeyModifiers;
3629 let mut app = stream_keys_fixture();
3630 app.enter_stream_keys();
3631 let ks = MockKeySender::default();
3632 stream_dispatch(
3633 &mut app,
3634 key_with(KeyCode::Char('c'), KeyModifiers::CONTROL),
3635 &ks,
3636 );
3637 assert_eq!(app.stage, Stage::StreamKeys, "Ctrl+C does NOT exit");
3638 let calls = ks.calls.lock().unwrap();
3639 assert_eq!(calls.len(), 1);
3640 assert_eq!(calls[0].1.args, vec!["C-c".to_string()]);
3641 }
3642
3643 #[test]
3644 fn stream_mode_forwards_enter_and_arrows() {
3645 use crate::keysender::test_support::MockKeySender;
3646 let mut app = stream_keys_fixture();
3647 app.enter_stream_keys();
3648 let ks = MockKeySender::default();
3649 stream_dispatch(&mut app, key(KeyCode::Enter), &ks);
3650 stream_dispatch(&mut app, key(KeyCode::Up), &ks);
3651 let calls = ks.calls.lock().unwrap();
3652 assert_eq!(calls[0].1.args, vec!["Enter".to_string()]);
3653 assert_eq!(calls[1].1.args, vec!["Up".to_string()]);
3654 }
3655
3656 #[test]
3657 fn stream_target_session_uses_focused_split_when_present() {
3658 let mut app = App::new();
3663 app.replace_team(fixture_team(vec![
3664 agent("p:a", AgentState::Running),
3665 agent("p:b", AgentState::Running),
3666 ]));
3667 app.dismiss_splash();
3668 app.cycle_focus(); app.selected_agent = Some(0);
3670 app.detail_splits
3672 .push(("p:b".into(), crate::app::SplitOrientation::Vertical));
3673 app.selected_split = 1; let target = app.stream_target_session();
3675 assert_eq!(
3676 target.as_deref(),
3677 Some("t-p-b"),
3678 "selected split's agent drives the target"
3679 );
3680 }
3681
3682 #[test]
3683 fn stream_mode_drops_back_when_target_session_disappears() {
3684 use crate::keysender::test_support::MockKeySender;
3689 let mut app = stream_keys_fixture();
3690 app.enter_stream_keys();
3691 app.selected_agent = None;
3693 app.team.agents.clear();
3694 let ks = MockKeySender::default();
3695 stream_dispatch(&mut app, key(KeyCode::Char('a')), &ks);
3696 assert_eq!(app.stage, Stage::Triptych);
3697 assert!(ks.calls.lock().unwrap().is_empty());
3698 }
3699
3700 fn pane_sync_fixture() -> App {
3703 let mut app = App::new();
3704 app.team = fixture_team(vec![
3705 agent("hello:mgr", AgentState::Running),
3706 agent("hello:dev", AgentState::Running),
3707 ]);
3708 app.selected_agent = Some(0);
3709 app.stage = Stage::Triptych;
3710 app.layout = MainLayout::Triptych;
3711 app
3712 }
3713
3714 #[test]
3715 fn sync_fires_resize_on_first_frame() {
3716 let mut app = pane_sync_fixture();
3717 let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
3718 sync_focused_pane_size_to(
3719 &mut app,
3720 ratatui::layout::Rect::new(0, 0, 120, 40),
3721 &resizer,
3722 );
3723 let calls = resizer.calls.lock().unwrap();
3724 assert_eq!(calls.len(), 1);
3727 assert_eq!(calls[0].0, "t-hello-mgr");
3728 assert_eq!(calls[0].1, 92); assert_eq!(calls[0].2, 24); }
3731
3732 #[test]
3733 fn sync_skips_when_size_unchanged() {
3734 let mut app = pane_sync_fixture();
3735 let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
3736 sync_focused_pane_size_to(
3738 &mut app,
3739 ratatui::layout::Rect::new(0, 0, 120, 40),
3740 &resizer,
3741 );
3742 sync_focused_pane_size_to(
3743 &mut app,
3744 ratatui::layout::Rect::new(0, 0, 120, 40),
3745 &resizer,
3746 );
3747 assert_eq!(resizer.calls.lock().unwrap().len(), 1);
3748 }
3749
3750 #[test]
3751 fn sync_fires_again_when_terminal_resizes() {
3752 let mut app = pane_sync_fixture();
3753 let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
3754 sync_focused_pane_size_to(
3755 &mut app,
3756 ratatui::layout::Rect::new(0, 0, 120, 40),
3757 &resizer,
3758 );
3759 sync_focused_pane_size_to(
3761 &mut app,
3762 ratatui::layout::Rect::new(0, 0, 200, 60),
3763 &resizer,
3764 );
3765 let calls = resizer.calls.lock().unwrap();
3766 assert_eq!(calls.len(), 2);
3767 assert_eq!(calls[0].1, 92);
3768 assert_eq!(calls[0].2, 24);
3769 assert_eq!(calls[1].1, 172); assert_eq!(calls[1].2, 36);
3772 }
3773
3774 #[test]
3775 fn sync_fires_on_focus_switch_to_unsynced_session() {
3776 let mut app = pane_sync_fixture();
3777 let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
3778 sync_focused_pane_size_to(
3779 &mut app,
3780 ratatui::layout::Rect::new(0, 0, 120, 40),
3781 &resizer,
3782 );
3783 app.selected_agent = Some(1);
3785 sync_focused_pane_size_to(
3786 &mut app,
3787 ratatui::layout::Rect::new(0, 0, 120, 40),
3788 &resizer,
3789 );
3790 let calls = resizer.calls.lock().unwrap();
3791 assert_eq!(calls.len(), 2);
3792 assert_eq!(calls[0].0, "t-hello-mgr");
3793 assert_eq!(calls[1].0, "t-hello-dev");
3794 }
3795
3796 #[test]
3797 fn sync_is_noop_when_no_agent_focused() {
3798 let mut app = pane_sync_fixture();
3799 app.selected_agent = None;
3800 let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
3801 sync_focused_pane_size_to(
3802 &mut app,
3803 ratatui::layout::Rect::new(0, 0, 120, 40),
3804 &resizer,
3805 );
3806 assert!(resizer.calls.lock().unwrap().is_empty());
3807 }
3808
3809 #[test]
3810 fn sync_is_noop_when_layout_is_not_triptych() {
3811 let mut app = pane_sync_fixture();
3812 app.layout = MainLayout::Wall;
3813 let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
3814 sync_focused_pane_size_to(
3815 &mut app,
3816 ratatui::layout::Rect::new(0, 0, 120, 40),
3817 &resizer,
3818 );
3819 assert!(resizer.calls.lock().unwrap().is_empty());
3822 }
3823
3824 #[test]
3825 fn sync_is_noop_on_degenerate_terminal_area() {
3826 let mut app = pane_sync_fixture();
3827 let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
3828 sync_focused_pane_size_to(&mut app, ratatui::layout::Rect::new(0, 0, 28, 40), &resizer);
3830 assert!(resizer.calls.lock().unwrap().is_empty());
3831 }
3832
3833 #[test]
3834 fn sync_accounts_for_approvals_stripe_when_present() {
3835 let mut app = pane_sync_fixture();
3836 app.pending_approvals = vec![crate::approvals::Approval {
3838 id: 1,
3839 project_id: "hello".into(),
3840 agent_id: "hello:dev".into(),
3841 action: "test".into(),
3842 summary: "test approval".into(),
3843 payload_json: String::new(),
3844 }];
3845 assert!(app.has_pending_approvals());
3846 let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
3847 sync_focused_pane_size_to(
3848 &mut app,
3849 ratatui::layout::Rect::new(0, 0, 120, 40),
3850 &resizer,
3851 );
3852 let calls = resizer.calls.lock().unwrap();
3853 assert_eq!(calls.len(), 1);
3855 assert_eq!(calls[0].2, 23);
3856 }
3857
3858 fn app_with_mailbox_focused() -> App {
3864 let mut app = App::new();
3865 app.dismiss_splash();
3866 app.cycle_focus();
3868 app.cycle_focus();
3869 assert_eq!(app.focused_pane, Pane::Mailbox);
3870 app
3871 }
3872
3873 #[test]
3874 fn f_opens_filter_input_when_mailbox_focused() {
3875 let mut app = app_with_mailbox_focused();
3876 assert!(app.mailbox_input_mode.is_none());
3877 dispatch(&mut app, key(KeyCode::Char('f')));
3878 assert_eq!(app.mailbox_input_mode, Some(MailboxInputKind::Filter));
3879 }
3880
3881 #[test]
3882 fn slash_opens_search_input_when_mailbox_focused() {
3883 let mut app = app_with_mailbox_focused();
3884 dispatch(&mut app, key(KeyCode::Char('/')));
3885 assert_eq!(app.mailbox_input_mode, Some(MailboxInputKind::Search));
3886 }
3887
3888 #[test]
3889 fn f_does_not_open_filter_when_roster_focused() {
3890 let mut app = App::new();
3894 app.dismiss_splash();
3895 assert_eq!(app.focused_pane, Pane::Roster);
3896 dispatch(&mut app, key(KeyCode::Char('f')));
3897 assert!(app.mailbox_input_mode.is_none());
3898 }
3899
3900 #[test]
3901 fn typing_into_filter_input_mutates_active_tab_buffer() {
3902 let mut app = app_with_mailbox_focused();
3903 dispatch(&mut app, key(KeyCode::Char('f')));
3904 dispatch(&mut app, key(KeyCode::Char('a')));
3905 dispatch(&mut app, key(KeyCode::Char('d')));
3906 dispatch(&mut app, key(KeyCode::Char('a')));
3907 assert_eq!(app.mailbox.filter_text(app.mailbox_tab), "ada");
3908 assert_eq!(app.mailbox.filter_text(MailboxTab::Sent), "");
3910 }
3911
3912 #[test]
3913 fn backspace_pops_input_buffer() {
3914 let mut app = app_with_mailbox_focused();
3915 dispatch(&mut app, key(KeyCode::Char('/')));
3916 for c in "abc".chars() {
3917 dispatch(&mut app, key(KeyCode::Char(c)));
3918 }
3919 assert_eq!(app.mailbox.search_text(app.mailbox_tab), "abc");
3920 dispatch(&mut app, key(KeyCode::Backspace));
3921 assert_eq!(app.mailbox.search_text(app.mailbox_tab), "ab");
3922 }
3923
3924 #[test]
3925 fn enter_confirms_keeps_typed_text() {
3926 let mut app = app_with_mailbox_focused();
3927 dispatch(&mut app, key(KeyCode::Char('f')));
3928 for c in "kian".chars() {
3929 dispatch(&mut app, key(KeyCode::Char(c)));
3930 }
3931 dispatch(&mut app, key(KeyCode::Enter));
3932 assert!(
3933 app.mailbox_input_mode.is_none(),
3934 "input must close on Enter"
3935 );
3936 assert_eq!(
3937 app.mailbox.filter_text(app.mailbox_tab),
3938 "kian",
3939 "Enter must keep the typed text (confirm-keep semantics)"
3940 );
3941 }
3942
3943 #[test]
3944 fn esc_cancels_reverts_to_snapshot() {
3945 let mut app = app_with_mailbox_focused();
3946 app.mailbox
3948 .set_input(app.mailbox_tab, MailboxInputKind::Filter, "previous".into());
3949 dispatch(&mut app, key(KeyCode::Char('f')));
3950 dispatch(&mut app, key(KeyCode::Backspace));
3952 dispatch(&mut app, key(KeyCode::Backspace));
3953 dispatch(&mut app, key(KeyCode::Char('x')));
3954 assert_eq!(app.mailbox.filter_text(app.mailbox_tab), "previox");
3955 dispatch(&mut app, key(KeyCode::Esc));
3957 assert!(app.mailbox_input_mode.is_none());
3958 assert_eq!(
3959 app.mailbox.filter_text(app.mailbox_tab),
3960 "previous",
3961 "Esc must revert the active buffer to the pre-open snapshot"
3962 );
3963 }
3964
3965 #[test]
3966 fn open_input_swallows_pr1_cursor_keys() {
3967 let mut app = app_with_mailbox_focused();
3971 app.mailbox.extend(
3973 app.mailbox_tab,
3974 (1..=10)
3975 .map(|i| crate::mailbox::MessageRow {
3976 id: i,
3977 sender: "p:a".into(),
3978 recipient: "p:dev".into(),
3979 text: "x".into(),
3980 sent_at: 0.0,
3981 })
3982 .collect(),
3983 );
3984 let seated = app.mailbox.cursor(app.mailbox_tab).selected_idx;
3985 assert_eq!(seated, 9, "extend seats cursor at tail (PR-1 contract)");
3986 dispatch(&mut app, key(KeyCode::Char('f')));
3988 dispatch(&mut app, key(KeyCode::Up));
3989 dispatch(&mut app, key(KeyCode::PageUp));
3990 dispatch(&mut app, key(KeyCode::Home));
3991 assert_eq!(app.mailbox.cursor(app.mailbox_tab).selected_idx, 9);
3996 }
3997
3998 #[test]
3999 fn ctrl_modifier_char_does_not_inject_into_input() {
4000 let mut app = app_with_mailbox_focused();
4006 dispatch(&mut app, key(KeyCode::Char('f'))); dispatch(
4008 &mut app,
4009 key_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
4010 );
4011 dispatch(
4012 &mut app,
4013 key_with(KeyCode::Char('c'), KeyModifiers::CONTROL),
4014 );
4015 dispatch(&mut app, key_with(KeyCode::Char('a'), KeyModifiers::ALT));
4016 assert_eq!(
4017 app.mailbox.filter_text(app.mailbox_tab),
4018 "",
4019 "modifier+Char combos must not leak into the filter buffer"
4020 );
4021 dispatch(&mut app, key(KeyCode::Char('w')));
4024 assert_eq!(app.mailbox.filter_text(app.mailbox_tab), "w");
4025 dispatch(&mut app, key_with(KeyCode::Char('X'), KeyModifiers::SHIFT));
4028 assert_eq!(app.mailbox.filter_text(app.mailbox_tab), "wX");
4029 }
4030
4031 #[test]
4032 fn open_input_swallows_q_quit() {
4033 let mut app = app_with_mailbox_focused();
4038 dispatch(&mut app, key(KeyCode::Char('f')));
4039 dispatch(&mut app, key(KeyCode::Char('q')));
4040 assert_eq!(
4041 app.stage,
4042 Stage::Triptych,
4043 "q must NOT trigger quit while input is open"
4044 );
4045 assert_eq!(
4046 app.mailbox.filter_text(app.mailbox_tab),
4047 "q",
4048 "q must land in the filter buffer"
4049 );
4050 }
4051
4052 fn seed_inbox_rows(app: &mut App, n: i64) {
4056 let rows: Vec<MessageRow> = (1..=n)
4057 .map(|i| MessageRow {
4058 id: i,
4059 sender: "p:dev".into(),
4060 recipient: "p:mgr".into(),
4061 text: format!("body #{i}"),
4062 sent_at: 1_700_000_000.0 + i as f64,
4063 })
4064 .collect();
4065 app.mailbox.extend(MailboxTab::Inbox, rows);
4066 }
4067
4068 #[test]
4069 fn enter_on_mailbox_opens_detail_modal_with_snapshot() {
4070 let mut app = app_with_mailbox_focused();
4071 seed_inbox_rows(&mut app, 5);
4072 dispatch(&mut app, key(KeyCode::Enter));
4074 assert_eq!(app.stage, Stage::MailboxDetailModal);
4075 let snap = app.mailbox_detail_modal.as_ref().expect("modal open");
4076 assert_eq!(snap.id, 5);
4077 assert_eq!(snap.text, "body #5");
4078 assert_eq!(app.mailbox_detail_scroll, 0, "scroll resets on open");
4079 }
4080
4081 #[test]
4082 fn enter_on_empty_visible_indices_is_noop() {
4083 let mut app = app_with_mailbox_focused();
4085 seed_inbox_rows(&mut app, 3);
4086 app.mailbox.set_input(
4087 MailboxTab::Inbox,
4088 MailboxInputKind::Filter,
4089 "no-such-sender".into(),
4090 );
4091 assert!(app.mailbox.visible_indices(MailboxTab::Inbox).is_empty());
4092 dispatch(&mut app, key(KeyCode::Enter));
4093 assert_eq!(app.stage, Stage::Triptych);
4094 assert!(app.mailbox_detail_modal.is_none());
4095 }
4096
4097 #[test]
4098 fn snapshot_stable_across_underlying_drain() {
4099 let mut app = app_with_mailbox_focused();
4103 seed_inbox_rows(&mut app, 5);
4104 app.mailbox.cursor_home(MailboxTab::Inbox);
4105 app.mailbox.move_cursor_down(MailboxTab::Inbox);
4106 app.mailbox.move_cursor_down(MailboxTab::Inbox); dispatch(&mut app, key(KeyCode::Enter));
4108 let snap_id = app.mailbox_detail_modal.as_ref().expect("open").id;
4109 assert_eq!(snap_id, 3);
4110 let more: Vec<MessageRow> = (6..=600)
4113 .map(|i| MessageRow {
4114 id: i,
4115 sender: "p:dev".into(),
4116 recipient: "p:mgr".into(),
4117 text: format!("body #{i}"),
4118 sent_at: 1_700_000_000.0 + i as f64,
4119 })
4120 .collect();
4121 app.mailbox.extend(MailboxTab::Inbox, more);
4122 let still_there = app
4124 .mailbox
4125 .rows(MailboxTab::Inbox)
4126 .iter()
4127 .any(|r| r.id == 3);
4128 assert!(!still_there, "row id 3 must have been drained");
4129 let snap = app.mailbox_detail_modal.as_ref().expect("still open");
4132 assert_eq!(snap.id, 3, "snapshot id must survive underlying drain");
4133 assert_eq!(snap.text, "body #3");
4134 }
4135
4136 #[test]
4137 fn esc_closes_detail_modal() {
4138 let mut app = app_with_mailbox_focused();
4139 seed_inbox_rows(&mut app, 3);
4140 dispatch(&mut app, key(KeyCode::Enter));
4141 assert_eq!(app.stage, Stage::MailboxDetailModal);
4142 dispatch(&mut app, key(KeyCode::Esc));
4143 assert_eq!(app.stage, Stage::Triptych);
4144 assert!(app.mailbox_detail_modal.is_none());
4145 }
4146
4147 #[test]
4148 fn q_closes_detail_modal() {
4149 let mut app = app_with_mailbox_focused();
4150 seed_inbox_rows(&mut app, 3);
4151 dispatch(&mut app, key(KeyCode::Enter));
4152 dispatch(&mut app, key(KeyCode::Char('q')));
4153 assert_eq!(app.stage, Stage::Triptych);
4154 assert!(app.mailbox_detail_modal.is_none());
4155 }
4156
4157 #[test]
4158 fn j_and_k_scroll_body_in_modal() {
4159 let mut app = app_with_mailbox_focused();
4160 seed_inbox_rows(&mut app, 3);
4161 dispatch(&mut app, key(KeyCode::Enter));
4162 assert_eq!(app.mailbox_detail_scroll, 0);
4163 dispatch(&mut app, key(KeyCode::Char('j')));
4164 dispatch(&mut app, key(KeyCode::Char('j')));
4165 dispatch(&mut app, key(KeyCode::Down));
4166 assert_eq!(app.mailbox_detail_scroll, 3);
4167 dispatch(&mut app, key(KeyCode::Char('k')));
4168 dispatch(&mut app, key(KeyCode::Up));
4169 assert_eq!(app.mailbox_detail_scroll, 1);
4170 for _ in 0..10 {
4172 dispatch(&mut app, key(KeyCode::Char('k')));
4173 }
4174 assert_eq!(app.mailbox_detail_scroll, 0);
4175 }
4176
4177 #[test]
4178 fn unrelated_keys_swallowed_in_modal() {
4179 let mut app = app_with_mailbox_focused();
4182 seed_inbox_rows(&mut app, 3);
4183 dispatch(&mut app, key(KeyCode::Enter));
4184 assert_eq!(app.stage, Stage::MailboxDetailModal);
4185 let focused_before = app.focused_pane;
4186 dispatch(&mut app, key(KeyCode::Char('f')));
4187 dispatch(&mut app, key(KeyCode::Char('/')));
4188 dispatch(&mut app, key(KeyCode::Tab));
4189 assert_eq!(app.stage, Stage::MailboxDetailModal, "stage stays");
4190 assert!(app.mailbox_input_mode.is_none(), "filter/search not opened");
4191 assert_eq!(
4192 app.focused_pane, focused_before,
4193 "Tab must not cycle panes underneath an open modal"
4194 );
4195 }
4196}