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, AsyncKeySender, 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(33);
52const REFRESH_INTERVAL: Duration = Duration::from_secs(1);
55const PANE_REFRESH_INTERVAL: Duration = Duration::from_millis(100);
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum Stage {
63 Splash,
64 Triptych,
65 QuitConfirm,
66 ApprovalsModal,
71 ComposeModal,
76 HelpOverlay,
79 Tutorial,
84 StreamKeys,
92 MailboxDetailModal,
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum SplitOrientation {
110 Vertical,
111 Horizontal,
112}
113
114pub struct App {
115 pub stage: Stage,
116 pub previous_stage: Stage,
118 pub focused_pane: Pane,
119 pub team: TeamSnapshot,
120 pub selected_agent: Option<usize>,
124 pub detail_buffer: Vec<String>,
128 pub version: &'static str,
129 pub capabilities: Capabilities,
130 pub splash_started: Instant,
131 pub last_refresh: Instant,
134 pub last_pane_refresh: Instant,
137 pub running: bool,
138 pub tutorial_completed: bool,
142 pub mailbox_tab: MailboxTab,
150 pub mailbox: MailboxBuffers,
154 pub mailbox_input_mode: Option<MailboxInputKind>,
161 pub mailbox_input_snapshot: String,
165 pub mailbox_detail_modal: Option<MessageRow>,
174 pub mailbox_detail_scroll: u16,
179 pub now_secs: f64,
189 pub pending_approvals: Vec<Approval>,
192 pub selected_approval: usize,
196 pub approval_error: Option<String>,
200 pub compose_target: Option<ComposeTarget>,
204 pub compose_editor: Editor,
208 pub compose_error: Option<String>,
212 pub layout: MainLayout,
215 pub wall_scroll: usize,
219 pub selected_channel: Option<usize>,
223 pub detail_splits: Vec<(String, SplitOrientation)>,
229 pub selected_split: usize,
230 pub pending_chord: Option<KeyCode>,
236 pub tutorial_pending_for_team: bool,
240 pub spinner_frame: usize,
243 pub tutorial_step: usize,
246 pub compose_picker_open: bool,
251 pub compose_picker_index: usize,
253 pub compose_attach_input_open: bool,
260 pub compose_attach_buffer: String,
263 pub last_synced_pane_sizes: std::collections::HashMap<String, (u16, u16)>,
271 pub detail_buffer_activity: Option<(String, u64)>,
281 pub sysinfo: sysinfo::System,
289 pub rate_limit_indicator_enabled: bool,
295}
296
297const MAX_DETAIL_LINES: usize = 2000;
298
299impl App {
300 pub fn new() -> Self {
306 Self {
307 stage: Stage::Splash,
308 previous_stage: Stage::Splash,
309 focused_pane: Pane::Roster,
310 team: TeamSnapshot::empty(std::path::PathBuf::new()),
311 selected_agent: None,
312 detail_buffer: Vec::new(),
313 version: env!("CARGO_PKG_VERSION"),
314 capabilities: detect_capabilities(),
315 splash_started: Instant::now(),
316 last_refresh: Instant::now() - REFRESH_INTERVAL,
317 last_pane_refresh: Instant::now(),
318 running: true,
319 tutorial_completed: tutorial::is_completed(),
320 mailbox_tab: MailboxTab::Inbox,
321 mailbox: MailboxBuffers::default(),
322 mailbox_input_mode: None,
323 mailbox_input_snapshot: String::new(),
324 mailbox_detail_modal: None,
325 mailbox_detail_scroll: 0,
326 now_secs: 0.0,
327 pending_approvals: Vec::new(),
328 selected_approval: 0,
329 approval_error: None,
330 compose_target: None,
331 compose_editor: Editor::default(),
332 compose_error: None,
333 layout: MainLayout::Triptych,
334 wall_scroll: 0,
335 selected_channel: None,
336 detail_splits: Vec::new(),
337 selected_split: 0,
338 compose_picker_open: false,
339 compose_picker_index: 0,
340 compose_attach_input_open: false,
341 compose_attach_buffer: String::new(),
342 pending_chord: None,
343 tutorial_pending_for_team: false,
344 spinner_frame: 0,
345 tutorial_step: 0,
346 last_synced_pane_sizes: std::collections::HashMap::new(),
347 detail_buffer_activity: None,
348 sysinfo: sysinfo::System::new(),
354 rate_limit_indicator_enabled: std::env::var_os("TEAMCTL_UI_RATE_LIMIT_INDICATOR")
360 .is_none_or(|v| v != "0"),
361 }
362 }
363
364 pub fn enter_help_overlay(&mut self) {
367 self.previous_stage = self.stage;
368 self.stage = Stage::HelpOverlay;
369 }
370 pub fn close_help_overlay(&mut self) {
371 self.stage = self.previous_stage;
372 }
373 pub fn enter_tutorial(&mut self) {
374 self.previous_stage = self.stage;
375 self.stage = Stage::Tutorial;
376 self.tutorial_step = 0;
377 }
378 pub fn close_tutorial(&mut self) {
379 self.stage = self.previous_stage;
380 self.tutorial_pending_for_team = false;
381 if !self.team.root.as_os_str().is_empty() {
382 let _ = crate::onboarding::mark_completed(&self.team.root);
383 }
384 }
385 pub fn tutorial_advance(&mut self) {
386 let len = crate::onboarding::STEPS.len();
387 if len == 0 {
388 self.close_tutorial();
389 return;
390 }
391 if self.tutorial_step + 1 >= len {
392 self.close_tutorial();
393 } else {
394 self.tutorial_step += 1;
395 }
396 }
397 pub fn tutorial_back(&mut self) {
398 self.tutorial_step = self.tutorial_step.saturating_sub(1);
399 }
400
401 pub fn toggle_wall_layout(&mut self) {
402 self.layout = self.layout.toggle_wall();
403 }
404 pub fn toggle_mailbox_first_layout(&mut self) {
405 self.layout = self.layout.toggle_mailbox_first();
406 if matches!(self.layout, MainLayout::MailboxFirst) && self.selected_channel.is_none() {
409 self.selected_channel = if self.team.channels.is_empty() {
410 None
411 } else {
412 Some(0)
413 };
414 }
415 }
416 pub fn wall_scroll_up(&mut self) {
417 self.wall_scroll = self
418 .wall_scroll
419 .saturating_sub(crate::layouts::WALL_TILE_CAP);
420 }
421 pub fn wall_scroll_down(&mut self) {
422 let next = self.wall_scroll + crate::layouts::WALL_TILE_CAP;
423 if next < self.team.agents.len() {
424 self.wall_scroll = next;
425 }
426 }
427 pub fn select_next_channel(&mut self) {
428 if self.team.channels.is_empty() {
429 return;
430 }
431 self.selected_channel = Some(match self.selected_channel {
432 None => 0,
433 Some(i) => (i + 1) % self.team.channels.len(),
434 });
435 }
436 pub fn select_prev_channel(&mut self) {
437 if self.team.channels.is_empty() {
438 return;
439 }
440 self.selected_channel = Some(match self.selected_channel {
441 None | Some(0) => self.team.channels.len() - 1,
442 Some(i) => i - 1,
443 });
444 }
445
446 pub fn add_detail_split_vertical(&mut self) {
450 self.add_detail_split_with_orientation(SplitOrientation::Vertical);
451 }
452 pub fn add_detail_split_horizontal(&mut self) {
454 self.add_detail_split_with_orientation(SplitOrientation::Horizontal);
455 }
456 fn add_detail_split_with_orientation(&mut self, orientation: SplitOrientation) {
457 let Some(id) = self.selected_agent_id() else {
458 return;
459 };
460 if self.detail_splits.len() >= 4 {
461 return;
462 }
463 self.detail_splits.push((id, orientation));
464 self.selected_split = self.detail_splits.len() - 1;
465 }
466 pub fn add_detail_split(&mut self) {
471 self.add_detail_split_vertical();
472 }
473 pub fn close_focused_split(&mut self) {
474 if self.detail_splits.is_empty() {
475 return;
476 }
477 let i = self.selected_split.min(self.detail_splits.len() - 1);
478 self.detail_splits.remove(i);
479 self.selected_split = i.saturating_sub(1);
480 }
481 pub fn cycle_split_next(&mut self) {
482 if self.detail_splits.is_empty() {
483 return;
484 }
485 self.selected_split = (self.selected_split + 1) % self.detail_splits.len();
486 }
487 pub fn cycle_split_prev(&mut self) {
488 if self.detail_splits.is_empty() {
489 return;
490 }
491 self.selected_split = if self.selected_split == 0 {
492 self.detail_splits.len() - 1
493 } else {
494 self.selected_split - 1
495 };
496 }
497
498 pub fn enter_compose_broadcast_with_picker(&mut self) {
503 if self.team.channels.is_empty() {
504 self.enter_compose_broadcast();
508 return;
509 }
510 let project_id = self
511 .team
512 .channels
513 .first()
514 .map(|c| c.project_id.clone())
515 .unwrap_or_default();
516 self.previous_stage = self.stage;
517 self.stage = Stage::ComposeModal;
518 self.compose_target = Some(ComposeTarget::Broadcast {
519 channel_id: format!("{project_id}:all"),
520 project_id,
521 });
522 self.compose_editor = Editor::default();
523 self.compose_error = None;
524 self.compose_picker_open = true;
525 self.compose_picker_index = 0;
526 }
527 pub fn picker_next(&mut self) {
528 if self.team.channels.is_empty() {
529 return;
530 }
531 self.compose_picker_index = (self.compose_picker_index + 1) % self.team.channels.len();
532 }
533 pub fn picker_prev(&mut self) {
534 if self.team.channels.is_empty() {
535 return;
536 }
537 self.compose_picker_index = if self.compose_picker_index == 0 {
538 self.team.channels.len() - 1
539 } else {
540 self.compose_picker_index - 1
541 };
542 }
543 pub fn picker_confirm(&mut self) {
544 if let Some(ch) = self.team.channels.get(self.compose_picker_index) {
545 self.compose_target = Some(ComposeTarget::Broadcast {
546 channel_id: ch.id.clone(),
547 project_id: ch.project_id.clone(),
548 });
549 }
550 self.compose_picker_open = false;
551 }
552
553 pub fn open_compose_attach_input(&mut self) {
556 self.compose_attach_input_open = true;
557 self.compose_attach_buffer.clear();
558 }
559
560 pub fn confirm_compose_attach_input(&mut self) {
566 let path = self.compose_attach_buffer.trim().to_string();
567 if !path.is_empty() {
568 let marker = format!("📎 attachment: {path}");
569 if let Some(last) = self.compose_editor.lines.last_mut() {
574 if !last.is_empty() {
575 self.compose_editor.lines.push(marker);
576 } else {
577 *last = marker;
578 }
579 } else {
580 self.compose_editor.lines.push(marker);
581 }
582 self.compose_editor.cursor_row = self.compose_editor.lines.len() - 1;
585 self.compose_editor.cursor_col = self
586 .compose_editor
587 .lines
588 .last()
589 .map(|l| l.len())
590 .unwrap_or(0);
591 }
592 self.close_compose_attach_input();
593 }
594
595 pub fn close_compose_attach_input(&mut self) {
596 self.compose_attach_input_open = false;
597 self.compose_attach_buffer.clear();
598 }
599
600 pub fn cycle_mailbox_tab(&mut self) {
601 self.mailbox_tab = self.mailbox_tab.next();
602 }
603
604 pub fn cycle_mailbox_tab_back(&mut self) {
605 self.mailbox_tab = self.mailbox_tab.prev();
606 }
607
608 pub fn mailbox_cursor_down(&mut self) {
613 self.mailbox.move_cursor_down(self.mailbox_tab);
614 }
615
616 pub fn mailbox_cursor_up(&mut self) {
617 self.mailbox.move_cursor_up(self.mailbox_tab);
618 }
619
620 pub fn mailbox_page_down(&mut self) {
621 self.mailbox.page_cursor_down(self.mailbox_tab);
622 }
623
624 pub fn mailbox_page_up(&mut self) {
625 self.mailbox.page_cursor_up(self.mailbox_tab);
626 }
627
628 pub fn mailbox_cursor_home(&mut self) {
629 self.mailbox.cursor_home(self.mailbox_tab);
630 }
631
632 pub fn mailbox_cursor_end(&mut self) {
633 self.mailbox.cursor_end(self.mailbox_tab);
634 }
635
636 pub fn open_mailbox_filter_input(&mut self) {
643 self.mailbox_input_snapshot = self.mailbox.filter_text(self.mailbox_tab).to_string();
644 self.mailbox_input_mode = Some(MailboxInputKind::Filter);
645 }
646
647 pub fn open_mailbox_search_input(&mut self) {
650 self.mailbox_input_snapshot = self.mailbox.search_text(self.mailbox_tab).to_string();
651 self.mailbox_input_mode = Some(MailboxInputKind::Search);
652 }
653
654 pub fn mailbox_input_push_char(&mut self, c: char) {
657 if let Some(kind) = self.mailbox_input_mode {
658 self.mailbox.input_push_char(self.mailbox_tab, kind, c);
659 }
660 }
661
662 pub fn mailbox_input_pop_char(&mut self) {
664 if let Some(kind) = self.mailbox_input_mode {
665 self.mailbox.input_pop_char(self.mailbox_tab, kind);
666 }
667 }
668
669 pub fn mailbox_input_confirm(&mut self) {
671 self.mailbox_input_mode = None;
672 self.mailbox_input_snapshot.clear();
673 }
674
675 pub fn mailbox_input_cancel(&mut self) {
679 if let Some(kind) = self.mailbox_input_mode {
680 let snapshot = std::mem::take(&mut self.mailbox_input_snapshot);
681 self.mailbox.set_input(self.mailbox_tab, kind, snapshot);
682 }
683 self.mailbox_input_mode = None;
684 self.mailbox_input_snapshot.clear();
685 }
686
687 pub fn open_mailbox_detail_modal(&mut self) {
697 let tab = self.mailbox_tab;
698 let visible = self.mailbox.visible_indices(tab);
699 if visible.is_empty() {
700 return;
701 }
702 let idx = self.mailbox.cursor(tab).selected_idx.min(visible.len() - 1);
703 let row_idx = visible[idx];
704 let row = self.mailbox.rows(tab).get(row_idx).cloned();
705 if let Some(row) = row {
706 self.mailbox_detail_modal = Some(row);
707 self.mailbox_detail_scroll = 0;
708 self.stage = Stage::MailboxDetailModal;
709 }
710 }
711
712 pub fn close_mailbox_detail_modal(&mut self) {
715 self.mailbox_detail_modal = None;
716 self.mailbox_detail_scroll = 0;
717 self.stage = Stage::Triptych;
718 }
719
720 pub fn mailbox_detail_scroll_down(&mut self) {
724 self.mailbox_detail_scroll = self.mailbox_detail_scroll.saturating_add(1);
729 }
730
731 pub fn mailbox_detail_scroll_up(&mut self) {
733 self.mailbox_detail_scroll = self.mailbox_detail_scroll.saturating_sub(1);
734 }
735
736 pub fn cycle_focus_back(&mut self) {
737 self.focused_pane = self.focused_pane.prev();
738 }
739
740 pub fn has_pending_approvals(&self) -> bool {
741 !self.pending_approvals.is_empty()
742 }
743
744 pub fn enter_approvals_modal(&mut self) {
745 if self.pending_approvals.is_empty() {
746 return;
747 }
748 self.previous_stage = self.stage;
749 self.stage = Stage::ApprovalsModal;
750 self.selected_approval = 0;
751 self.approval_error = None;
752 }
753
754 pub fn close_approvals_modal(&mut self) {
755 self.stage = self.previous_stage;
756 self.approval_error = None;
757 }
758
759 pub fn cycle_approval_next(&mut self) {
760 if self.pending_approvals.is_empty() {
761 return;
762 }
763 self.selected_approval = (self.selected_approval + 1) % self.pending_approvals.len();
764 }
765
766 pub fn cycle_approval_prev(&mut self) {
767 if self.pending_approvals.is_empty() {
768 return;
769 }
770 self.selected_approval = if self.selected_approval == 0 {
771 self.pending_approvals.len() - 1
772 } else {
773 self.selected_approval - 1
774 };
775 }
776
777 pub fn focused_approval(&self) -> Option<&Approval> {
778 self.pending_approvals.get(self.selected_approval)
779 }
780
781 pub fn replace_approvals(&mut self, approvals: Vec<Approval>) {
787 self.pending_approvals = approvals;
788 if self.pending_approvals.is_empty() {
789 if matches!(self.stage, Stage::ApprovalsModal) {
790 self.close_approvals_modal();
791 }
792 self.selected_approval = 0;
793 } else if self.selected_approval >= self.pending_approvals.len() {
794 self.selected_approval = self.pending_approvals.len() - 1;
795 }
796 }
797
798 pub fn apply_decision<D: ApprovalDecider>(&mut self, decider: &D, kind: Decision, note: &str) {
805 let Some(approval) = self.focused_approval().cloned() else {
806 return;
807 };
808 match decider.decide(&self.team.root, approval.id, kind, note) {
809 Ok(()) => {
810 self.pending_approvals.retain(|a| a.id != approval.id);
811 self.approval_error = None;
812 if self.pending_approvals.is_empty() {
813 self.close_approvals_modal();
814 } else if self.selected_approval >= self.pending_approvals.len() {
815 self.selected_approval = self.pending_approvals.len() - 1;
816 }
817 }
818 Err(err) => {
819 self.approval_error = Some(err.to_string());
820 }
821 }
822 }
823
824 pub fn enter_compose_dm_for_focused(&mut self) {
827 let Some(info) = self
828 .selected_agent
829 .and_then(|i| self.team.agents.get(i))
830 .cloned()
831 else {
832 return;
833 };
834 self.previous_stage = self.stage;
835 self.stage = Stage::ComposeModal;
836 self.compose_target = Some(ComposeTarget::Dm {
837 agent_id: info.id.clone(),
838 project_id: info.project.clone(),
839 });
840 self.compose_editor = Editor::default();
841 self.compose_error = None;
842 }
843
844 pub fn enter_compose_broadcast(&mut self) {
852 let project_id = self
853 .selected_agent
854 .and_then(|i| self.team.agents.get(i))
855 .map(|a| a.project.clone())
856 .or_else(|| self.team.agents.first().map(|a| a.project.clone()));
857 let Some(project_id) = project_id else {
858 return;
859 };
860 let channel_id = format!("{project_id}:all");
861 self.previous_stage = self.stage;
862 self.stage = Stage::ComposeModal;
863 self.compose_target = Some(ComposeTarget::Broadcast {
864 channel_id,
865 project_id,
866 });
867 self.compose_editor = Editor::default();
868 self.compose_error = None;
869 }
870
871 pub fn close_compose_modal(&mut self) {
872 self.stage = self.previous_stage;
873 self.compose_target = None;
874 self.compose_editor = Editor::default();
875 self.compose_error = None;
876 self.compose_attach_input_open = false;
879 self.compose_attach_buffer.clear();
880 }
881
882 pub fn apply_send<S: MessageSender, M: MailboxSource>(
888 &mut self,
889 sender: &S,
890 mailbox_source: &M,
891 ) {
892 let Some(target) = self.compose_target.clone() else {
893 return;
894 };
895 let body = self.compose_editor.body();
896 if body.is_empty() {
897 self.compose_error = Some("body is empty".into());
898 return;
899 }
900 let result = match &target {
901 ComposeTarget::Dm { agent_id, .. } => sender.send_dm(&self.team.root, agent_id, &body),
902 ComposeTarget::Broadcast { channel_id, .. } => {
903 sender.broadcast(&self.team.root, channel_id, &body)
904 }
905 };
906 match result {
907 Ok(()) => {
908 self.close_compose_modal();
909 refresh_mailbox(self, mailbox_source);
912 }
913 Err(err) => {
914 self.compose_error = Some(err.to_string());
915 }
916 }
917 }
918
919 pub fn dismiss_splash(&mut self) {
920 if matches!(self.stage, Stage::Splash) {
921 self.stage = Stage::Triptych;
922 self.previous_stage = Stage::Triptych;
923 }
924 }
925
926 pub fn cycle_focus(&mut self) {
927 self.focused_pane = self.focused_pane.next();
928 }
929
930 pub fn select_prev(&mut self) {
936 if self.team.agents.is_empty() {
937 self.selected_agent = None;
938 return;
939 }
940 let prior = self.selected_agent_id();
941 self.selected_agent = Some(match self.selected_agent {
942 None | Some(0) => self.team.agents.len() - 1,
943 Some(i) => i - 1,
944 });
945 if prior != self.selected_agent_id() {
946 self.mailbox.reset();
947 }
948 }
949
950 pub fn select_next(&mut self) {
953 if self.team.agents.is_empty() {
954 self.selected_agent = None;
955 return;
956 }
957 let prior = self.selected_agent_id();
958 self.selected_agent = Some(match self.selected_agent {
959 None => 0,
960 Some(i) => (i + 1) % self.team.agents.len(),
961 });
962 if prior != self.selected_agent_id() {
963 self.mailbox.reset();
964 }
965 }
966
967 pub fn selected_agent_id(&self) -> Option<String> {
969 self.selected_agent
970 .and_then(|i| self.team.agents.get(i))
971 .map(|a| a.id.clone())
972 }
973
974 pub fn enter_quit_confirm(&mut self) {
975 self.previous_stage = self.stage;
976 self.stage = Stage::QuitConfirm;
977 }
978
979 pub fn cancel_quit(&mut self) {
980 self.stage = self.previous_stage;
981 }
982
983 pub fn confirm_quit(&mut self) {
984 self.running = false;
985 }
986
987 pub fn replace_team(&mut self, team: TeamSnapshot) {
994 let prior_id = self.selected_agent_id();
995 self.team = team;
996 self.selected_agent = match (prior_id.clone(), self.team.agents.is_empty()) {
997 (_, true) => None,
998 (Some(id), false) => self.team.agents.iter().position(|a| a.id == id).or(Some(0)),
999 (None, false) => Some(0),
1000 };
1001 if prior_id != self.selected_agent_id() {
1002 self.mailbox.reset();
1003 }
1004 }
1005
1006 pub fn focused_session(&self) -> Option<&str> {
1009 self.selected_agent
1010 .and_then(|i| self.team.agents.get(i))
1011 .map(|a| a.tmux_session.as_str())
1012 }
1013
1014 pub fn stream_target_session(&self) -> Option<String> {
1021 if self.detail_splits.is_empty() || self.selected_split == 0 {
1022 return self.focused_session().map(|s| s.to_string());
1023 }
1024 let split_idx = self.selected_split - 1;
1025 let agent_id = self.detail_splits.get(split_idx).map(|(id, _)| id)?;
1026 self.team
1027 .agents
1028 .iter()
1029 .find(|a| &a.id == agent_id)
1030 .map(|a| a.tmux_session.clone())
1031 }
1032
1033 pub fn enter_stream_keys(&mut self) {
1038 if self.stream_target_session().is_none() {
1039 return;
1040 }
1041 self.previous_stage = self.stage;
1042 self.stage = Stage::StreamKeys;
1043 }
1044
1045 pub fn exit_stream_keys(&mut self) {
1049 self.stage = self.previous_stage;
1050 }
1051
1052 pub fn set_detail_buffer(&mut self, lines: Vec<String>) {
1054 let len = lines.len();
1055 let start = len.saturating_sub(MAX_DETAIL_LINES);
1056 self.detail_buffer = lines[start..].to_vec();
1057 }
1058}
1059
1060impl Default for App {
1061 fn default() -> Self {
1062 Self::new()
1063 }
1064}
1065
1066pub fn refresh<P: PaneSource, M: MailboxSource, A: ApprovalSource>(
1071 app: &mut App,
1072 pane_source: &P,
1073 mailbox_source: &M,
1074 approval_source: &A,
1075) {
1076 if let Ok(Some(snapshot)) = TeamSnapshot::discover_and_load() {
1077 app.replace_team(snapshot);
1078 }
1079 if let Some(session) = app.focused_session().map(|s| s.to_string()) {
1080 if let Ok(lines) = pane_source.capture(&session) {
1081 app.set_detail_buffer(lines);
1082 }
1083 } else {
1084 app.detail_buffer.clear();
1085 }
1086 refresh_mailbox(app, mailbox_source);
1087 refresh_approvals(app, approval_source);
1088 app.last_refresh = Instant::now();
1089 app.last_pane_refresh = Instant::now();
1090}
1091
1092pub fn refresh_approvals<A: ApprovalSource>(app: &mut App, approval_source: &A) {
1098 let approvals = approval_source.pending().unwrap_or_default();
1099 app.replace_approvals(approvals);
1100}
1101
1102pub fn refresh_mailbox<M: MailboxSource>(app: &mut App, mailbox_source: &M) {
1107 let Some(agent_id) = app.selected_agent_id() else {
1108 return;
1111 };
1112 let project_id = app
1113 .selected_agent
1114 .and_then(|i| app.team.agents.get(i))
1115 .map(|a| a.project.clone())
1116 .unwrap_or_default();
1117 app.mailbox.agent_id = agent_id.clone();
1121 let inbox_was_at_tail = app.mailbox.inbox_at_tail();
1125 if let Ok(batch) = mailbox_source.inbox(&agent_id, app.mailbox.inbox_after) {
1130 app.mailbox.extend(MailboxTab::Inbox, batch);
1131 }
1132 if let Ok(batch) = mailbox_source.sent(&agent_id, app.mailbox.sent_after) {
1133 app.mailbox.extend(MailboxTab::Sent, batch);
1134 }
1135 if let Ok(batch) = mailbox_source.channel_feed(&agent_id, app.mailbox.channel_after) {
1136 app.mailbox.extend(MailboxTab::Channel, batch);
1137 }
1138 if let Ok(batch) = mailbox_source.wire(&project_id, app.mailbox.wire_after) {
1139 app.mailbox.extend(MailboxTab::Wire, batch);
1140 }
1141 if inbox_was_at_tail {
1142 app.mailbox.follow_inbox_tail();
1143 }
1144}
1145
1146pub fn run<B: Backend>(terminal: &mut Terminal<B>) -> Result<()> {
1147 let mut app = App::new();
1148 let pane_source = TmuxPaneSource;
1149 let decider = CliApprovalDecider;
1150 let sender = CliMessageSender;
1151 let key_sender = AsyncKeySender::new(TmuxKeySender);
1156 let pane_resizer = crate::pane_resize::TmuxPaneResizer;
1157 refresh_with_default_sources(&mut app, &pane_source);
1160 let mut watch = Watch::try_new(&app.team.root.join("state"));
1161 while app.running {
1162 app.now_secs = chrono::Utc::now().timestamp() as f64;
1167 terminal.draw(|f| draw(f, &app))?;
1168 let term_sz = terminal.size()?;
1176 let term_area = ratatui::layout::Rect::new(0, 0, term_sz.width, term_sz.height);
1177 sync_focused_pane_size_to(&mut app, term_area, &pane_resizer);
1178 if event::poll(POLL_INTERVAL)? {
1179 let db_path = app.team.root.join("state/mailbox.db");
1183 let mailbox_source = BrokerMailboxSource::new(db_path);
1184 handle_event(
1185 &mut app,
1186 event::read()?,
1187 &decider,
1188 &sender,
1189 &mailbox_source,
1190 &key_sender,
1191 );
1192 }
1193 if matches!(app.stage, Stage::Splash) && app.splash_started.elapsed() >= SPLASH_AUTO_DISMISS
1194 {
1195 app.dismiss_splash();
1196 }
1197 let dirty = watch.take_dirty();
1204 if dirty || app.last_refresh.elapsed() >= REFRESH_INTERVAL {
1205 let prior_root = app.team.root.clone();
1206 refresh_with_default_sources(&mut app, &pane_source);
1207 if app.team.root != prior_root {
1210 watch = Watch::try_new(&app.team.root.join("state"));
1211 }
1212 } else if app.last_pane_refresh.elapsed() >= PANE_REFRESH_INTERVAL {
1213 recapture_focused_pane(&mut app, &pane_source);
1216 }
1217 }
1218 Ok(())
1219}
1220
1221fn recapture_focused_pane<P: PaneSource>(app: &mut App, pane_source: &P) {
1226 if let Some(session) = app.focused_session().map(|s| s.to_string()) {
1227 let ts = pane_source.last_activity_secs(&session);
1252 let now_secs = app.now_secs as u64;
1253 let already_current = matches!(
1254 (&app.detail_buffer_activity, ts),
1255 (Some((cached_session, cached_ts)), Some(live_ts))
1256 if cached_session == &session
1257 && *cached_ts == live_ts
1258 && live_ts < now_secs
1259 );
1260 if !already_current {
1261 if let Ok(lines) = pane_source.capture(&session) {
1262 app.set_detail_buffer(lines);
1263 app.detail_buffer_activity = ts.map(|t| (session.clone(), t));
1264 }
1265 }
1266 }
1267 app.last_pane_refresh = Instant::now();
1268}
1269
1270pub fn sync_focused_pane_size_to<R: crate::pane_resize::PaneResizer>(
1285 app: &mut App,
1286 total_area: ratatui::layout::Rect,
1287 resizer: &R,
1288) {
1289 if !matches!(app.layout, MainLayout::Triptych) {
1290 return;
1291 }
1292 let stream = matches!(app.stage, Stage::StreamKeys);
1293 let Some(detail) =
1294 crate::pane_resize::triptych_detail_area(total_area, app.has_pending_approvals(), stream)
1295 else {
1296 return;
1297 };
1298 let Some(session) = app.focused_session().map(|s| s.to_string()) else {
1299 return;
1300 };
1301 let inner_w = detail.width.saturating_sub(2);
1308 let inner_h = detail.height.saturating_sub(2);
1309 if inner_w == 0 || inner_h == 0 {
1310 return;
1311 }
1312 let target = (inner_w, inner_h);
1313 if !crate::pane_resize::should_sync(&app.last_synced_pane_sizes, &session, target) {
1314 return;
1315 }
1316 resizer.resize(&session, target.0, target.1);
1317 app.last_synced_pane_sizes.insert(session, target);
1318}
1319
1320fn refresh_with_default_sources<P: PaneSource>(app: &mut App, pane_source: &P) {
1325 if let Ok(Some(snapshot)) = TeamSnapshot::discover_and_load() {
1326 app.replace_team(snapshot);
1327 }
1328 let db_path = app.team.root.join("state/mailbox.db");
1329 let mailbox_source = BrokerMailboxSource::new(db_path.clone());
1330 let approval_source = BrokerApprovalSource::new(db_path);
1331 if let Some(session) = app.focused_session().map(|s| s.to_string()) {
1332 if let Ok(lines) = pane_source.capture(&session) {
1333 app.set_detail_buffer(lines);
1334 }
1335 } else {
1336 app.detail_buffer.clear();
1337 }
1338 refresh_mailbox(app, &mailbox_source);
1339 refresh_approvals(app, &approval_source);
1340 app.sysinfo.refresh_cpu_usage();
1346 app.sysinfo.refresh_memory();
1347 app.last_refresh = Instant::now();
1348 app.last_pane_refresh = Instant::now();
1349}
1350
1351pub fn draw(f: &mut Frame<'_>, app: &App) {
1352 let area = f.area();
1353 match app.stage {
1354 Stage::Splash => splash::draw(f, app),
1355 Stage::Triptych => draw_main(f, area, app),
1356 Stage::StreamKeys => draw_main(f, area, app),
1361 Stage::QuitConfirm => {
1362 draw_main(f, area, app);
1363 draw_quit_confirm(f, area);
1364 }
1365 Stage::ApprovalsModal => {
1366 draw_main(f, area, app);
1367 draw_approvals_modal(f, area, app);
1368 }
1369 Stage::ComposeModal => {
1370 draw_main(f, area, app);
1371 draw_compose_modal(f, area, app);
1372 }
1373 Stage::HelpOverlay => {
1374 draw_main(f, area, app);
1375 let buf = f.buffer_mut();
1376 render_help_overlay(area, buf, app);
1377 }
1378 Stage::Tutorial => {
1379 draw_main(f, area, app);
1380 let buf = f.buffer_mut();
1381 render_tutorial(area, buf, app);
1382 }
1383 Stage::MailboxDetailModal => {
1384 draw_main(f, area, app);
1385 let buf = f.buffer_mut();
1386 render_mailbox_detail_modal(area, buf, app);
1387 }
1388 }
1389}
1390
1391fn render_help_overlay(area: Rect, buf: &mut Buffer, app: &App) {
1392 let popup_w = 70u16.min(area.width.saturating_sub(4));
1393 let popup_h = 24u16.min(area.height.saturating_sub(2));
1394 let popup = centered_rect(popup_w, popup_h, area);
1395 Clear.render(popup, buf);
1396 let block = Block::default()
1397 .title("help · ? to close")
1398 .borders(Borders::ALL)
1399 .border_style(Style::default().fg(app.capabilities.accent()));
1400 let inner = block.inner(popup);
1401 block.render(popup, buf);
1402 let muted = Style::default().fg(app.capabilities.muted());
1403 let bold = Style::default().add_modifier(Modifier::BOLD);
1404 let mut lines: Vec<ratatui::text::Line<'_>> = Vec::new();
1405 for group in crate::help::ALL_GROUPS {
1406 lines.push(ratatui::text::Line::styled(group.title, bold));
1407 for b in group.bindings {
1408 lines.push(ratatui::text::Line::raw(format!(
1409 " {:<22} {}",
1410 b.chord, b.description
1411 )));
1412 }
1413 lines.push(ratatui::text::Line::styled("", muted));
1414 }
1415 Paragraph::new(lines).render(inner, buf);
1416}
1417
1418fn render_mailbox_detail_modal(area: Rect, buf: &mut Buffer, app: &App) {
1426 let Some(row) = app.mailbox_detail_modal.as_ref() else {
1427 return;
1428 };
1429 let popup_w = 80u16.min(area.width.saturating_sub(4));
1430 let popup_h = 24u16.min(area.height.saturating_sub(2));
1431 let popup = centered_rect(popup_w, popup_h, area);
1432 Clear.render(popup, buf);
1433 let title = format!("MESSAGE · id {} · Esc/q to close", row.id);
1434 let block = Block::default()
1435 .title(title)
1436 .borders(Borders::ALL)
1437 .border_style(Style::default().fg(app.capabilities.accent()));
1438 let inner = block.inner(popup);
1439 block.render(popup, buf);
1440 if inner.height == 0 {
1441 return;
1442 }
1443
1444 const META_LINES: u16 = 6;
1449 let meta_h = META_LINES.min(inner.height);
1450 let body_h = inner.height.saturating_sub(meta_h);
1451 let meta_area = Rect {
1452 x: inner.x,
1453 y: inner.y,
1454 width: inner.width,
1455 height: meta_h,
1456 };
1457 let body_area = Rect {
1458 x: inner.x,
1459 y: inner.y + meta_h,
1460 width: inner.width,
1461 height: body_h,
1462 };
1463
1464 let ts = chrono::DateTime::<chrono::Utc>::from_timestamp(
1469 row.sent_at as i64,
1470 ((row.sent_at.fract() * 1_000_000_000.0) as u32).min(999_999_999),
1471 )
1472 .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
1473 .unwrap_or_else(|| "—".to_string());
1474
1475 let muted = Style::default().fg(app.capabilities.muted());
1476 let meta_lines = vec![
1477 ratatui::text::Line::raw(format!("from: {}", row.sender)),
1478 ratatui::text::Line::raw(format!("to: {}", row.recipient)),
1479 ratatui::text::Line::raw(format!("kind: {}", crate::mailbox::kind_label(row))),
1480 ratatui::text::Line::raw(format!("time: {ts}")),
1481 ratatui::text::Line::raw(format!(
1482 "transport: {}",
1483 crate::mailbox::transport_label(row)
1484 )),
1485 ratatui::text::Line::styled("", muted),
1486 ];
1487 Paragraph::new(meta_lines)
1488 .style(Style::default())
1489 .render(meta_area, buf);
1490
1491 Paragraph::new(row.text.clone())
1497 .wrap(Wrap { trim: false })
1498 .scroll((app.mailbox_detail_scroll, 0))
1499 .render(body_area, buf);
1500}
1501
1502fn render_tutorial(area: Rect, buf: &mut Buffer, app: &App) {
1503 let popup_w = 64u16.min(area.width.saturating_sub(4));
1504 let popup_h = 14u16.min(area.height.saturating_sub(2));
1505 let popup = centered_rect(popup_w, popup_h, area);
1506 Clear.render(popup, buf);
1507 let total = crate::onboarding::STEPS.len();
1508 let i = app.tutorial_step.min(total.saturating_sub(1));
1509 let step = &crate::onboarding::STEPS[i];
1510 let block = Block::default()
1511 .title(format!("tutorial · {}/{total}", i + 1))
1512 .borders(Borders::ALL)
1513 .border_style(Style::default().fg(app.capabilities.accent()));
1514 let inner = block.inner(popup);
1515 block.render(popup, buf);
1516 let muted = Style::default().fg(app.capabilities.muted());
1517 let lines = vec![
1518 ratatui::text::Line::styled(step.heading, Style::default().add_modifier(Modifier::BOLD)),
1519 ratatui::text::Line::raw(""),
1520 ratatui::text::Line::raw(step.body),
1521 ratatui::text::Line::raw(""),
1522 ratatui::text::Line::styled("any key next · k / ↑ / p back · Esc skip", muted),
1523 ];
1524 Paragraph::new(lines)
1530 .wrap(ratatui::widgets::Wrap { trim: true })
1531 .render(inner, buf);
1532}
1533
1534fn draw_main(f: &mut Frame<'_>, area: Rect, app: &App) {
1535 let chunks = Layout::default()
1540 .direction(Direction::Vertical)
1541 .constraints([
1542 Constraint::Min(3),
1543 Constraint::Length(1), Constraint::Length(1), ])
1546 .split(area);
1547 let buf = f.buffer_mut();
1548 match app.layout {
1549 crate::triptych::MainLayout::Triptych => {
1550 triptych::Triptych { app }.render(chunks[0], buf);
1551 }
1552 crate::triptych::MainLayout::Wall => {
1553 layouts::Wall { app }.render(chunks[0], buf);
1554 }
1555 crate::triptych::MainLayout::MailboxFirst => {
1556 layouts::MailboxFirst { app }.render(chunks[0], buf);
1557 }
1558 }
1559 statusline::Statusline { app }.render(chunks[1], buf);
1560 status_bar::StatusBar { app }.render(chunks[2], buf);
1561}
1562
1563fn draw_approvals_modal(f: &mut Frame<'_>, area: Rect, app: &App) {
1564 let buf = f.buffer_mut();
1565 render_approvals_modal(area, buf, app);
1566}
1567
1568fn draw_compose_modal(f: &mut Frame<'_>, area: Rect, app: &App) {
1569 let buf = f.buffer_mut();
1570 render_compose_modal(area, buf, app);
1571}
1572
1573fn render_compose_picker_body(inner: Rect, buf: &mut Buffer, app: &App) {
1574 let muted = Style::default().fg(app.capabilities.muted());
1575 let chunks = Layout::default()
1576 .direction(Direction::Vertical)
1577 .constraints([
1578 Constraint::Min(1),
1579 Constraint::Length(1),
1580 Constraint::Length(1),
1581 ])
1582 .split(inner);
1583 let lines: Vec<ratatui::text::Line<'_>> = if app.team.channels.is_empty() {
1584 vec![ratatui::text::Line::styled(
1585 "(no channels declared in team-compose)",
1586 muted,
1587 )]
1588 } else {
1589 app.team
1590 .channels
1591 .iter()
1592 .enumerate()
1593 .map(|(i, ch)| {
1594 let label = format!(" #{} ({})", ch.name, ch.project_id);
1595 let style = if i == app.compose_picker_index {
1596 Style::default()
1597 .fg(app.capabilities.accent())
1598 .add_modifier(Modifier::REVERSED)
1599 } else {
1600 Style::default()
1601 };
1602 ratatui::text::Line::styled(label, style)
1603 })
1604 .collect()
1605 };
1606 Paragraph::new(lines).render(chunks[0], buf);
1607 Paragraph::new("pick a channel to broadcast to")
1608 .style(muted)
1609 .render(chunks[1], buf);
1610 Paragraph::new("Enter pick · j/k navigate · Esc cancel")
1611 .style(muted)
1612 .render(chunks[2], buf);
1613}
1614
1615fn render_compose_modal(area: Rect, buf: &mut Buffer, app: &App) {
1616 let popup_w = 80u16.min(area.width.saturating_sub(4));
1617 let popup_h = 16u16.min(area.height.saturating_sub(2));
1618 let popup = centered_rect(popup_w, popup_h, area);
1619 Clear.render(popup, buf);
1620 let title = app
1621 .compose_target
1622 .as_ref()
1623 .map(|t| t.title(&app.team))
1624 .unwrap_or_else(|| "→ ?".into());
1625 let block = Block::default()
1626 .title(title)
1627 .borders(Borders::ALL)
1628 .border_style(Style::default().fg(app.capabilities.accent()));
1629 let inner = block.inner(popup);
1630 block.render(popup, buf);
1631
1632 if inner.height < 3 {
1633 return;
1634 }
1635 if app.compose_picker_open {
1639 render_compose_picker_body(inner, buf, app);
1640 return;
1641 }
1642 if app.compose_attach_input_open {
1643 render_compose_attach_input(inner, buf, app);
1644 return;
1645 }
1646 let chunks = Layout::default()
1649 .direction(Direction::Vertical)
1650 .constraints([
1651 Constraint::Min(1), Constraint::Length(1), Constraint::Length(1), ])
1655 .split(inner);
1656
1657 let muted = Style::default().fg(app.capabilities.muted());
1662 let body_lines: Vec<ratatui::text::Line<'_>> = app
1663 .compose_editor
1664 .lines
1665 .iter()
1666 .enumerate()
1667 .map(|(row, line)| {
1668 if row == app.compose_editor.cursor_row
1669 && app.compose_editor.mode == crate::compose::VimMode::Insert
1670 {
1671 let col = app.compose_editor.cursor_col.min(line.len());
1672 let (head, tail) = line.split_at(col);
1673 ratatui::text::Line::from(vec![
1674 ratatui::text::Span::raw(head.to_string()),
1675 ratatui::text::Span::styled(
1676 "▏",
1677 Style::default().fg(app.capabilities.accent()),
1678 ),
1679 ratatui::text::Span::raw(tail.to_string()),
1680 ])
1681 } else {
1682 ratatui::text::Line::raw(line.clone())
1683 }
1684 })
1685 .collect();
1686 Paragraph::new(body_lines).render(chunks[0], buf);
1687
1688 let error_line = match (&app.compose_error, app.compose_editor.mode) {
1689 (Some(e), _) => format!("error: {e}"),
1690 (None, crate::compose::VimMode::Ex) => format!(":{}", app.compose_editor.ex_buffer),
1691 (None, crate::compose::VimMode::Normal) => "-- NORMAL --".into(),
1692 (None, crate::compose::VimMode::Insert) => "-- INSERT --".into(),
1693 };
1694 let style = if app.compose_error.is_some() {
1695 Style::default().fg(app.capabilities.accent())
1696 } else {
1697 muted
1698 };
1699 Paragraph::new(error_line)
1700 .style(style)
1701 .render(chunks[1], buf);
1702
1703 Paragraph::new("Esc Enter send · Esc Esc cancel · Tab attach")
1704 .style(muted)
1705 .render(chunks[2], buf);
1706}
1707
1708fn render_compose_attach_input(inner: Rect, buf: &mut Buffer, app: &App) {
1713 let muted = Style::default().fg(app.capabilities.muted());
1714 let chunks = Layout::default()
1715 .direction(Direction::Vertical)
1716 .constraints([
1717 Constraint::Min(1),
1718 Constraint::Length(1),
1719 Constraint::Length(1),
1720 ])
1721 .split(inner);
1722 let line = ratatui::text::Line::from(vec![
1723 ratatui::text::Span::raw(format!("path: {}", app.compose_attach_buffer)),
1724 ratatui::text::Span::styled("▏", Style::default().fg(app.capabilities.accent())),
1725 ]);
1726 Paragraph::new(line).render(chunks[0], buf);
1727 Paragraph::new("type or paste an absolute path; the agent reads it via the broker")
1728 .style(muted)
1729 .render(chunks[1], buf);
1730 Paragraph::new("Enter confirm · Esc cancel")
1731 .style(muted)
1732 .render(chunks[2], buf);
1733}
1734
1735fn render_approvals_modal(area: Rect, buf: &mut Buffer, app: &App) {
1736 let popup_w = 80u16.min(area.width.saturating_sub(4));
1737 let popup_h = 18u16.min(area.height.saturating_sub(2));
1738 let popup = centered_rect(popup_w, popup_h, area);
1739 Clear.render(popup, buf);
1740 let n = app.pending_approvals.len();
1741 let i = app.selected_approval.min(n.saturating_sub(1));
1742 let title = format!("approvals · {}/{n}", i + 1);
1743 let block = Block::default()
1744 .title(title)
1745 .borders(Borders::ALL)
1746 .border_style(Style::default().fg(app.capabilities.accent()));
1747 let inner = block.inner(popup);
1748 block.render(popup, buf);
1749
1750 let muted = Style::default().fg(app.capabilities.muted());
1751 let bold = Style::default().add_modifier(Modifier::BOLD);
1752
1753 let Some(a) = app.focused_approval() else {
1754 Paragraph::new("(no pending approvals)")
1755 .style(muted)
1756 .alignment(Alignment::Center)
1757 .render(inner, buf);
1758 return;
1759 };
1760
1761 let mut lines: Vec<ratatui::text::Line<'_>> = vec![
1762 ratatui::text::Line::styled(format!("#{} {}", a.id, a.action), bold),
1763 ratatui::text::Line::styled(
1764 format!("from: {}", crate::data::agent_label(&app.team, &a.agent_id)),
1765 muted,
1766 ),
1767 ratatui::text::Line::raw(""),
1768 ratatui::text::Line::raw(a.summary.clone()),
1769 ];
1770 if !a.payload_json.is_empty() && a.payload_json != "{}" {
1771 lines.push(ratatui::text::Line::raw(""));
1772 lines.push(ratatui::text::Line::styled("payload:", muted));
1773 for chunk in a.payload_json.lines().take(4) {
1774 lines.push(ratatui::text::Line::raw(chunk.to_string()));
1775 }
1776 }
1777 if let Some(err) = &app.approval_error {
1778 lines.push(ratatui::text::Line::raw(""));
1779 lines.push(ratatui::text::Line::styled(
1780 format!("error: {err}"),
1781 Style::default().fg(app.capabilities.accent()),
1782 ));
1783 }
1784 lines.push(ratatui::text::Line::raw(""));
1785 lines.push(ratatui::text::Line::styled(
1786 "[y] approve · [Shift-N] deny · [j/k] cycle · [Esc] close",
1787 muted,
1788 ));
1789 Paragraph::new(lines).render(inner, buf);
1790}
1791
1792fn draw_quit_confirm(f: &mut Frame<'_>, area: Rect) {
1793 let popup_w = 36u16.min(area.width.saturating_sub(2));
1794 let popup_h = 5u16.min(area.height.saturating_sub(2));
1795 let popup = centered_rect(popup_w, popup_h, area);
1796 let buf = f.buffer_mut();
1797 Clear.render(popup, buf);
1798 Paragraph::new("Quit teamctl-ui? [y / n]")
1799 .alignment(Alignment::Center)
1800 .block(Block::default().borders(Borders::ALL).title("confirm"))
1801 .render(popup, buf);
1802}
1803
1804fn centered_rect(w: u16, h: u16, area: Rect) -> Rect {
1805 let x = area.x + area.width.saturating_sub(w) / 2;
1806 let y = area.y + area.height.saturating_sub(h) / 2;
1807 Rect {
1808 x,
1809 y,
1810 width: w,
1811 height: h,
1812 }
1813}
1814
1815pub fn handle_event<D: ApprovalDecider, S: MessageSender, M: MailboxSource, K: KeySender>(
1816 app: &mut App,
1817 ev: Event,
1818 decider: &D,
1819 sender: &S,
1820 mailbox_source: &M,
1821 key_sender: &K,
1822) {
1823 use crossterm::event::KeyModifiers;
1824 match ev {
1825 Event::Key(k) if k.kind == KeyEventKind::Press => match app.stage {
1826 Stage::Splash => app.dismiss_splash(),
1827 Stage::Triptych => match k.code {
1828 KeyCode::Enter if app.mailbox_input_mode.is_some() => app.mailbox_input_confirm(),
1837 KeyCode::Esc if app.mailbox_input_mode.is_some() => app.mailbox_input_cancel(),
1838 KeyCode::Backspace if app.mailbox_input_mode.is_some() => {
1839 app.mailbox_input_pop_char()
1840 }
1841 KeyCode::Char(c)
1849 if app.mailbox_input_mode.is_some()
1850 && (k.modifiers.is_empty() || k.modifiers == KeyModifiers::SHIFT) =>
1851 {
1852 app.mailbox_input_push_char(c)
1853 }
1854 _ if app.mailbox_input_mode.is_some() => {}
1855
1856 KeyCode::Char('q') if app.pending_chord == Some(KeyCode::Char('w')) => {
1861 app.pending_chord = None;
1862 app.close_focused_split();
1863 }
1864 KeyCode::Char('o') if app.pending_chord == Some(KeyCode::Char('w')) => {
1865 app.pending_chord = None;
1866 if !app.detail_splits.is_empty() {
1867 let keep = app.selected_split.min(app.detail_splits.len() - 1);
1868 let kept = app.detail_splits.remove(keep);
1869 app.detail_splits.clear();
1870 app.detail_splits.push(kept);
1871 app.selected_split = 0;
1872 }
1873 }
1874 KeyCode::Char('q') if k.modifiers.is_empty() => app.enter_quit_confirm(),
1875 KeyCode::Char('a') => app.enter_approvals_modal(),
1879 KeyCode::Char('@') => app.enter_compose_dm_for_focused(),
1884 KeyCode::Char('!') => app.enter_compose_broadcast_with_picker(),
1885 KeyCode::Char('w') | KeyCode::Char('W')
1895 if k.modifiers.contains(KeyModifiers::CONTROL)
1896 && !app.detail_splits.is_empty() =>
1897 {
1898 app.pending_chord = Some(KeyCode::Char('w'))
1899 }
1900 KeyCode::Char('w') | KeyCode::Char('W')
1905 if k.modifiers.contains(KeyModifiers::CONTROL) =>
1906 {
1907 app.toggle_wall_layout()
1908 }
1909 KeyCode::Char('m') | KeyCode::Char('M')
1910 if k.modifiers.contains(KeyModifiers::CONTROL) =>
1911 {
1912 app.toggle_mailbox_first_layout()
1913 }
1914 KeyCode::Char('|') if k.modifiers.contains(KeyModifiers::CONTROL) => {
1918 app.add_detail_split_vertical()
1919 }
1920 KeyCode::Char('-') if k.modifiers.contains(KeyModifiers::CONTROL) => {
1921 app.add_detail_split_horizontal()
1922 }
1923 KeyCode::Char('h')
1928 | KeyCode::Char('H')
1929 | KeyCode::Char('k')
1930 | KeyCode::Char('K')
1931 if k.modifiers.contains(KeyModifiers::CONTROL) =>
1932 {
1933 app.cycle_split_prev()
1934 }
1935 KeyCode::Char('l')
1936 | KeyCode::Char('L')
1937 | KeyCode::Char('j')
1938 | KeyCode::Char('J')
1939 if k.modifiers.contains(KeyModifiers::CONTROL) =>
1940 {
1941 app.cycle_split_next()
1942 }
1943 KeyCode::Char('q') | KeyCode::Char('Q')
1948 if k.modifiers.contains(KeyModifiers::CONTROL) =>
1949 {
1950 app.close_focused_split()
1951 }
1952 KeyCode::Char('e') | KeyCode::Char('E')
1960 if k.modifiers.contains(KeyModifiers::CONTROL)
1961 && app.focused_pane == Pane::Detail =>
1962 {
1963 app.enter_stream_keys()
1964 }
1965 KeyCode::Char('?')
1973 if k.modifiers.is_empty() || k.modifiers == KeyModifiers::SHIFT =>
1974 {
1975 app.enter_help_overlay()
1976 }
1977 KeyCode::Char('t') if k.modifiers.is_empty() => app.enter_tutorial(),
1978 KeyCode::BackTab => app.cycle_focus_back(),
1982 KeyCode::Tab if k.modifiers.contains(KeyModifiers::SHIFT) => app.cycle_focus_back(),
1983 KeyCode::Tab => app.cycle_focus(),
1991 KeyCode::Right if app.focused_pane == Pane::Mailbox => app.cycle_mailbox_tab(),
1998 KeyCode::Left if app.focused_pane == Pane::Mailbox => app.cycle_mailbox_tab_back(),
1999 KeyCode::Up | KeyCode::Char('k') if matches!(app.layout, MainLayout::Wall) => {
2004 app.wall_scroll_up()
2005 }
2006 KeyCode::Down | KeyCode::Char('j') if matches!(app.layout, MainLayout::Wall) => {
2007 app.wall_scroll_down()
2008 }
2009 KeyCode::Up | KeyCode::Char('k')
2012 if matches!(app.layout, MainLayout::MailboxFirst) =>
2013 {
2014 app.select_prev_channel()
2015 }
2016 KeyCode::Down | KeyCode::Char('j')
2017 if matches!(app.layout, MainLayout::MailboxFirst) =>
2018 {
2019 app.select_next_channel()
2020 }
2021 KeyCode::Up | KeyCode::Char('k') if app.focused_pane == Pane::Mailbox => {
2034 app.mailbox_cursor_up()
2035 }
2036 KeyCode::Down | KeyCode::Char('j') if app.focused_pane == Pane::Mailbox => {
2037 app.mailbox_cursor_down()
2038 }
2039 KeyCode::PageUp if app.focused_pane == Pane::Mailbox => app.mailbox_page_up(),
2040 KeyCode::PageDown if app.focused_pane == Pane::Mailbox => app.mailbox_page_down(),
2041 KeyCode::Home if app.focused_pane == Pane::Mailbox => app.mailbox_cursor_home(),
2042 KeyCode::End if app.focused_pane == Pane::Mailbox => app.mailbox_cursor_end(),
2043 KeyCode::Char('f') if app.focused_pane == Pane::Mailbox => {
2050 app.open_mailbox_filter_input()
2051 }
2052 KeyCode::Char('/') if app.focused_pane == Pane::Mailbox => {
2053 app.open_mailbox_search_input()
2054 }
2055 KeyCode::Enter if app.focused_pane == Pane::Mailbox => {
2066 app.open_mailbox_detail_modal()
2067 }
2068 KeyCode::Up | KeyCode::Char('k') if app.focused_pane == Pane::Roster => {
2072 app.select_prev()
2073 }
2074 KeyCode::Down | KeyCode::Char('j') if app.focused_pane == Pane::Roster => {
2075 app.select_next()
2076 }
2077 _ => {}
2078 },
2079 Stage::QuitConfirm => match k.code {
2080 KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => app.confirm_quit(),
2081 KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => app.cancel_quit(),
2082 _ => {}
2083 },
2084 Stage::ApprovalsModal => match k.code {
2085 KeyCode::Char('y') | KeyCode::Char('Y') => {
2094 app.apply_decision(decider, Decision::Approve, "")
2095 }
2096 KeyCode::Char('N') => app.apply_decision(decider, Decision::Deny, ""),
2097 KeyCode::Char('j') | KeyCode::Down => app.cycle_approval_next(),
2098 KeyCode::Char('k') | KeyCode::Up => app.cycle_approval_prev(),
2099 KeyCode::Esc | KeyCode::Char('q') => app.close_approvals_modal(),
2100 _ => {}
2101 },
2102 Stage::ComposeModal => {
2103 if app.compose_picker_open {
2107 match k.code {
2108 KeyCode::Down | KeyCode::Char('j') => app.picker_next(),
2109 KeyCode::Up | KeyCode::Char('k') => app.picker_prev(),
2110 KeyCode::Enter => app.picker_confirm(),
2111 KeyCode::Esc => {
2120 app.compose_picker_open = false;
2121 app.compose_picker_index = 0;
2122 }
2123 _ => {}
2124 }
2125 } else if app.compose_attach_input_open {
2126 match k.code {
2133 KeyCode::Char(c) => app.compose_attach_buffer.push(c),
2134 KeyCode::Backspace => {
2135 app.compose_attach_buffer.pop();
2136 }
2137 KeyCode::Enter => app.confirm_compose_attach_input(),
2138 KeyCode::Esc => app.close_compose_attach_input(),
2139 _ => {}
2140 }
2141 } else if k.code == KeyCode::Tab {
2142 app.open_compose_attach_input();
2147 } else {
2148 match app.compose_editor.apply_key(k) {
2151 EditorAction::Continue => {}
2152 EditorAction::Send => app.apply_send(sender, mailbox_source),
2153 EditorAction::Cancel => app.close_compose_modal(),
2154 }
2155 }
2156 }
2157 Stage::HelpOverlay => match k.code {
2158 KeyCode::Esc | KeyCode::Char('?') | KeyCode::Char('q') => app.close_help_overlay(),
2159 _ => {}
2160 },
2161 Stage::MailboxDetailModal => match k.code {
2167 KeyCode::Esc | KeyCode::Char('q') => app.close_mailbox_detail_modal(),
2168 KeyCode::Char('j') | KeyCode::Down => app.mailbox_detail_scroll_down(),
2169 KeyCode::Char('k') | KeyCode::Up => app.mailbox_detail_scroll_up(),
2170 _ => {}
2171 },
2172 Stage::Tutorial => match k.code {
2173 KeyCode::Esc => app.close_tutorial(),
2174 KeyCode::Char('k') | KeyCode::Up | KeyCode::Char('p') => app.tutorial_back(),
2175 _ => app.tutorial_advance(),
2176 },
2177 Stage::StreamKeys => {
2188 let ctrl = k.modifiers.contains(KeyModifiers::CONTROL);
2189 let ctrl_shift = k
2190 .modifiers
2191 .contains(KeyModifiers::CONTROL | KeyModifiers::SHIFT);
2192 if ctrl && matches!(k.code, KeyCode::Char('e') | KeyCode::Char('E')) {
2193 app.exit_stream_keys();
2194 } else if ctrl_shift && matches!(k.code, KeyCode::Up | KeyCode::Down) {
2195 if app.detail_splits.is_empty() || app.selected_split == 0 {
2210 if matches!(k.code, KeyCode::Up) {
2211 app.select_prev();
2212 } else {
2213 app.select_next();
2214 }
2215 }
2216 } else if let Some(session) = app.stream_target_session() {
2217 if let Some(encoded) = encode_key(k) {
2218 let _ = key_sender.send(&session, &encoded);
2223 }
2224 } else {
2225 app.exit_stream_keys();
2230 }
2231 }
2232 },
2233 Event::Resize(_, _) => {
2234 }
2236 Event::Mouse(m) if matches!(app.stage, Stage::Triptych) => {
2246 use crossterm::event::MouseEventKind;
2247 let direction = match m.kind {
2248 MouseEventKind::ScrollUp => Some(ScrollDirection::Up),
2249 MouseEventKind::ScrollDown => Some(ScrollDirection::Down),
2250 _ => None,
2251 };
2252 if let Some(dir) = direction {
2253 match app.focused_pane {
2254 Pane::Detail => {
2255 if let Some(session) = app.focused_session().map(|s| s.to_string()) {
2256 let _ = key_sender.scroll(&session, dir);
2261 }
2262 }
2263 Pane::Roster => match dir {
2264 ScrollDirection::Up => app.select_prev(),
2265 ScrollDirection::Down => app.select_next(),
2266 },
2267 Pane::Mailbox => match dir {
2268 ScrollDirection::Up => app.mailbox_cursor_up(),
2269 ScrollDirection::Down => app.mailbox_cursor_down(),
2270 },
2271 }
2272 }
2273 }
2274 _ => {}
2275 }
2276}
2277
2278pub fn render_to_buffer(app: &App, width: u16, height: u16) -> Buffer {
2282 let area = Rect::new(0, 0, width, height);
2283 let mut buf = Buffer::empty(area);
2284 match app.stage {
2285 Stage::Splash => splash::Splash { app }.render(area, &mut buf),
2286 Stage::Triptych => render_main(app, area, &mut buf),
2287 Stage::StreamKeys => render_main(app, area, &mut buf),
2288 Stage::QuitConfirm => {
2289 render_main(app, area, &mut buf);
2290 render_quit_confirm(area, &mut buf);
2291 }
2292 Stage::ApprovalsModal => {
2293 render_main(app, area, &mut buf);
2294 render_approvals_modal(area, &mut buf, app);
2295 }
2296 Stage::ComposeModal => {
2297 render_main(app, area, &mut buf);
2298 render_compose_modal(area, &mut buf, app);
2299 }
2300 Stage::HelpOverlay => {
2301 render_main(app, area, &mut buf);
2302 render_help_overlay(area, &mut buf, app);
2303 }
2304 Stage::Tutorial => {
2305 render_main(app, area, &mut buf);
2306 render_tutorial(area, &mut buf, app);
2307 }
2308 Stage::MailboxDetailModal => {
2309 render_main(app, area, &mut buf);
2310 render_mailbox_detail_modal(area, &mut buf, app);
2311 }
2312 }
2313 buf
2314}
2315
2316fn render_main(app: &App, area: Rect, buf: &mut Buffer) {
2317 let chunks = Layout::default()
2320 .direction(Direction::Vertical)
2321 .constraints([
2322 Constraint::Min(3),
2323 Constraint::Length(1), Constraint::Length(1), ])
2326 .split(area);
2327 match app.layout {
2328 crate::triptych::MainLayout::Triptych => {
2329 triptych::Triptych { app }.render(chunks[0], buf);
2330 }
2331 crate::triptych::MainLayout::Wall => {
2332 layouts::Wall { app }.render(chunks[0], buf);
2333 }
2334 crate::triptych::MainLayout::MailboxFirst => {
2335 layouts::MailboxFirst { app }.render(chunks[0], buf);
2336 }
2337 }
2338 statusline::Statusline { app }.render(chunks[1], buf);
2339 status_bar::StatusBar { app }.render(chunks[2], buf);
2340}
2341
2342fn render_quit_confirm(area: Rect, buf: &mut Buffer) {
2343 let popup_w = 36u16.min(area.width.saturating_sub(2));
2344 let popup_h = 5u16.min(area.height.saturating_sub(2));
2345 let popup = centered_rect(popup_w, popup_h, area);
2346 Clear.render(popup, buf);
2347 Paragraph::new("Quit teamctl-ui? [y / n]")
2348 .alignment(Alignment::Center)
2349 .block(Block::default().borders(Borders::ALL).title("confirm"))
2350 .render(popup, buf);
2351}
2352
2353#[cfg(test)]
2354mod tests {
2355 use super::*;
2356 use crate::data::AgentInfo;
2357 use crossterm::event::{KeyEvent, KeyEventState, KeyModifiers};
2358 use team_core::supervisor::AgentState;
2359
2360 fn key(code: KeyCode) -> Event {
2361 Event::Key(KeyEvent {
2362 code,
2363 modifiers: KeyModifiers::NONE,
2364 kind: KeyEventKind::Press,
2365 state: KeyEventState::NONE,
2366 })
2367 }
2368
2369 fn key_with(code: KeyCode, modifiers: KeyModifiers) -> Event {
2370 Event::Key(KeyEvent {
2371 code,
2372 modifiers,
2373 kind: KeyEventKind::Press,
2374 state: KeyEventState::NONE,
2375 })
2376 }
2377
2378 struct NoopDecider;
2380 impl crate::approvals::ApprovalDecider for NoopDecider {
2381 fn decide(
2382 &self,
2383 _root: &std::path::Path,
2384 _id: i64,
2385 _kind: crate::approvals::Decision,
2386 _note: &str,
2387 ) -> anyhow::Result<()> {
2388 Ok(())
2389 }
2390 }
2391
2392 struct NoopSender;
2394 impl crate::compose::MessageSender for NoopSender {
2395 fn send_dm(
2396 &self,
2397 _root: &std::path::Path,
2398 _agent: &str,
2399 _body: &str,
2400 ) -> anyhow::Result<()> {
2401 Ok(())
2402 }
2403 fn broadcast(
2404 &self,
2405 _root: &std::path::Path,
2406 _channel: &str,
2407 _body: &str,
2408 ) -> anyhow::Result<()> {
2409 Ok(())
2410 }
2411 }
2412
2413 struct EmptyMailbox;
2416 impl crate::mailbox::MailboxSource for EmptyMailbox {
2417 fn inbox(&self, _id: &str, _after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
2418 Ok(Vec::new())
2419 }
2420 fn sent(&self, _id: &str, _after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
2421 Ok(Vec::new())
2422 }
2423 fn channel_feed(
2424 &self,
2425 _id: &str,
2426 _after: i64,
2427 ) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
2428 Ok(Vec::new())
2429 }
2430 fn wire(&self, _id: &str, _after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
2431 Ok(Vec::new())
2432 }
2433 }
2434
2435 fn dispatch(app: &mut App, ev: Event) {
2438 super::handle_event(
2439 app,
2440 ev,
2441 &NoopDecider,
2442 &NoopSender,
2443 &EmptyMailbox,
2444 &crate::keysender::test_support::MockKeySender::default(),
2445 );
2446 }
2447
2448 fn agent(id: &str, state: AgentState) -> AgentInfo {
2449 AgentInfo {
2450 id: id.into(),
2451 agent: id
2452 .split_once(':')
2453 .map(|(_, a)| a.to_string())
2454 .unwrap_or_default(),
2455 project: id
2456 .split_once(':')
2457 .map(|(p, _)| p.to_string())
2458 .unwrap_or_default(),
2459 tmux_session: format!("t-{}", id.replace(':', "-")),
2460 state,
2461 unread_mail: 0,
2462 pending_approvals: 0,
2463 is_manager: false,
2464 display_name: None,
2465 rate_limit_resets_at: None,
2466 last_activity_at: None,
2467 reports_to: None,
2468 }
2469 }
2470
2471 pub fn fixture_team(agents: Vec<AgentInfo>) -> TeamSnapshot {
2472 TeamSnapshot {
2473 root: std::path::PathBuf::from("/fixture"),
2474 team_name: "fixture".into(),
2475 agents,
2476 channels: Vec::new(),
2477 }
2478 }
2479
2480 #[test]
2481 fn splash_dismissed_by_any_key() {
2482 let mut app = App::new();
2483 assert_eq!(app.stage, Stage::Splash);
2484 dispatch(&mut app, key(KeyCode::Char(' ')));
2485 assert_eq!(app.stage, Stage::Triptych);
2486 }
2487
2488 #[test]
2489 fn tab_cycles_panes_uniformly_and_wraps_through_mailbox() {
2490 let mut app = App::new();
2497 app.dismiss_splash();
2498 assert_eq!(app.focused_pane, Pane::Roster);
2499 dispatch(&mut app, key(KeyCode::Tab));
2500 assert_eq!(app.focused_pane, Pane::Detail);
2501 dispatch(&mut app, key(KeyCode::Tab));
2502 assert_eq!(app.focused_pane, Pane::Mailbox);
2503 assert_eq!(
2504 app.mailbox_tab,
2505 MailboxTab::Inbox,
2506 "Tab into mailbox does NOT touch the active mailbox tab"
2507 );
2508 dispatch(&mut app, key(KeyCode::Tab));
2509 assert_eq!(
2510 app.focused_pane,
2511 Pane::Roster,
2512 "Tab from mailbox wraps to roster, not into mailbox subtabs"
2513 );
2514 assert_eq!(
2515 app.mailbox_tab,
2516 MailboxTab::Inbox,
2517 "mailbox tab still untouched"
2518 );
2519 }
2520
2521 #[test]
2522 fn arrow_keys_walk_mailbox_tabs_when_mailbox_focused() {
2523 let mut app = App::new();
2528 app.dismiss_splash();
2529 dispatch(&mut app, key(KeyCode::Tab));
2531 dispatch(&mut app, key(KeyCode::Tab));
2532 assert_eq!(app.focused_pane, Pane::Mailbox);
2533 assert_eq!(app.mailbox_tab, MailboxTab::Inbox);
2534
2535 dispatch(&mut app, key(KeyCode::Right));
2537 assert_eq!(app.mailbox_tab, MailboxTab::Sent);
2538 dispatch(&mut app, key(KeyCode::Right));
2539 assert_eq!(app.mailbox_tab, MailboxTab::Inbox, "→ wraps");
2540
2541 dispatch(&mut app, key(KeyCode::Left));
2542 assert_eq!(app.mailbox_tab, MailboxTab::Sent, "← walks back");
2543 }
2544
2545 #[test]
2546 fn arrow_keys_no_op_when_mailbox_not_focused() {
2547 let mut app = App::new();
2550 app.dismiss_splash();
2551 assert_eq!(app.focused_pane, Pane::Roster);
2552 let initial = app.mailbox_tab;
2553 dispatch(&mut app, key(KeyCode::Right));
2554 dispatch(&mut app, key(KeyCode::Left));
2555 assert_eq!(
2556 app.mailbox_tab, initial,
2557 "←/→ from non-mailbox panes must not flip the active tab"
2558 );
2559 }
2560
2561 #[test]
2562 fn brackets_no_longer_cycle_mailbox_tabs() {
2563 let mut app = App::new();
2568 app.dismiss_splash();
2569 dispatch(&mut app, key(KeyCode::Tab));
2570 dispatch(&mut app, key(KeyCode::Tab));
2571 assert_eq!(app.focused_pane, Pane::Mailbox);
2572 let initial = app.mailbox_tab;
2573
2574 dispatch(&mut app, key(KeyCode::Char(']')));
2575 dispatch(&mut app, key(KeyCode::Char('[')));
2576 assert_eq!(
2577 app.mailbox_tab, initial,
2578 "`[` / `]` must no longer cycle mailbox tabs (T-124 hard-swap)",
2579 );
2580 }
2581
2582 #[test]
2583 fn q_opens_confirm_then_n_cancels() {
2584 let mut app = App::new();
2585 app.dismiss_splash();
2586 dispatch(&mut app, key(KeyCode::Char('q')));
2587 assert_eq!(app.stage, Stage::QuitConfirm);
2588 dispatch(&mut app, key(KeyCode::Char('n')));
2589 assert_eq!(app.stage, Stage::Triptych);
2590 assert!(app.running, "n must not exit");
2591 }
2592
2593 #[test]
2594 fn q_then_y_exits() {
2595 let mut app = App::new();
2596 app.dismiss_splash();
2597 dispatch(&mut app, key(KeyCode::Char('q')));
2598 dispatch(&mut app, key(KeyCode::Char('y')));
2599 assert!(!app.running);
2600 }
2601
2602 #[test]
2603 fn esc_cancels_quit_confirm() {
2604 let mut app = App::new();
2605 app.dismiss_splash();
2606 app.enter_quit_confirm();
2607 dispatch(&mut app, key(KeyCode::Esc));
2608 assert_eq!(app.stage, Stage::Triptych);
2609 }
2610
2611 #[test]
2612 fn render_does_not_panic_at_minimal_size() {
2613 let app = App::new();
2614 let _ = render_to_buffer(&app, 20, 8);
2615 }
2616
2617 #[test]
2618 fn render_does_not_panic_at_huge_size() {
2619 let app = App::new();
2620 let _ = render_to_buffer(&app, 240, 80);
2621 }
2622
2623 #[test]
2624 fn select_next_wraps_through_team() {
2625 let mut app = App::new();
2626 app.replace_team(fixture_team(vec![
2627 agent("p:a", AgentState::Running),
2628 agent("p:b", AgentState::Running),
2629 agent("p:c", AgentState::Running),
2630 ]));
2631 assert_eq!(app.selected_agent, Some(0));
2632 app.select_next();
2633 assert_eq!(app.selected_agent, Some(1));
2634 app.select_next();
2635 assert_eq!(app.selected_agent, Some(2));
2636 app.select_next();
2637 assert_eq!(app.selected_agent, Some(0)); }
2639
2640 #[test]
2641 fn select_prev_wraps_at_top() {
2642 let mut app = App::new();
2643 app.replace_team(fixture_team(vec![
2644 agent("p:a", AgentState::Running),
2645 agent("p:b", AgentState::Running),
2646 ]));
2647 app.selected_agent = Some(0);
2648 app.select_prev();
2649 assert_eq!(app.selected_agent, Some(1));
2650 }
2651
2652 #[test]
2653 fn select_no_op_on_empty_team() {
2654 let mut app = App::new();
2655 app.select_next();
2656 assert_eq!(app.selected_agent, None);
2657 app.select_prev();
2658 assert_eq!(app.selected_agent, None);
2659 }
2660
2661 #[test]
2662 fn replace_team_preserves_selection_when_agent_still_present() {
2663 let mut app = App::new();
2664 app.replace_team(fixture_team(vec![
2665 agent("p:a", AgentState::Running),
2666 agent("p:b", AgentState::Running),
2667 ]));
2668 app.selected_agent = Some(1);
2669 app.replace_team(fixture_team(vec![
2670 agent("p:a", AgentState::Running),
2671 agent("p:b", AgentState::Stopped), ]));
2673 assert_eq!(app.selected_agent, Some(1), "selection follows the id");
2674 }
2675
2676 #[test]
2677 fn replace_team_resets_selection_when_agent_disappears() {
2678 let mut app = App::new();
2679 app.replace_team(fixture_team(vec![
2680 agent("p:a", AgentState::Running),
2681 agent("p:gone", AgentState::Running),
2682 ]));
2683 app.selected_agent = Some(1);
2684 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
2685 assert_eq!(app.selected_agent, Some(0), "falls back to first agent");
2686 }
2687
2688 #[test]
2689 fn switching_agent_resets_mailbox_buffers() {
2690 let mut app = App::new();
2694 app.replace_team(fixture_team(vec![
2695 agent("p:a", AgentState::Running),
2696 agent("p:b", AgentState::Running),
2697 ]));
2698 app.mailbox.extend(
2699 crate::mailbox::MailboxTab::Inbox,
2700 vec![crate::mailbox::MessageRow {
2701 id: 7,
2702 sender: "p:b".into(),
2703 recipient: "p:a".into(),
2704 text: "hi".into(),
2705 sent_at: 0.0,
2706 }],
2707 );
2708 assert_eq!(app.mailbox.inbox.len(), 1);
2709 assert_eq!(app.mailbox.inbox_after, 7);
2710 app.select_next();
2712 assert_eq!(app.selected_agent_id().as_deref(), Some("p:b"));
2713 assert!(app.mailbox.inbox.is_empty());
2714 assert_eq!(app.mailbox.inbox_after, 0);
2715 }
2716
2717 struct TripleFilterMock {
2722 inbox: Vec<crate::mailbox::MessageRow>,
2723 sent: Vec<crate::mailbox::MessageRow>,
2724 channel: Vec<crate::mailbox::MessageRow>,
2725 wire: Vec<crate::mailbox::MessageRow>,
2726 calls: std::sync::Mutex<Vec<(&'static str, String, i64)>>,
2727 }
2728 impl crate::mailbox::MailboxSource for TripleFilterMock {
2729 fn inbox(&self, id: &str, after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
2730 self.calls.lock().unwrap().push(("inbox", id.into(), after));
2731 Ok(self.inbox.clone())
2732 }
2733 fn sent(&self, id: &str, after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
2734 self.calls.lock().unwrap().push(("sent", id.into(), after));
2735 Ok(self.sent.clone())
2736 }
2737 fn channel_feed(
2738 &self,
2739 id: &str,
2740 after: i64,
2741 ) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
2742 self.calls
2743 .lock()
2744 .unwrap()
2745 .push(("channel", id.into(), after));
2746 Ok(self.channel.clone())
2747 }
2748 fn wire(&self, id: &str, after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
2749 self.calls.lock().unwrap().push(("wire", id.into(), after));
2750 Ok(self.wire.clone())
2751 }
2752 }
2753
2754 #[test]
2755 fn refresh_mailbox_fans_out_to_four_filters() {
2756 use crate::mailbox::MessageRow;
2757 let mut app = App::new();
2758 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
2759 let mock = TripleFilterMock {
2760 inbox: vec![MessageRow {
2761 id: 1,
2762 sender: "p:b".into(),
2763 recipient: "p:a".into(),
2764 text: "dm".into(),
2765 sent_at: 0.0,
2766 }],
2767 sent: vec![MessageRow {
2768 id: 4,
2769 sender: "p:a".into(),
2770 recipient: "p:b".into(),
2771 text: "outgoing dm".into(),
2772 sent_at: 0.0,
2773 }],
2774 channel: vec![MessageRow {
2775 id: 2,
2776 sender: "p:b".into(),
2777 recipient: "channel:p:editorial".into(),
2778 text: "ch".into(),
2779 sent_at: 0.0,
2780 }],
2781 wire: vec![MessageRow {
2782 id: 3,
2783 sender: "p:b".into(),
2784 recipient: "channel:p:all".into(),
2785 text: "wire".into(),
2786 sent_at: 0.0,
2787 }],
2788 calls: std::sync::Mutex::new(Vec::new()),
2789 };
2790 super::refresh_mailbox(&mut app, &mock);
2791 assert_eq!(app.mailbox.inbox.len(), 1);
2792 assert_eq!(app.mailbox.sent.len(), 1);
2793 assert_eq!(app.mailbox.channel.len(), 1);
2794 assert_eq!(app.mailbox.wire.len(), 1);
2795 let calls = mock.calls.lock().unwrap();
2796 assert!(calls.contains(&("inbox", "p:a".into(), 0)));
2799 assert!(calls.contains(&("sent", "p:a".into(), 0)));
2800 assert!(calls.contains(&("channel", "p:a".into(), 0)));
2801 assert!(calls.contains(&("wire", "p".into(), 0)));
2802 }
2803
2804 fn ap(id: i64) -> crate::approvals::Approval {
2805 crate::approvals::Approval {
2806 id,
2807 project_id: "p".into(),
2808 agent_id: "p:m".into(),
2809 action: "publish".into(),
2810 summary: format!("approval #{id}"),
2811 payload_json: String::new(),
2812 }
2813 }
2814
2815 #[test]
2816 fn has_pending_approvals_tracks_replace_calls() {
2817 let mut app = App::new();
2818 assert!(!app.has_pending_approvals());
2819 app.replace_approvals(vec![ap(1), ap(2)]);
2820 assert!(app.has_pending_approvals());
2821 app.replace_approvals(vec![]);
2822 assert!(!app.has_pending_approvals());
2823 }
2824
2825 #[test]
2826 fn enter_approvals_modal_no_op_when_queue_empty() {
2827 let mut app = App::new();
2828 app.dismiss_splash();
2829 app.enter_approvals_modal();
2830 assert_eq!(app.stage, Stage::Triptych, "no pending → no modal");
2831 }
2832
2833 #[test]
2834 fn a_chord_opens_modal_when_pending() {
2835 let mut app = App::new();
2836 app.dismiss_splash();
2837 app.replace_approvals(vec![ap(1), ap(2)]);
2838 dispatch(&mut app, key(KeyCode::Char('a')));
2839 assert_eq!(app.stage, Stage::ApprovalsModal);
2840 assert_eq!(app.selected_approval, 0);
2841 }
2842
2843 #[test]
2844 fn modal_cycle_jk_walks_approvals() {
2845 let mut app = App::new();
2846 app.dismiss_splash();
2847 app.replace_approvals(vec![ap(1), ap(2), ap(3)]);
2848 app.enter_approvals_modal();
2849 dispatch(&mut app, key(KeyCode::Char('j')));
2850 assert_eq!(app.selected_approval, 1);
2851 dispatch(&mut app, key(KeyCode::Char('j')));
2852 assert_eq!(app.selected_approval, 2);
2853 dispatch(&mut app, key(KeyCode::Char('j')));
2854 assert_eq!(app.selected_approval, 0, "wraps");
2855 dispatch(&mut app, key(KeyCode::Char('k')));
2856 assert_eq!(app.selected_approval, 2, "k wraps too");
2857 }
2858
2859 #[test]
2860 fn capital_y_routes_approve_through_decider() {
2861 use crate::approvals::test_support::MockApprovalDecider;
2862 let dec = MockApprovalDecider::default();
2863 let mut app = App::new();
2864 app.dismiss_splash();
2865 app.replace_approvals(vec![ap(7), ap(8)]);
2866 app.enter_approvals_modal();
2867 super::handle_event(
2868 &mut app,
2869 key(KeyCode::Char('Y')),
2870 &dec,
2871 &NoopSender,
2872 &EmptyMailbox,
2873 &crate::keysender::test_support::MockKeySender::default(),
2874 );
2875 let calls = dec.calls.lock().unwrap().clone();
2876 assert_eq!(calls.len(), 1);
2877 assert_eq!(calls[0].0, 7);
2878 assert_eq!(calls[0].1, crate::approvals::Decision::Approve);
2879 assert_eq!(app.pending_approvals.len(), 1);
2881 assert_eq!(app.pending_approvals[0].id, 8);
2882 }
2883
2884 #[test]
2885 fn capital_n_routes_deny_through_decider() {
2886 use crate::approvals::test_support::MockApprovalDecider;
2887 let dec = MockApprovalDecider::default();
2888 let mut app = App::new();
2889 app.dismiss_splash();
2890 app.replace_approvals(vec![ap(7)]);
2891 app.enter_approvals_modal();
2892 super::handle_event(
2893 &mut app,
2894 key(KeyCode::Char('N')),
2895 &dec,
2896 &NoopSender,
2897 &EmptyMailbox,
2898 &crate::keysender::test_support::MockKeySender::default(),
2899 );
2900 let calls = dec.calls.lock().unwrap().clone();
2901 assert_eq!(calls.len(), 1);
2902 assert_eq!(calls[0].1, crate::approvals::Decision::Deny);
2903 assert_eq!(app.stage, Stage::Triptych);
2905 }
2906
2907 #[test]
2908 fn esc_closes_approvals_modal() {
2909 let mut app = App::new();
2910 app.dismiss_splash();
2911 app.replace_approvals(vec![ap(1)]);
2912 app.enter_approvals_modal();
2913 dispatch(&mut app, key(KeyCode::Esc));
2914 assert_eq!(app.stage, Stage::Triptych);
2915 }
2916
2917 #[test]
2918 fn lowercase_y_routes_approve_through_decider() {
2919 use crate::approvals::test_support::MockApprovalDecider;
2923 let dec = MockApprovalDecider::default();
2924 let mut app = App::new();
2925 app.dismiss_splash();
2926 app.replace_approvals(vec![ap(7)]);
2927 app.enter_approvals_modal();
2928 super::handle_event(
2929 &mut app,
2930 key(KeyCode::Char('y')),
2931 &dec,
2932 &NoopSender,
2933 &EmptyMailbox,
2934 &crate::keysender::test_support::MockKeySender::default(),
2935 );
2936 let calls = dec.calls.lock().unwrap().clone();
2937 assert_eq!(calls.len(), 1);
2938 assert_eq!(calls[0].1, crate::approvals::Decision::Approve);
2939 }
2940
2941 #[test]
2942 fn lowercase_n_does_not_deny() {
2943 use crate::approvals::test_support::MockApprovalDecider;
2948 let dec = MockApprovalDecider::default();
2949 let mut app = App::new();
2950 app.dismiss_splash();
2951 app.replace_approvals(vec![ap(7)]);
2952 app.enter_approvals_modal();
2953 super::handle_event(
2954 &mut app,
2955 key(KeyCode::Char('n')),
2956 &dec,
2957 &NoopSender,
2958 &EmptyMailbox,
2959 &crate::keysender::test_support::MockKeySender::default(),
2960 );
2961 assert!(
2962 dec.calls.lock().unwrap().is_empty(),
2963 "lowercase n must not route through the decider"
2964 );
2965 assert_eq!(
2966 app.stage,
2967 Stage::ApprovalsModal,
2968 "stale lowercase n leaves the modal open"
2969 );
2970 }
2971
2972 #[test]
2973 fn shift_tab_cycles_panes_backward() {
2974 use crossterm::event::KeyModifiers;
2975 let mut app = App::new();
2976 app.dismiss_splash();
2977 assert_eq!(app.focused_pane, Pane::Roster);
2978 dispatch(&mut app, key(KeyCode::BackTab));
2981 assert_eq!(app.focused_pane, Pane::Mailbox);
2982 dispatch(&mut app, key_with(KeyCode::Tab, KeyModifiers::SHIFT));
2984 assert_eq!(app.focused_pane, Pane::Detail);
2985 }
2986
2987 #[test]
2988 fn at_chord_opens_compose_dm_to_focused_agent() {
2989 let mut app = App::new();
2990 app.replace_team(fixture_team(vec![
2991 agent("writing:manager", AgentState::Running),
2992 agent("writing:dev1", AgentState::Running),
2993 ]));
2994 app.dismiss_splash();
2995 app.select_next();
2996 dispatch(&mut app, key(KeyCode::Char('@')));
2997 assert_eq!(app.stage, Stage::ComposeModal);
2998 match app.compose_target.as_ref() {
2999 Some(crate::compose::ComposeTarget::Dm { agent_id, .. }) => {
3000 assert_eq!(agent_id, "writing:dev1");
3001 }
3002 other => panic!("expected DM target, got {other:?}"),
3003 }
3004 }
3005
3006 #[test]
3007 fn bang_chord_opens_compose_broadcast_to_all_channel() {
3008 let mut app = App::new();
3009 app.replace_team(fixture_team(vec![agent(
3010 "writing:manager",
3011 AgentState::Running,
3012 )]));
3013 app.dismiss_splash();
3014 dispatch(&mut app, key(KeyCode::Char('!')));
3015 assert_eq!(app.stage, Stage::ComposeModal);
3016 match app.compose_target.as_ref() {
3017 Some(crate::compose::ComposeTarget::Broadcast { channel_id, .. }) => {
3018 assert_eq!(channel_id, "writing:all");
3019 }
3020 other => panic!("expected Broadcast target, got {other:?}"),
3021 }
3022 }
3023
3024 #[test]
3025 fn send_routes_dm_through_mock_sender() {
3026 use crate::compose::test_support::MockMessageSender;
3027 let sender = MockMessageSender::default();
3028 let mailbox = EmptyMailbox;
3029 let mut app = App::new();
3030 app.replace_team(fixture_team(vec![agent(
3031 "writing:dev1",
3032 AgentState::Running,
3033 )]));
3034 app.dismiss_splash();
3035 app.enter_compose_dm_for_focused();
3036 for c in "ship it".chars() {
3037 super::handle_event(
3038 &mut app,
3039 key(KeyCode::Char(c)),
3040 &NoopDecider,
3041 &sender,
3042 &mailbox,
3043 &crate::keysender::test_support::MockKeySender::default(),
3044 );
3045 }
3046 super::handle_event(
3047 &mut app,
3048 key_with(KeyCode::Enter, crossterm::event::KeyModifiers::CONTROL),
3049 &NoopDecider,
3050 &sender,
3051 &mailbox,
3052 &crate::keysender::test_support::MockKeySender::default(),
3053 );
3054 let calls = sender.dm_calls.lock().unwrap().clone();
3055 assert_eq!(calls.len(), 1);
3056 assert_eq!(calls[0].0, "writing:dev1");
3057 assert_eq!(calls[0].1, "ship it");
3058 assert_eq!(app.stage, Stage::Triptych, "modal closes on send");
3059 }
3060
3061 #[test]
3062 fn esc_esc_cancels_compose_without_send() {
3063 use crate::compose::test_support::MockMessageSender;
3064 let sender = MockMessageSender::default();
3065 let mailbox = EmptyMailbox;
3066 let mut app = App::new();
3067 app.replace_team(fixture_team(vec![agent(
3068 "writing:dev1",
3069 AgentState::Running,
3070 )]));
3071 app.dismiss_splash();
3072 app.enter_compose_dm_for_focused();
3073 for c in "draft".chars() {
3074 super::handle_event(
3075 &mut app,
3076 key(KeyCode::Char(c)),
3077 &NoopDecider,
3078 &sender,
3079 &mailbox,
3080 &crate::keysender::test_support::MockKeySender::default(),
3081 );
3082 }
3083 super::handle_event(
3084 &mut app,
3085 key(KeyCode::Esc),
3086 &NoopDecider,
3087 &sender,
3088 &mailbox,
3089 &crate::keysender::test_support::MockKeySender::default(),
3090 );
3091 super::handle_event(
3092 &mut app,
3093 key(KeyCode::Esc),
3094 &NoopDecider,
3095 &sender,
3096 &mailbox,
3097 &crate::keysender::test_support::MockKeySender::default(),
3098 );
3099 assert_eq!(app.stage, Stage::Triptych);
3100 assert!(sender.dm_calls.lock().unwrap().is_empty());
3101 }
3102
3103 #[test]
3104 fn send_failure_surfaces_error_inline_keeps_modal_open() {
3105 use crate::compose::test_support::MockMessageSender;
3106 let sender = MockMessageSender::default();
3107 *sender.fail_next.lock().unwrap() = Some("rate limit".into());
3108 let mailbox = EmptyMailbox;
3109 let mut app = App::new();
3110 app.replace_team(fixture_team(vec![agent(
3111 "writing:dev1",
3112 AgentState::Running,
3113 )]));
3114 app.dismiss_splash();
3115 app.enter_compose_dm_for_focused();
3116 super::handle_event(
3117 &mut app,
3118 key(KeyCode::Char('x')),
3119 &NoopDecider,
3120 &sender,
3121 &mailbox,
3122 &crate::keysender::test_support::MockKeySender::default(),
3123 );
3124 super::handle_event(
3125 &mut app,
3126 key_with(KeyCode::Enter, crossterm::event::KeyModifiers::CONTROL),
3127 &NoopDecider,
3128 &sender,
3129 &mailbox,
3130 &crate::keysender::test_support::MockKeySender::default(),
3131 );
3132 assert_eq!(app.stage, Stage::ComposeModal, "modal stays open on err");
3133 assert!(app
3134 .compose_error
3135 .as_deref()
3136 .unwrap_or_default()
3137 .contains("rate limit"));
3138 }
3139
3140 fn channel(id: &str, project: &str) -> crate::data::ChannelInfo {
3141 crate::data::ChannelInfo {
3142 id: id.into(),
3143 name: id
3144 .rsplit_once(':')
3145 .map(|(_, n)| n.to_string())
3146 .unwrap_or_default(),
3147 project_id: project.into(),
3148 }
3149 }
3150
3151 fn fixture_team_with_channels(
3152 agents: Vec<AgentInfo>,
3153 channels: Vec<crate::data::ChannelInfo>,
3154 ) -> TeamSnapshot {
3155 TeamSnapshot {
3156 root: std::path::PathBuf::from("/fixture"),
3157 team_name: "fixture".into(),
3158 agents,
3159 channels,
3160 }
3161 }
3162
3163 #[test]
3164 fn ctrl_w_toggles_wall_layout() {
3165 use crossterm::event::KeyModifiers;
3166 let mut app = App::new();
3167 app.dismiss_splash();
3168 assert_eq!(app.layout, MainLayout::Triptych);
3169 dispatch(
3170 &mut app,
3171 key_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
3172 );
3173 assert_eq!(app.layout, MainLayout::Wall);
3174 dispatch(
3175 &mut app,
3176 key_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
3177 );
3178 assert_eq!(app.layout, MainLayout::Triptych);
3179 }
3180
3181 #[test]
3182 fn ctrl_m_toggles_mailbox_first_layout() {
3183 use crossterm::event::KeyModifiers;
3184 let mut app = App::new();
3185 app.dismiss_splash();
3186 dispatch(
3187 &mut app,
3188 key_with(KeyCode::Char('m'), KeyModifiers::CONTROL),
3189 );
3190 assert_eq!(app.layout, MainLayout::MailboxFirst);
3191 dispatch(
3192 &mut app,
3193 key_with(KeyCode::Char('m'), KeyModifiers::CONTROL),
3194 );
3195 assert_eq!(app.layout, MainLayout::Triptych);
3196 }
3197
3198 #[test]
3199 fn wall_scroll_pages_through_overflow_agents() {
3200 let mut app = App::new();
3201 let mut agents: Vec<_> = (1..=10)
3202 .map(|i| agent(&format!("p:agent-{i:02}"), AgentState::Running))
3203 .collect();
3204 for a in agents.iter_mut() {
3206 a.is_manager = false;
3207 }
3208 app.replace_team(fixture_team(agents));
3209 app.dismiss_splash();
3210 app.toggle_wall_layout();
3211 assert_eq!(app.wall_scroll, 0);
3212 app.wall_scroll_down();
3213 assert_eq!(app.wall_scroll, 4);
3214 app.wall_scroll_down();
3215 assert_eq!(app.wall_scroll, 8);
3216 app.wall_scroll_down();
3218 assert_eq!(app.wall_scroll, 8, "scroll capped at last full window");
3219 app.wall_scroll_up();
3220 assert_eq!(app.wall_scroll, 4);
3221 }
3222
3223 #[test]
3224 fn ctrl_pipe_adds_detail_split_capped_at_four() {
3225 use crossterm::event::KeyModifiers;
3226 let mut app = App::new();
3227 app.replace_team(fixture_team(vec![
3228 agent("p:a", AgentState::Running),
3229 agent("p:b", AgentState::Running),
3230 ]));
3231 app.dismiss_splash();
3232 for _ in 0..6 {
3233 dispatch(
3234 &mut app,
3235 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
3236 );
3237 }
3238 assert_eq!(app.detail_splits.len(), 4, "split count capped at 4");
3239 }
3240
3241 #[test]
3242 fn ctrl_q_closes_focused_split() {
3243 use crossterm::event::KeyModifiers;
3244 let mut app = App::new();
3245 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3246 app.dismiss_splash();
3247 dispatch(
3248 &mut app,
3249 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
3250 );
3251 dispatch(
3252 &mut app,
3253 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
3254 );
3255 assert_eq!(app.detail_splits.len(), 2);
3256 dispatch(
3257 &mut app,
3258 key_with(KeyCode::Char('Q'), KeyModifiers::CONTROL),
3259 );
3260 assert_eq!(app.detail_splits.len(), 1);
3261 }
3262
3263 #[test]
3264 fn ctrl_hjkl_cycles_splits() {
3265 use crossterm::event::KeyModifiers;
3266 let mut app = App::new();
3267 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3268 app.dismiss_splash();
3269 for _ in 0..3 {
3270 dispatch(
3271 &mut app,
3272 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
3273 );
3274 }
3275 assert_eq!(app.selected_split, 2);
3276 dispatch(
3277 &mut app,
3278 key_with(KeyCode::Char('l'), KeyModifiers::CONTROL),
3279 );
3280 assert_eq!(app.selected_split, 0, "wraps");
3281 dispatch(
3282 &mut app,
3283 key_with(KeyCode::Char('h'), KeyModifiers::CONTROL),
3284 );
3285 assert_eq!(app.selected_split, 2);
3286 }
3287
3288 #[test]
3289 fn wall_scroll_at_exactly_cap_agents_does_not_scroll() {
3290 let mut app = App::new();
3295 let agents: Vec<_> = (1..=4)
3296 .map(|i| agent(&format!("p:agent-{i}"), AgentState::Running))
3297 .collect();
3298 app.replace_team(fixture_team(agents));
3299 app.dismiss_splash();
3300 app.toggle_wall_layout();
3301 assert_eq!(app.wall_scroll, 0);
3302 app.wall_scroll_down();
3303 assert_eq!(app.wall_scroll, 0, "exactly-cap should not advance");
3304 app.wall_scroll_up();
3305 assert_eq!(app.wall_scroll, 0);
3306 }
3307
3308 #[test]
3309 fn wall_scroll_at_cap_plus_one_advances_then_stops() {
3310 let mut app = App::new();
3315 let agents: Vec<_> = (1..=5)
3316 .map(|i| agent(&format!("p:agent-{i}"), AgentState::Running))
3317 .collect();
3318 app.replace_team(fixture_team(agents));
3319 app.dismiss_splash();
3320 app.toggle_wall_layout();
3321 app.wall_scroll_down();
3322 assert_eq!(app.wall_scroll, 4, "first scroll exposes agent 5");
3323 app.wall_scroll_down();
3324 assert_eq!(app.wall_scroll, 4, "second scroll caps; nothing past");
3325 }
3326
3327 #[test]
3328 fn esc_in_picker_dismisses_overlay_only_keeps_modal_open() {
3329 let mut app = App::new();
3335 app.replace_team(fixture_team_with_channels(
3336 vec![agent("writing:manager", AgentState::Running)],
3337 vec![
3338 channel("writing:all", "writing"),
3339 channel("writing:editorial", "writing"),
3340 ],
3341 ));
3342 app.dismiss_splash();
3343 dispatch(&mut app, key(KeyCode::Char('!')));
3344 assert!(app.compose_picker_open);
3345 assert_eq!(app.stage, Stage::ComposeModal);
3346 dispatch(&mut app, key(KeyCode::Esc));
3347 assert!(!app.compose_picker_open, "picker dismissed");
3348 assert_eq!(app.stage, Stage::ComposeModal, "compose modal stays open");
3349 }
3350
3351 #[test]
3352 fn send_routes_broadcast_through_mock_sender_via_picker() {
3353 use crate::compose::test_support::MockMessageSender;
3359 let sender = MockMessageSender::default();
3360 let mailbox = EmptyMailbox;
3361 let mut app = App::new();
3362 app.replace_team(fixture_team_with_channels(
3363 vec![agent("writing:manager", AgentState::Running)],
3364 vec![
3365 channel("writing:all", "writing"),
3366 channel("writing:editorial", "writing"),
3367 channel("writing:critique", "writing"),
3368 ],
3369 ));
3370 app.dismiss_splash();
3371 super::handle_event(
3374 &mut app,
3375 key(KeyCode::Char('!')),
3376 &NoopDecider,
3377 &sender,
3378 &mailbox,
3379 &crate::keysender::test_support::MockKeySender::default(),
3380 );
3381 super::handle_event(
3382 &mut app,
3383 key(KeyCode::Char('j')),
3384 &NoopDecider,
3385 &sender,
3386 &mailbox,
3387 &crate::keysender::test_support::MockKeySender::default(),
3388 );
3389 super::handle_event(
3390 &mut app,
3391 key(KeyCode::Enter),
3392 &NoopDecider,
3393 &sender,
3394 &mailbox,
3395 &crate::keysender::test_support::MockKeySender::default(),
3396 );
3397 for c in "ship docs".chars() {
3398 super::handle_event(
3399 &mut app,
3400 key(KeyCode::Char(c)),
3401 &NoopDecider,
3402 &sender,
3403 &mailbox,
3404 &crate::keysender::test_support::MockKeySender::default(),
3405 );
3406 }
3407 super::handle_event(
3408 &mut app,
3409 key_with(KeyCode::Enter, crossterm::event::KeyModifiers::CONTROL),
3410 &NoopDecider,
3411 &sender,
3412 &mailbox,
3413 &crate::keysender::test_support::MockKeySender::default(),
3414 );
3415 let dm_calls = sender.dm_calls.lock().unwrap().clone();
3416 let bcast_calls = sender.broadcast_calls.lock().unwrap().clone();
3417 assert!(dm_calls.is_empty(), "broadcast must not route via send_dm");
3418 assert_eq!(bcast_calls.len(), 1);
3419 assert_eq!(
3420 bcast_calls[0].0, "writing:editorial",
3421 "channel id from picker selection"
3422 );
3423 assert_eq!(bcast_calls[0].1, "ship docs");
3424 assert_eq!(app.stage, Stage::Triptych, "modal closes on send");
3425 }
3426
3427 #[test]
3428 fn bang_chord_opens_picker_when_channels_available() {
3429 let mut app = App::new();
3430 app.replace_team(fixture_team_with_channels(
3431 vec![agent("writing:manager", AgentState::Running)],
3432 vec![
3433 channel("writing:all", "writing"),
3434 channel("writing:editorial", "writing"),
3435 channel("writing:critique", "writing"),
3436 ],
3437 ));
3438 app.dismiss_splash();
3439 dispatch(&mut app, key(KeyCode::Char('!')));
3440 assert_eq!(app.stage, Stage::ComposeModal);
3441 assert!(app.compose_picker_open);
3442 dispatch(&mut app, key(KeyCode::Char('j')));
3444 assert_eq!(app.compose_picker_index, 1);
3445 dispatch(&mut app, key(KeyCode::Enter));
3447 assert!(!app.compose_picker_open, "picker closes on confirm");
3448 match app.compose_target.as_ref() {
3449 Some(crate::compose::ComposeTarget::Broadcast { channel_id, .. }) => {
3450 assert_eq!(channel_id, "writing:editorial");
3451 }
3452 other => panic!("expected Broadcast target, got {other:?}"),
3453 }
3454 }
3455
3456 #[test]
3457 fn mailbox_first_layout_seeds_channel_selection_on_entry() {
3458 let mut app = App::new();
3459 app.replace_team(fixture_team_with_channels(
3460 vec![agent("writing:manager", AgentState::Running)],
3461 vec![
3462 channel("writing:all", "writing"),
3463 channel("writing:editorial", "writing"),
3464 ],
3465 ));
3466 app.dismiss_splash();
3467 assert!(app.selected_channel.is_none());
3468 app.toggle_mailbox_first_layout();
3469 assert_eq!(app.selected_channel, Some(0));
3470 }
3471
3472 #[test]
3473 fn help_overlay_opens_on_question_mark_closes_on_esc() {
3474 let mut app = App::new();
3475 app.dismiss_splash();
3476 dispatch(&mut app, key(KeyCode::Char('?')));
3477 assert_eq!(app.stage, Stage::HelpOverlay);
3478 dispatch(&mut app, key(KeyCode::Esc));
3479 assert_eq!(app.stage, Stage::Triptych);
3480 }
3481
3482 #[test]
3483 fn tutorial_opens_on_t_advances_and_closes() {
3484 let mut app = App::new();
3485 app.dismiss_splash();
3486 dispatch(&mut app, key(KeyCode::Char('t')));
3487 assert_eq!(app.stage, Stage::Tutorial);
3488 assert_eq!(app.tutorial_step, 0);
3489 dispatch(&mut app, key(KeyCode::Char(' ')));
3491 assert_eq!(app.tutorial_step, 1);
3492 dispatch(&mut app, key(KeyCode::Char('k')));
3494 assert_eq!(app.tutorial_step, 0);
3495 dispatch(&mut app, key(KeyCode::Esc));
3497 assert_eq!(app.stage, Stage::Triptych);
3498 }
3499
3500 #[test]
3501 fn tutorial_walk_back_at_step_zero_is_no_op() {
3502 let mut app = App::new();
3507 app.dismiss_splash();
3508 app.enter_tutorial();
3509 assert_eq!(app.tutorial_step, 0);
3510 dispatch(&mut app, key(KeyCode::Char('k')));
3511 assert_eq!(app.tutorial_step, 0, "step-0 walk-back is no-op");
3512 assert_eq!(app.stage, Stage::Tutorial);
3515 }
3516
3517 #[test]
3518 fn ctrl_pipe_adds_vertical_split_ctrl_minus_adds_horizontal() {
3519 use crossterm::event::KeyModifiers;
3520 let mut app = App::new();
3521 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3522 app.dismiss_splash();
3523 dispatch(
3524 &mut app,
3525 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
3526 );
3527 dispatch(
3528 &mut app,
3529 key_with(KeyCode::Char('-'), KeyModifiers::CONTROL),
3530 );
3531 assert_eq!(app.detail_splits.len(), 2);
3532 assert_eq!(app.detail_splits[0].1, SplitOrientation::Vertical);
3533 assert_eq!(app.detail_splits[1].1, SplitOrientation::Horizontal);
3534 }
3535
3536 #[test]
3537 fn ctrl_w_q_chord_prefix_closes_focused_split() {
3538 use crossterm::event::KeyModifiers;
3539 let mut app = App::new();
3540 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3541 app.dismiss_splash();
3542 dispatch(
3545 &mut app,
3546 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
3547 );
3548 dispatch(
3549 &mut app,
3550 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
3551 );
3552 dispatch(
3553 &mut app,
3554 key_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
3555 );
3556 assert_eq!(app.pending_chord, Some(KeyCode::Char('w')));
3557 dispatch(&mut app, key(KeyCode::Char('q')));
3560 assert_eq!(app.detail_splits.len(), 1);
3561 assert_eq!(app.stage, Stage::Triptych, "must not enter quit confirm");
3562 assert_eq!(app.pending_chord, None, "chord cleared");
3563 }
3564
3565 #[test]
3566 fn ctrl_w_o_chord_keeps_only_focused_split() {
3567 use crossterm::event::KeyModifiers;
3568 let mut app = App::new();
3569 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3570 app.dismiss_splash();
3571 for _ in 0..3 {
3572 dispatch(
3573 &mut app,
3574 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
3575 );
3576 }
3577 app.selected_split = 1;
3579 let kept_id = app.detail_splits[1].0.clone();
3580 dispatch(
3581 &mut app,
3582 key_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
3583 );
3584 dispatch(&mut app, key(KeyCode::Char('o')));
3585 assert_eq!(app.detail_splits.len(), 1);
3586 assert_eq!(app.detail_splits[0].0, kept_id);
3587 assert_eq!(app.selected_split, 0);
3588 }
3589
3590 #[test]
3591 fn add_detail_split_saturates_at_four_with_explicit_4_and_5_calls() {
3592 let mut app = App::new();
3597 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3598 for _ in 0..4 {
3599 app.add_detail_split();
3600 }
3601 assert_eq!(app.detail_splits.len(), 4);
3602 let snapshot_len = app.detail_splits.len();
3603 app.add_detail_split();
3604 assert_eq!(app.detail_splits.len(), snapshot_len, "5th call rejected");
3605 }
3606
3607 #[test]
3608 fn replace_approvals_clamps_selection_in_range() {
3609 let mut app = App::new();
3610 app.replace_approvals(vec![ap(1), ap(2), ap(3)]);
3611 app.selected_approval = 2;
3612 app.replace_approvals(vec![ap(1), ap(2)]);
3614 assert_eq!(app.selected_approval, 1, "clamps to last index");
3615 }
3616
3617 #[test]
3618 fn arrow_keys_navigate_only_when_roster_focused() {
3619 let mut app = App::new();
3620 app.replace_team(fixture_team(vec![
3621 agent("p:a", AgentState::Running),
3622 agent("p:b", AgentState::Running),
3623 ]));
3624 app.dismiss_splash();
3625 app.selected_agent = Some(0);
3627 dispatch(&mut app, key(KeyCode::Down));
3628 assert_eq!(app.selected_agent, Some(1));
3629 app.cycle_focus();
3631 dispatch(&mut app, key(KeyCode::Down));
3632 assert_eq!(
3633 app.selected_agent,
3634 Some(1),
3635 "non-roster focus ignores arrows"
3636 );
3637 }
3638
3639 fn stream_keys_fixture() -> App {
3645 let mut app = App::new();
3646 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3647 app.dismiss_splash();
3648 app.cycle_focus(); assert_eq!(app.focused_pane, Pane::Detail);
3650 assert_eq!(app.selected_agent, Some(0));
3651 app
3652 }
3653
3654 fn stream_dispatch(
3655 app: &mut App,
3656 ev: Event,
3657 key_sender: &crate::keysender::test_support::MockKeySender,
3658 ) {
3659 super::handle_event(
3660 app,
3661 ev,
3662 &NoopDecider,
3663 &NoopSender,
3664 &EmptyMailbox,
3665 key_sender,
3666 );
3667 }
3668
3669 #[test]
3670 fn ctrl_e_enters_stream_keys_when_detail_focused() {
3671 use crate::keysender::test_support::MockKeySender;
3672 use crossterm::event::KeyModifiers;
3673 let mut app = stream_keys_fixture();
3674 let ks = MockKeySender::default();
3675 stream_dispatch(
3676 &mut app,
3677 key_with(KeyCode::Char('e'), KeyModifiers::CONTROL),
3678 &ks,
3679 );
3680 assert_eq!(app.stage, Stage::StreamKeys);
3681 assert!(
3682 ks.calls.lock().unwrap().is_empty(),
3683 "the activation chord itself never forwards a keystroke"
3684 );
3685 }
3686
3687 #[test]
3688 fn ctrl_e_no_op_when_detail_not_focused() {
3689 use crate::keysender::test_support::MockKeySender;
3694 use crossterm::event::KeyModifiers;
3695 let mut app = App::new();
3696 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3697 app.dismiss_splash();
3698 assert_eq!(app.focused_pane, Pane::Roster);
3699 let ks = MockKeySender::default();
3700 stream_dispatch(
3701 &mut app,
3702 key_with(KeyCode::Char('e'), KeyModifiers::CONTROL),
3703 &ks,
3704 );
3705 assert_eq!(app.stage, Stage::Triptych);
3706 }
3707
3708 #[test]
3709 fn ctrl_e_no_op_when_no_agent_selected() {
3710 use crate::keysender::test_support::MockKeySender;
3713 use crossterm::event::KeyModifiers;
3714 let mut app = App::new();
3715 app.dismiss_splash();
3716 app.cycle_focus(); assert_eq!(app.selected_agent, None);
3718 let ks = MockKeySender::default();
3719 stream_dispatch(
3720 &mut app,
3721 key_with(KeyCode::Char('e'), KeyModifiers::CONTROL),
3722 &ks,
3723 );
3724 assert_eq!(app.stage, Stage::Triptych);
3725 }
3726
3727 #[test]
3728 fn esc_forwards_to_pane_in_stream_keys() {
3729 use crate::keysender::test_support::MockKeySender;
3733 use crossterm::event::KeyModifiers;
3734 let mut app = stream_keys_fixture();
3735 app.enter_stream_keys();
3736 assert_eq!(app.stage, Stage::StreamKeys);
3737 let ks = MockKeySender::default();
3738 stream_dispatch(&mut app, key_with(KeyCode::Esc, KeyModifiers::NONE), &ks);
3739 assert_eq!(
3740 app.stage,
3741 Stage::StreamKeys,
3742 "Esc does NOT exit stream-keys"
3743 );
3744 let calls = ks.calls.lock().unwrap();
3745 assert_eq!(calls.len(), 1, "Esc forwards as one keystroke");
3746 assert_eq!(calls[0].0, "t-p-a");
3747 assert_eq!(calls[0].1.args, vec!["Escape".to_string()]);
3748 }
3749
3750 #[test]
3751 fn ctrl_e_exits_stream_keys() {
3752 use crate::keysender::test_support::MockKeySender;
3755 use crossterm::event::KeyModifiers;
3756 let mut app = stream_keys_fixture();
3757 app.enter_stream_keys();
3758 assert_eq!(app.stage, Stage::StreamKeys);
3759 let ks = MockKeySender::default();
3760 stream_dispatch(
3761 &mut app,
3762 key_with(KeyCode::Char('e'), KeyModifiers::CONTROL),
3763 &ks,
3764 );
3765 assert_eq!(app.stage, Stage::Triptych);
3766 assert!(
3767 ks.calls.lock().unwrap().is_empty(),
3768 "Ctrl+E is the exit chord — it must not forward as a keystroke"
3769 );
3770 }
3771
3772 #[test]
3773 fn stream_mode_forwards_printable_chars_to_target_session() {
3774 use crate::keysender::test_support::MockKeySender;
3775 let mut app = stream_keys_fixture();
3776 app.enter_stream_keys();
3777 let ks = MockKeySender::default();
3778 for c in "hi".chars() {
3779 stream_dispatch(&mut app, key(KeyCode::Char(c)), &ks);
3780 }
3781 let calls = ks.calls.lock().unwrap();
3782 assert_eq!(calls.len(), 2, "one tmux send-keys per keystroke");
3783 assert_eq!(calls[0].0, "t-p-a");
3786 assert_eq!(calls[0].1.args, vec!["-l".to_string(), "h".to_string()]);
3787 assert_eq!(calls[1].1.args, vec!["-l".to_string(), "i".to_string()]);
3788 }
3789
3790 #[test]
3791 fn stream_mode_passes_ctrl_c_through_to_agent() {
3792 use crate::keysender::test_support::MockKeySender;
3796 use crossterm::event::KeyModifiers;
3797 let mut app = stream_keys_fixture();
3798 app.enter_stream_keys();
3799 let ks = MockKeySender::default();
3800 stream_dispatch(
3801 &mut app,
3802 key_with(KeyCode::Char('c'), KeyModifiers::CONTROL),
3803 &ks,
3804 );
3805 assert_eq!(app.stage, Stage::StreamKeys, "Ctrl+C does NOT exit");
3806 let calls = ks.calls.lock().unwrap();
3807 assert_eq!(calls.len(), 1);
3808 assert_eq!(calls[0].1.args, vec!["C-c".to_string()]);
3809 }
3810
3811 #[test]
3812 fn stream_mode_forwards_enter_and_arrows() {
3813 use crate::keysender::test_support::MockKeySender;
3814 let mut app = stream_keys_fixture();
3815 app.enter_stream_keys();
3816 let ks = MockKeySender::default();
3817 stream_dispatch(&mut app, key(KeyCode::Enter), &ks);
3818 stream_dispatch(&mut app, key(KeyCode::Up), &ks);
3819 let calls = ks.calls.lock().unwrap();
3820 assert_eq!(calls[0].1.args, vec!["Enter".to_string()]);
3821 assert_eq!(calls[1].1.args, vec!["Up".to_string()]);
3822 }
3823
3824 #[test]
3825 fn stream_target_session_uses_focused_split_when_present() {
3826 let mut app = App::new();
3831 app.replace_team(fixture_team(vec![
3832 agent("p:a", AgentState::Running),
3833 agent("p:b", AgentState::Running),
3834 ]));
3835 app.dismiss_splash();
3836 app.cycle_focus(); app.selected_agent = Some(0);
3838 app.detail_splits
3840 .push(("p:b".into(), crate::app::SplitOrientation::Vertical));
3841 app.selected_split = 1; let target = app.stream_target_session();
3843 assert_eq!(
3844 target.as_deref(),
3845 Some("t-p-b"),
3846 "selected split's agent drives the target"
3847 );
3848 }
3849
3850 #[test]
3851 fn stream_mode_drops_back_when_target_session_disappears() {
3852 use crate::keysender::test_support::MockKeySender;
3857 let mut app = stream_keys_fixture();
3858 app.enter_stream_keys();
3859 app.selected_agent = None;
3861 app.team.agents.clear();
3862 let ks = MockKeySender::default();
3863 stream_dispatch(&mut app, key(KeyCode::Char('a')), &ks);
3864 assert_eq!(app.stage, Stage::Triptych);
3865 assert!(ks.calls.lock().unwrap().is_empty());
3866 }
3867
3868 #[test]
3871 fn recapture_focused_pane_sets_buffer_and_advances_clock() {
3872 use crate::pane::test_support::MockPaneSource;
3877 let mut app = App::new();
3878 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3879 app.dismiss_splash();
3880 assert_eq!(app.selected_agent, Some(0));
3881 let mock = MockPaneSource {
3882 lines: vec!["hello".into(), "world".into()],
3883 asked: std::sync::Mutex::new(Vec::new()),
3884 ..Default::default()
3885 };
3886 let before = Instant::now() - PANE_REFRESH_INTERVAL;
3888 app.last_pane_refresh = before;
3889
3890 super::recapture_focused_pane(&mut app, &mock);
3891
3892 assert_eq!(app.detail_buffer, vec!["hello", "world"]);
3893 assert_eq!(mock.asked.lock().unwrap().clone(), vec!["t-p-a"]);
3895 assert!(
3896 app.last_pane_refresh > before,
3897 "re-capture advances the fast-cadence clock"
3898 );
3899 }
3900
3901 #[test]
3902 fn recapture_focused_pane_no_op_when_no_agent_focused() {
3903 use crate::pane::test_support::MockPaneSource;
3906 let mut app = App::new();
3907 app.dismiss_splash();
3908 assert_eq!(app.selected_agent, None);
3909 let mock = MockPaneSource {
3910 lines: vec!["unused".into()],
3911 asked: std::sync::Mutex::new(Vec::new()),
3912 ..Default::default()
3913 };
3914
3915 super::recapture_focused_pane(&mut app, &mock);
3916
3917 assert!(
3918 mock.asked.lock().unwrap().is_empty(),
3919 "no focused agent → no capture call"
3920 );
3921 assert!(
3922 app.detail_buffer.is_empty(),
3923 "detail buffer untouched with no agent"
3924 );
3925 }
3926
3927 #[test]
3928 fn recapture_focused_pane_no_op_when_selection_cleared() {
3929 use crate::pane::test_support::MockPaneSource;
3932 let mut app = App::new();
3933 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3934 app.dismiss_splash();
3935 app.selected_agent = None;
3936 let mock = MockPaneSource {
3937 lines: vec!["unused".into()],
3938 asked: std::sync::Mutex::new(Vec::new()),
3939 ..Default::default()
3940 };
3941
3942 super::recapture_focused_pane(&mut app, &mock);
3943
3944 assert!(mock.asked.lock().unwrap().is_empty());
3945 assert!(app.detail_buffer.is_empty());
3946 }
3947
3948 #[test]
3949 fn recapture_focused_pane_skips_when_activity_unchanged() {
3950 use crate::pane::test_support::MockPaneSource;
3953 let mut app = App::new();
3954 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3955 app.dismiss_splash();
3956 let session = app.focused_session().unwrap().to_string();
3957 app.now_secs = 1_000.0;
3961
3962 let mock = MockPaneSource {
3963 lines: vec!["frame-1".into()],
3964 activity_ts: Some(100),
3965 ..Default::default()
3966 };
3967 super::recapture_focused_pane(&mut app, &mock);
3968 assert_eq!(app.detail_buffer, vec!["frame-1"]);
3969 assert_eq!(mock.asked.lock().unwrap().clone(), vec![session.clone()]);
3970 assert_eq!(app.detail_buffer_activity, Some((session.clone(), 100)));
3971
3972 let mock2 = MockPaneSource {
3974 lines: vec!["frame-2-should-not-appear".into()],
3975 activity_ts: Some(100),
3976 ..Default::default()
3977 };
3978 super::recapture_focused_pane(&mut app, &mock2);
3979 assert!(
3980 mock2.asked.lock().unwrap().is_empty(),
3981 "unchanged activity ts → capture skipped"
3982 );
3983 assert_eq!(app.detail_buffer, vec!["frame-1"], "buffer kept");
3984
3985 let mock3 = MockPaneSource {
3987 lines: vec!["frame-3".into()],
3988 activity_ts: Some(200),
3989 ..Default::default()
3990 };
3991 super::recapture_focused_pane(&mut app, &mock3);
3992 assert_eq!(app.detail_buffer, vec!["frame-3"]);
3993 assert_eq!(mock3.asked.lock().unwrap().clone(), vec![session.clone()]);
3994 assert_eq!(app.detail_buffer_activity, Some((session, 200)));
3995 }
3996
3997 #[test]
3998 fn recapture_focused_pane_recaptures_during_active_second() {
3999 use crate::pane::test_support::MockPaneSource;
4008 let mut app = App::new();
4009 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
4010 app.dismiss_splash();
4011 let session = app.focused_session().unwrap().to_string();
4012
4013 let active_second = 1_781_343_000u64;
4016 app.now_secs = active_second as f64;
4017 app.detail_buffer_activity = Some((session.clone(), active_second));
4018 app.set_detail_buffer(vec!["stale-within-second".into()]);
4019
4020 let mock = MockPaneSource {
4023 lines: vec!["live-frame".into()],
4024 activity_ts: Some(active_second),
4025 ..Default::default()
4026 };
4027 super::recapture_focused_pane(&mut app, &mock);
4028 assert_eq!(
4029 mock.asked.lock().unwrap().clone(),
4030 vec![session.clone()],
4031 "activity in the current second must re-capture, not freeze at ~1Hz"
4032 );
4033 assert_eq!(app.detail_buffer, vec!["live-frame"]);
4034
4035 app.now_secs = (active_second + 1) as f64;
4038 let mock_idle = MockPaneSource {
4039 lines: vec!["should-not-appear".into()],
4040 activity_ts: Some(active_second),
4041 ..Default::default()
4042 };
4043 super::recapture_focused_pane(&mut app, &mock_idle);
4044 assert!(
4045 mock_idle.asked.lock().unwrap().is_empty(),
4046 "settled activity ts → capture skipped"
4047 );
4048 assert_eq!(app.detail_buffer, vec!["live-frame"], "buffer kept");
4049 }
4050
4051 #[test]
4052 fn recapture_focused_pane_recaptures_on_focus_switch() {
4053 use crate::pane::test_support::MockPaneSource;
4059 let mut app = App::new();
4060 app.replace_team(fixture_team(vec![
4061 agent("p:a", AgentState::Running),
4062 agent("p:b", AgentState::Running),
4063 ]));
4064 app.dismiss_splash();
4065 app.now_secs = 1_000.0;
4068
4069 app.selected_agent = Some(0);
4070 let sess_a = app.focused_session().unwrap().to_string();
4071 let mock_a = MockPaneSource {
4072 lines: vec!["A-frame".into()],
4073 activity_ts: Some(100),
4074 ..Default::default()
4075 };
4076 super::recapture_focused_pane(&mut app, &mock_a);
4077 assert_eq!(app.detail_buffer, vec!["A-frame"]);
4078
4079 app.selected_agent = Some(1);
4082 let sess_b = app.focused_session().unwrap().to_string();
4083 assert_ne!(sess_a, sess_b);
4084 let mock_b = MockPaneSource {
4085 lines: vec!["B-frame".into()],
4086 activity_ts: Some(100),
4087 ..Default::default()
4088 };
4089 super::recapture_focused_pane(&mut app, &mock_b);
4090 assert_eq!(
4091 mock_b.asked.lock().unwrap().clone(),
4092 vec![sess_b.clone()],
4093 "focus switch must capture the newly focused session"
4094 );
4095 assert_eq!(
4096 app.detail_buffer,
4097 vec!["B-frame"],
4098 "buffer holds B's content, not stale A"
4099 );
4100 assert_eq!(app.detail_buffer_activity, Some((sess_b, 100)));
4101 }
4102
4103 #[test]
4104 fn recapture_focused_pane_captures_when_activity_unreadable() {
4105 use crate::pane::test_support::MockPaneSource;
4108 let mut app = App::new();
4109 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
4110 app.dismiss_splash();
4111 let mock = MockPaneSource {
4112 lines: vec!["x".into()],
4113 activity_ts: None,
4114 ..Default::default()
4115 };
4116 super::recapture_focused_pane(&mut app, &mock);
4117 super::recapture_focused_pane(&mut app, &mock);
4118 assert_eq!(
4119 mock.asked.lock().unwrap().len(),
4120 2,
4121 "unreadable ts → capture every call"
4122 );
4123 }
4124
4125 fn stream_keys_fixture_two_agents() -> App {
4131 let mut app = App::new();
4132 app.replace_team(fixture_team(vec![
4133 agent("p:a", AgentState::Running),
4134 agent("p:b", AgentState::Running),
4135 ]));
4136 app.dismiss_splash();
4137 app.cycle_focus(); assert_eq!(app.focused_pane, Pane::Detail);
4139 assert_eq!(app.selected_agent, Some(0));
4140 app.enter_stream_keys();
4141 assert_eq!(app.stage, Stage::StreamKeys);
4142 app
4143 }
4144
4145 #[test]
4146 fn ctrl_shift_down_moves_selection_to_next_agent_no_split() {
4147 use crate::keysender::test_support::MockKeySender;
4151 let mut app = stream_keys_fixture_two_agents();
4152 let ks = MockKeySender::default();
4153 stream_dispatch(
4154 &mut app,
4155 key_with(KeyCode::Down, KeyModifiers::CONTROL | KeyModifiers::SHIFT),
4156 &ks,
4157 );
4158 assert_eq!(app.selected_agent, Some(1), "switched to next agent");
4159 assert_eq!(app.stage, Stage::StreamKeys, "stays in stream-keys");
4160 assert!(
4161 ks.calls.lock().unwrap().is_empty(),
4162 "the switch chord never forwards a keystroke"
4163 );
4164 }
4165
4166 #[test]
4167 fn ctrl_shift_up_moves_selection_to_prev_agent_no_split() {
4168 use crate::keysender::test_support::MockKeySender;
4171 let mut app = stream_keys_fixture_two_agents();
4172 let ks = MockKeySender::default();
4173 stream_dispatch(
4174 &mut app,
4175 key_with(KeyCode::Up, KeyModifiers::CONTROL | KeyModifiers::SHIFT),
4176 &ks,
4177 );
4178 assert_eq!(
4179 app.selected_agent,
4180 Some(1),
4181 "Up from agent 0 wraps to the last agent"
4182 );
4183 assert_eq!(app.stage, Stage::StreamKeys);
4184 assert!(ks.calls.lock().unwrap().is_empty());
4185 }
4186
4187 #[test]
4188 fn ctrl_shift_switch_no_op_when_split_focused() {
4189 use crate::keysender::test_support::MockKeySender;
4196 for code in [KeyCode::Up, KeyCode::Down] {
4197 let mut app = stream_keys_fixture_two_agents();
4198 app.detail_splits
4201 .push(("p:b".into(), SplitOrientation::Vertical));
4202 app.selected_split = 1;
4203 let ks = MockKeySender::default();
4204 stream_dispatch(
4205 &mut app,
4206 key_with(code, KeyModifiers::CONTROL | KeyModifiers::SHIFT),
4207 &ks,
4208 );
4209 assert_eq!(
4210 app.selected_agent,
4211 Some(0),
4212 "split focused → selection must not move ({code:?})"
4213 );
4214 assert_eq!(app.stage, Stage::StreamKeys);
4215 assert!(
4216 ks.calls.lock().unwrap().is_empty(),
4217 "split-focused switch chord is consumed, not forwarded ({code:?})"
4218 );
4219 }
4220 }
4221
4222 #[test]
4223 fn ctrl_shift_switch_single_agent_is_no_op() {
4224 use crate::keysender::test_support::MockKeySender;
4228 let mut app = stream_keys_fixture(); app.enter_stream_keys();
4230 assert_eq!(app.selected_agent, Some(0));
4231 let ks = MockKeySender::default();
4232 stream_dispatch(
4233 &mut app,
4234 key_with(KeyCode::Down, KeyModifiers::CONTROL | KeyModifiers::SHIFT),
4235 &ks,
4236 );
4237 assert_eq!(app.selected_agent, Some(0), "single agent → stays at 0");
4238 assert_eq!(app.stage, Stage::StreamKeys);
4239 assert!(ks.calls.lock().unwrap().is_empty());
4240 }
4241
4242 fn pane_sync_fixture() -> App {
4245 let mut app = App::new();
4246 app.team = fixture_team(vec![
4247 agent("hello:mgr", AgentState::Running),
4248 agent("hello:dev", AgentState::Running),
4249 ]);
4250 app.selected_agent = Some(0);
4251 app.stage = Stage::Triptych;
4252 app.layout = MainLayout::Triptych;
4253 app
4254 }
4255
4256 #[test]
4257 fn sync_fires_resize_on_first_frame() {
4258 let mut app = pane_sync_fixture();
4259 let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
4260 sync_focused_pane_size_to(
4261 &mut app,
4262 ratatui::layout::Rect::new(0, 0, 120, 40),
4263 &resizer,
4264 );
4265 let calls = resizer.calls.lock().unwrap();
4266 assert_eq!(calls.len(), 1);
4269 assert_eq!(calls[0].0, "t-hello-mgr");
4270 assert_eq!(calls[0].1, 90); assert_eq!(calls[0].2, 22); }
4273
4274 #[test]
4275 fn sync_uses_taller_detail_in_stream_keys_mode() {
4276 let mut app = pane_sync_fixture();
4284 app.stage = Stage::StreamKeys;
4285 let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
4286 sync_focused_pane_size_to(
4287 &mut app,
4288 ratatui::layout::Rect::new(0, 0, 120, 40),
4289 &resizer,
4290 );
4291 let calls = resizer.calls.lock().unwrap();
4292 assert_eq!(calls.len(), 1);
4293 assert_eq!(calls[0].0, "t-hello-mgr");
4294 assert_eq!(calls[0].1, 90); assert_eq!(calls[0].2, 33); }
4297
4298 #[test]
4299 fn sync_skips_when_size_unchanged() {
4300 let mut app = pane_sync_fixture();
4301 let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
4302 sync_focused_pane_size_to(
4304 &mut app,
4305 ratatui::layout::Rect::new(0, 0, 120, 40),
4306 &resizer,
4307 );
4308 sync_focused_pane_size_to(
4309 &mut app,
4310 ratatui::layout::Rect::new(0, 0, 120, 40),
4311 &resizer,
4312 );
4313 assert_eq!(resizer.calls.lock().unwrap().len(), 1);
4314 }
4315
4316 #[test]
4317 fn sync_fires_again_when_terminal_resizes() {
4318 let mut app = pane_sync_fixture();
4319 let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
4320 sync_focused_pane_size_to(
4321 &mut app,
4322 ratatui::layout::Rect::new(0, 0, 120, 40),
4323 &resizer,
4324 );
4325 sync_focused_pane_size_to(
4327 &mut app,
4328 ratatui::layout::Rect::new(0, 0, 200, 60),
4329 &resizer,
4330 );
4331 let calls = resizer.calls.lock().unwrap();
4332 assert_eq!(calls.len(), 2);
4333 assert_eq!(calls[0].1, 90); assert_eq!(calls[0].2, 22); assert_eq!(calls[1].1, 170); assert_eq!(calls[1].2, 34);
4338 }
4339
4340 #[test]
4341 fn sync_fires_on_focus_switch_to_unsynced_session() {
4342 let mut app = pane_sync_fixture();
4343 let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
4344 sync_focused_pane_size_to(
4345 &mut app,
4346 ratatui::layout::Rect::new(0, 0, 120, 40),
4347 &resizer,
4348 );
4349 app.selected_agent = Some(1);
4351 sync_focused_pane_size_to(
4352 &mut app,
4353 ratatui::layout::Rect::new(0, 0, 120, 40),
4354 &resizer,
4355 );
4356 let calls = resizer.calls.lock().unwrap();
4357 assert_eq!(calls.len(), 2);
4358 assert_eq!(calls[0].0, "t-hello-mgr");
4359 assert_eq!(calls[1].0, "t-hello-dev");
4360 }
4361
4362 #[test]
4363 fn sync_is_noop_when_no_agent_focused() {
4364 let mut app = pane_sync_fixture();
4365 app.selected_agent = None;
4366 let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
4367 sync_focused_pane_size_to(
4368 &mut app,
4369 ratatui::layout::Rect::new(0, 0, 120, 40),
4370 &resizer,
4371 );
4372 assert!(resizer.calls.lock().unwrap().is_empty());
4373 }
4374
4375 #[test]
4376 fn sync_is_noop_when_layout_is_not_triptych() {
4377 let mut app = pane_sync_fixture();
4378 app.layout = MainLayout::Wall;
4379 let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
4380 sync_focused_pane_size_to(
4381 &mut app,
4382 ratatui::layout::Rect::new(0, 0, 120, 40),
4383 &resizer,
4384 );
4385 assert!(resizer.calls.lock().unwrap().is_empty());
4388 }
4389
4390 #[test]
4391 fn sync_is_noop_on_degenerate_terminal_area() {
4392 let mut app = pane_sync_fixture();
4393 let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
4394 sync_focused_pane_size_to(&mut app, ratatui::layout::Rect::new(0, 0, 28, 40), &resizer);
4396 assert!(resizer.calls.lock().unwrap().is_empty());
4397 }
4398
4399 #[test]
4400 fn sync_accounts_for_approvals_stripe_when_present() {
4401 let mut app = pane_sync_fixture();
4402 app.pending_approvals = vec![crate::approvals::Approval {
4404 id: 1,
4405 project_id: "hello".into(),
4406 agent_id: "hello:dev".into(),
4407 action: "test".into(),
4408 summary: "test approval".into(),
4409 payload_json: String::new(),
4410 }];
4411 assert!(app.has_pending_approvals());
4412 let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
4413 sync_focused_pane_size_to(
4414 &mut app,
4415 ratatui::layout::Rect::new(0, 0, 120, 40),
4416 &resizer,
4417 );
4418 let calls = resizer.calls.lock().unwrap();
4419 assert_eq!(calls.len(), 1);
4422 assert_eq!(calls[0].2, 21);
4423 }
4424
4425 fn app_with_mailbox_focused() -> App {
4431 let mut app = App::new();
4432 app.dismiss_splash();
4433 app.cycle_focus();
4435 app.cycle_focus();
4436 assert_eq!(app.focused_pane, Pane::Mailbox);
4437 app
4438 }
4439
4440 #[test]
4441 fn f_opens_filter_input_when_mailbox_focused() {
4442 let mut app = app_with_mailbox_focused();
4443 assert!(app.mailbox_input_mode.is_none());
4444 dispatch(&mut app, key(KeyCode::Char('f')));
4445 assert_eq!(app.mailbox_input_mode, Some(MailboxInputKind::Filter));
4446 }
4447
4448 #[test]
4449 fn slash_opens_search_input_when_mailbox_focused() {
4450 let mut app = app_with_mailbox_focused();
4451 dispatch(&mut app, key(KeyCode::Char('/')));
4452 assert_eq!(app.mailbox_input_mode, Some(MailboxInputKind::Search));
4453 }
4454
4455 #[test]
4456 fn f_does_not_open_filter_when_roster_focused() {
4457 let mut app = App::new();
4461 app.dismiss_splash();
4462 assert_eq!(app.focused_pane, Pane::Roster);
4463 dispatch(&mut app, key(KeyCode::Char('f')));
4464 assert!(app.mailbox_input_mode.is_none());
4465 }
4466
4467 #[test]
4468 fn typing_into_filter_input_mutates_active_tab_buffer() {
4469 let mut app = app_with_mailbox_focused();
4470 dispatch(&mut app, key(KeyCode::Char('f')));
4471 dispatch(&mut app, key(KeyCode::Char('a')));
4472 dispatch(&mut app, key(KeyCode::Char('d')));
4473 dispatch(&mut app, key(KeyCode::Char('a')));
4474 assert_eq!(app.mailbox.filter_text(app.mailbox_tab), "ada");
4475 assert_eq!(app.mailbox.filter_text(MailboxTab::Sent), "");
4477 }
4478
4479 #[test]
4480 fn backspace_pops_input_buffer() {
4481 let mut app = app_with_mailbox_focused();
4482 dispatch(&mut app, key(KeyCode::Char('/')));
4483 for c in "abc".chars() {
4484 dispatch(&mut app, key(KeyCode::Char(c)));
4485 }
4486 assert_eq!(app.mailbox.search_text(app.mailbox_tab), "abc");
4487 dispatch(&mut app, key(KeyCode::Backspace));
4488 assert_eq!(app.mailbox.search_text(app.mailbox_tab), "ab");
4489 }
4490
4491 #[test]
4492 fn enter_confirms_keeps_typed_text() {
4493 let mut app = app_with_mailbox_focused();
4494 dispatch(&mut app, key(KeyCode::Char('f')));
4495 for c in "kian".chars() {
4496 dispatch(&mut app, key(KeyCode::Char(c)));
4497 }
4498 dispatch(&mut app, key(KeyCode::Enter));
4499 assert!(
4500 app.mailbox_input_mode.is_none(),
4501 "input must close on Enter"
4502 );
4503 assert_eq!(
4504 app.mailbox.filter_text(app.mailbox_tab),
4505 "kian",
4506 "Enter must keep the typed text (confirm-keep semantics)"
4507 );
4508 }
4509
4510 #[test]
4511 fn esc_cancels_reverts_to_snapshot() {
4512 let mut app = app_with_mailbox_focused();
4513 app.mailbox
4515 .set_input(app.mailbox_tab, MailboxInputKind::Filter, "previous".into());
4516 dispatch(&mut app, key(KeyCode::Char('f')));
4517 dispatch(&mut app, key(KeyCode::Backspace));
4519 dispatch(&mut app, key(KeyCode::Backspace));
4520 dispatch(&mut app, key(KeyCode::Char('x')));
4521 assert_eq!(app.mailbox.filter_text(app.mailbox_tab), "previox");
4522 dispatch(&mut app, key(KeyCode::Esc));
4524 assert!(app.mailbox_input_mode.is_none());
4525 assert_eq!(
4526 app.mailbox.filter_text(app.mailbox_tab),
4527 "previous",
4528 "Esc must revert the active buffer to the pre-open snapshot"
4529 );
4530 }
4531
4532 #[test]
4533 fn open_input_swallows_pr1_cursor_keys() {
4534 let mut app = app_with_mailbox_focused();
4538 app.mailbox.extend(
4540 app.mailbox_tab,
4541 (1..=10)
4542 .map(|i| crate::mailbox::MessageRow {
4543 id: i,
4544 sender: "p:a".into(),
4545 recipient: "p:dev".into(),
4546 text: "x".into(),
4547 sent_at: 0.0,
4548 })
4549 .collect(),
4550 );
4551 let seated = app.mailbox.cursor(app.mailbox_tab).selected_idx;
4552 assert_eq!(seated, 9, "extend seats cursor at tail (PR-1 contract)");
4553 dispatch(&mut app, key(KeyCode::Char('f')));
4555 dispatch(&mut app, key(KeyCode::Up));
4556 dispatch(&mut app, key(KeyCode::PageUp));
4557 dispatch(&mut app, key(KeyCode::Home));
4558 assert_eq!(app.mailbox.cursor(app.mailbox_tab).selected_idx, 9);
4563 }
4564
4565 #[test]
4566 fn ctrl_modifier_char_does_not_inject_into_input() {
4567 let mut app = app_with_mailbox_focused();
4573 dispatch(&mut app, key(KeyCode::Char('f'))); dispatch(
4575 &mut app,
4576 key_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
4577 );
4578 dispatch(
4579 &mut app,
4580 key_with(KeyCode::Char('c'), KeyModifiers::CONTROL),
4581 );
4582 dispatch(&mut app, key_with(KeyCode::Char('a'), KeyModifiers::ALT));
4583 assert_eq!(
4584 app.mailbox.filter_text(app.mailbox_tab),
4585 "",
4586 "modifier+Char combos must not leak into the filter buffer"
4587 );
4588 dispatch(&mut app, key(KeyCode::Char('w')));
4591 assert_eq!(app.mailbox.filter_text(app.mailbox_tab), "w");
4592 dispatch(&mut app, key_with(KeyCode::Char('X'), KeyModifiers::SHIFT));
4595 assert_eq!(app.mailbox.filter_text(app.mailbox_tab), "wX");
4596 }
4597
4598 #[test]
4599 fn open_input_swallows_q_quit() {
4600 let mut app = app_with_mailbox_focused();
4605 dispatch(&mut app, key(KeyCode::Char('f')));
4606 dispatch(&mut app, key(KeyCode::Char('q')));
4607 assert_eq!(
4608 app.stage,
4609 Stage::Triptych,
4610 "q must NOT trigger quit while input is open"
4611 );
4612 assert_eq!(
4613 app.mailbox.filter_text(app.mailbox_tab),
4614 "q",
4615 "q must land in the filter buffer"
4616 );
4617 }
4618
4619 fn seed_inbox_rows(app: &mut App, n: i64) {
4623 let rows: Vec<MessageRow> = (1..=n)
4624 .map(|i| MessageRow {
4625 id: i,
4626 sender: "p:dev".into(),
4627 recipient: "p:mgr".into(),
4628 text: format!("body #{i}"),
4629 sent_at: 1_700_000_000.0 + i as f64,
4630 })
4631 .collect();
4632 app.mailbox.extend(MailboxTab::Inbox, rows);
4633 }
4634
4635 #[test]
4636 fn enter_on_mailbox_opens_detail_modal_with_snapshot() {
4637 let mut app = app_with_mailbox_focused();
4638 seed_inbox_rows(&mut app, 5);
4639 dispatch(&mut app, key(KeyCode::Enter));
4641 assert_eq!(app.stage, Stage::MailboxDetailModal);
4642 let snap = app.mailbox_detail_modal.as_ref().expect("modal open");
4643 assert_eq!(snap.id, 5);
4644 assert_eq!(snap.text, "body #5");
4645 assert_eq!(app.mailbox_detail_scroll, 0, "scroll resets on open");
4646 }
4647
4648 #[test]
4649 fn enter_on_empty_visible_indices_is_noop() {
4650 let mut app = app_with_mailbox_focused();
4652 seed_inbox_rows(&mut app, 3);
4653 app.mailbox.set_input(
4654 MailboxTab::Inbox,
4655 MailboxInputKind::Filter,
4656 "no-such-sender".into(),
4657 );
4658 assert!(app.mailbox.visible_indices(MailboxTab::Inbox).is_empty());
4659 dispatch(&mut app, key(KeyCode::Enter));
4660 assert_eq!(app.stage, Stage::Triptych);
4661 assert!(app.mailbox_detail_modal.is_none());
4662 }
4663
4664 #[test]
4665 fn snapshot_stable_across_underlying_drain() {
4666 let mut app = app_with_mailbox_focused();
4670 seed_inbox_rows(&mut app, 5);
4671 app.mailbox.cursor_home(MailboxTab::Inbox);
4672 app.mailbox.move_cursor_down(MailboxTab::Inbox);
4673 app.mailbox.move_cursor_down(MailboxTab::Inbox); dispatch(&mut app, key(KeyCode::Enter));
4675 let snap_id = app.mailbox_detail_modal.as_ref().expect("open").id;
4676 assert_eq!(snap_id, 3);
4677 let more: Vec<MessageRow> = (6..=600)
4680 .map(|i| MessageRow {
4681 id: i,
4682 sender: "p:dev".into(),
4683 recipient: "p:mgr".into(),
4684 text: format!("body #{i}"),
4685 sent_at: 1_700_000_000.0 + i as f64,
4686 })
4687 .collect();
4688 app.mailbox.extend(MailboxTab::Inbox, more);
4689 let still_there = app
4691 .mailbox
4692 .rows(MailboxTab::Inbox)
4693 .iter()
4694 .any(|r| r.id == 3);
4695 assert!(!still_there, "row id 3 must have been drained");
4696 let snap = app.mailbox_detail_modal.as_ref().expect("still open");
4699 assert_eq!(snap.id, 3, "snapshot id must survive underlying drain");
4700 assert_eq!(snap.text, "body #3");
4701 }
4702
4703 #[test]
4704 fn esc_closes_detail_modal() {
4705 let mut app = app_with_mailbox_focused();
4706 seed_inbox_rows(&mut app, 3);
4707 dispatch(&mut app, key(KeyCode::Enter));
4708 assert_eq!(app.stage, Stage::MailboxDetailModal);
4709 dispatch(&mut app, key(KeyCode::Esc));
4710 assert_eq!(app.stage, Stage::Triptych);
4711 assert!(app.mailbox_detail_modal.is_none());
4712 }
4713
4714 #[test]
4715 fn q_closes_detail_modal() {
4716 let mut app = app_with_mailbox_focused();
4717 seed_inbox_rows(&mut app, 3);
4718 dispatch(&mut app, key(KeyCode::Enter));
4719 dispatch(&mut app, key(KeyCode::Char('q')));
4720 assert_eq!(app.stage, Stage::Triptych);
4721 assert!(app.mailbox_detail_modal.is_none());
4722 }
4723
4724 #[test]
4725 fn j_and_k_scroll_body_in_modal() {
4726 let mut app = app_with_mailbox_focused();
4727 seed_inbox_rows(&mut app, 3);
4728 dispatch(&mut app, key(KeyCode::Enter));
4729 assert_eq!(app.mailbox_detail_scroll, 0);
4730 dispatch(&mut app, key(KeyCode::Char('j')));
4731 dispatch(&mut app, key(KeyCode::Char('j')));
4732 dispatch(&mut app, key(KeyCode::Down));
4733 assert_eq!(app.mailbox_detail_scroll, 3);
4734 dispatch(&mut app, key(KeyCode::Char('k')));
4735 dispatch(&mut app, key(KeyCode::Up));
4736 assert_eq!(app.mailbox_detail_scroll, 1);
4737 for _ in 0..10 {
4739 dispatch(&mut app, key(KeyCode::Char('k')));
4740 }
4741 assert_eq!(app.mailbox_detail_scroll, 0);
4742 }
4743
4744 #[test]
4745 fn unrelated_keys_swallowed_in_modal() {
4746 let mut app = app_with_mailbox_focused();
4749 seed_inbox_rows(&mut app, 3);
4750 dispatch(&mut app, key(KeyCode::Enter));
4751 assert_eq!(app.stage, Stage::MailboxDetailModal);
4752 let focused_before = app.focused_pane;
4753 dispatch(&mut app, key(KeyCode::Char('f')));
4754 dispatch(&mut app, key(KeyCode::Char('/')));
4755 dispatch(&mut app, key(KeyCode::Tab));
4756 assert_eq!(app.stage, Stage::MailboxDetailModal, "stage stays");
4757 assert!(app.mailbox_input_mode.is_none(), "filter/search not opened");
4758 assert_eq!(
4759 app.focused_pane, focused_before,
4760 "Tab must not cycle panes underneath an open modal"
4761 );
4762 }
4763}