1use std::time::{Duration, Instant};
12
13use anyhow::Result;
14use crossterm::event::{self, Event, KeyCode, KeyEventKind};
15use ratatui::backend::Backend;
16use ratatui::buffer::Buffer;
17use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
18use ratatui::style::{Modifier, Style};
19use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget, Wrap};
20use ratatui::{Frame, Terminal};
21
22use crate::approvals::{
23 Approval, ApprovalDecider, ApprovalSource, BrokerApprovalSource, CliApprovalDecider, Decision,
24};
25use crate::compose::{CliMessageSender, ComposeTarget, Editor, EditorAction, MessageSender};
26use crate::data::TeamSnapshot;
27use crate::keysender::{encode_key, KeySender, ScrollDirection, TmuxKeySender};
28use crate::layouts;
29use crate::mailbox::{
30 BrokerMailboxSource, MailboxBuffers, MailboxInputKind, MailboxSource, MailboxTab, MessageRow,
31};
32use crate::pane::{PaneSource, TmuxPaneSource};
33use crate::splash;
34use crate::status_bar;
35use crate::statusline;
36use crate::theme::{detect_capabilities, Capabilities};
37use crate::triptych::{self, MainLayout, Pane};
38use crate::tutorial;
39use crate::watch::Watch;
40
41const SPLASH_AUTO_DISMISS: Duration = Duration::from_secs(3);
42const POLL_INTERVAL: Duration = Duration::from_millis(50);
43const REFRESH_INTERVAL: Duration = Duration::from_secs(1);
46const PANE_REFRESH_INTERVAL: Duration = Duration::from_millis(100);
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum Stage {
54 Splash,
55 Triptych,
56 QuitConfirm,
57 ApprovalsModal,
62 ComposeModal,
67 HelpOverlay,
70 Tutorial,
75 StreamKeys,
83 MailboxDetailModal,
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum SplitOrientation {
101 Vertical,
102 Horizontal,
103}
104
105pub struct App {
106 pub stage: Stage,
107 pub previous_stage: Stage,
109 pub focused_pane: Pane,
110 pub team: TeamSnapshot,
111 pub selected_agent: Option<usize>,
115 pub detail_buffer: Vec<String>,
119 pub version: &'static str,
120 pub capabilities: Capabilities,
121 pub splash_started: Instant,
122 pub last_refresh: Instant,
125 pub last_pane_refresh: Instant,
128 pub running: bool,
129 pub tutorial_completed: bool,
133 pub mailbox_tab: MailboxTab,
141 pub mailbox: MailboxBuffers,
145 pub mailbox_input_mode: Option<MailboxInputKind>,
152 pub mailbox_input_snapshot: String,
156 pub mailbox_detail_modal: Option<MessageRow>,
165 pub mailbox_detail_scroll: u16,
170 pub now_secs: f64,
180 pub pending_approvals: Vec<Approval>,
183 pub selected_approval: usize,
187 pub approval_error: Option<String>,
191 pub compose_target: Option<ComposeTarget>,
195 pub compose_editor: Editor,
199 pub compose_error: Option<String>,
203 pub layout: MainLayout,
206 pub wall_scroll: usize,
210 pub selected_channel: Option<usize>,
214 pub detail_splits: Vec<(String, SplitOrientation)>,
220 pub selected_split: usize,
221 pub pending_chord: Option<KeyCode>,
227 pub tutorial_pending_for_team: bool,
231 pub spinner_frame: usize,
234 pub tutorial_step: usize,
237 pub compose_picker_open: bool,
242 pub compose_picker_index: usize,
244 pub compose_attach_input_open: bool,
251 pub compose_attach_buffer: String,
254 pub last_synced_pane_sizes: std::collections::HashMap<String, (u16, u16)>,
262 pub sysinfo: sysinfo::System,
270 pub rate_limit_indicator_enabled: bool,
278}
279
280const MAX_DETAIL_LINES: usize = 2000;
281
282impl App {
283 pub fn new() -> Self {
289 Self {
290 stage: Stage::Splash,
291 previous_stage: Stage::Splash,
292 focused_pane: Pane::Roster,
293 team: TeamSnapshot::empty(std::path::PathBuf::new()),
294 selected_agent: None,
295 detail_buffer: Vec::new(),
296 version: env!("CARGO_PKG_VERSION"),
297 capabilities: detect_capabilities(),
298 splash_started: Instant::now(),
299 last_refresh: Instant::now() - REFRESH_INTERVAL,
300 last_pane_refresh: Instant::now(),
301 running: true,
302 tutorial_completed: tutorial::is_completed(),
303 mailbox_tab: MailboxTab::Inbox,
304 mailbox: MailboxBuffers::default(),
305 mailbox_input_mode: None,
306 mailbox_input_snapshot: String::new(),
307 mailbox_detail_modal: None,
308 mailbox_detail_scroll: 0,
309 now_secs: 0.0,
310 pending_approvals: Vec::new(),
311 selected_approval: 0,
312 approval_error: None,
313 compose_target: None,
314 compose_editor: Editor::default(),
315 compose_error: None,
316 layout: MainLayout::Triptych,
317 wall_scroll: 0,
318 selected_channel: None,
319 detail_splits: Vec::new(),
320 selected_split: 0,
321 compose_picker_open: false,
322 compose_picker_index: 0,
323 compose_attach_input_open: false,
324 compose_attach_buffer: String::new(),
325 pending_chord: None,
326 tutorial_pending_for_team: false,
327 spinner_frame: 0,
328 tutorial_step: 0,
329 last_synced_pane_sizes: std::collections::HashMap::new(),
330 sysinfo: sysinfo::System::new(),
336 rate_limit_indicator_enabled: std::env::var_os("TEAMCTL_UI_RATE_LIMIT_INDICATOR")
344 .is_some(),
345 }
346 }
347
348 pub fn enter_help_overlay(&mut self) {
351 self.previous_stage = self.stage;
352 self.stage = Stage::HelpOverlay;
353 }
354 pub fn close_help_overlay(&mut self) {
355 self.stage = self.previous_stage;
356 }
357 pub fn enter_tutorial(&mut self) {
358 self.previous_stage = self.stage;
359 self.stage = Stage::Tutorial;
360 self.tutorial_step = 0;
361 }
362 pub fn close_tutorial(&mut self) {
363 self.stage = self.previous_stage;
364 self.tutorial_pending_for_team = false;
365 if !self.team.root.as_os_str().is_empty() {
366 let _ = crate::onboarding::mark_completed(&self.team.root);
367 }
368 }
369 pub fn tutorial_advance(&mut self) {
370 let len = crate::onboarding::STEPS.len();
371 if len == 0 {
372 self.close_tutorial();
373 return;
374 }
375 if self.tutorial_step + 1 >= len {
376 self.close_tutorial();
377 } else {
378 self.tutorial_step += 1;
379 }
380 }
381 pub fn tutorial_back(&mut self) {
382 self.tutorial_step = self.tutorial_step.saturating_sub(1);
383 }
384
385 pub fn toggle_wall_layout(&mut self) {
386 self.layout = self.layout.toggle_wall();
387 }
388 pub fn toggle_mailbox_first_layout(&mut self) {
389 self.layout = self.layout.toggle_mailbox_first();
390 if matches!(self.layout, MainLayout::MailboxFirst) && self.selected_channel.is_none() {
393 self.selected_channel = if self.team.channels.is_empty() {
394 None
395 } else {
396 Some(0)
397 };
398 }
399 }
400 pub fn wall_scroll_up(&mut self) {
401 self.wall_scroll = self
402 .wall_scroll
403 .saturating_sub(crate::layouts::WALL_TILE_CAP);
404 }
405 pub fn wall_scroll_down(&mut self) {
406 let next = self.wall_scroll + crate::layouts::WALL_TILE_CAP;
407 if next < self.team.agents.len() {
408 self.wall_scroll = next;
409 }
410 }
411 pub fn select_next_channel(&mut self) {
412 if self.team.channels.is_empty() {
413 return;
414 }
415 self.selected_channel = Some(match self.selected_channel {
416 None => 0,
417 Some(i) => (i + 1) % self.team.channels.len(),
418 });
419 }
420 pub fn select_prev_channel(&mut self) {
421 if self.team.channels.is_empty() {
422 return;
423 }
424 self.selected_channel = Some(match self.selected_channel {
425 None | Some(0) => self.team.channels.len() - 1,
426 Some(i) => i - 1,
427 });
428 }
429
430 pub fn add_detail_split_vertical(&mut self) {
434 self.add_detail_split_with_orientation(SplitOrientation::Vertical);
435 }
436 pub fn add_detail_split_horizontal(&mut self) {
438 self.add_detail_split_with_orientation(SplitOrientation::Horizontal);
439 }
440 fn add_detail_split_with_orientation(&mut self, orientation: SplitOrientation) {
441 let Some(id) = self.selected_agent_id() else {
442 return;
443 };
444 if self.detail_splits.len() >= 4 {
445 return;
446 }
447 self.detail_splits.push((id, orientation));
448 self.selected_split = self.detail_splits.len() - 1;
449 }
450 pub fn add_detail_split(&mut self) {
455 self.add_detail_split_vertical();
456 }
457 pub fn close_focused_split(&mut self) {
458 if self.detail_splits.is_empty() {
459 return;
460 }
461 let i = self.selected_split.min(self.detail_splits.len() - 1);
462 self.detail_splits.remove(i);
463 self.selected_split = i.saturating_sub(1);
464 }
465 pub fn cycle_split_next(&mut self) {
466 if self.detail_splits.is_empty() {
467 return;
468 }
469 self.selected_split = (self.selected_split + 1) % self.detail_splits.len();
470 }
471 pub fn cycle_split_prev(&mut self) {
472 if self.detail_splits.is_empty() {
473 return;
474 }
475 self.selected_split = if self.selected_split == 0 {
476 self.detail_splits.len() - 1
477 } else {
478 self.selected_split - 1
479 };
480 }
481
482 pub fn enter_compose_broadcast_with_picker(&mut self) {
487 if self.team.channels.is_empty() {
488 self.enter_compose_broadcast();
492 return;
493 }
494 let project_id = self
495 .team
496 .channels
497 .first()
498 .map(|c| c.project_id.clone())
499 .unwrap_or_default();
500 self.previous_stage = self.stage;
501 self.stage = Stage::ComposeModal;
502 self.compose_target = Some(ComposeTarget::Broadcast {
503 channel_id: format!("{project_id}:all"),
504 project_id,
505 });
506 self.compose_editor = Editor::default();
507 self.compose_error = None;
508 self.compose_picker_open = true;
509 self.compose_picker_index = 0;
510 }
511 pub fn picker_next(&mut self) {
512 if self.team.channels.is_empty() {
513 return;
514 }
515 self.compose_picker_index = (self.compose_picker_index + 1) % self.team.channels.len();
516 }
517 pub fn picker_prev(&mut self) {
518 if self.team.channels.is_empty() {
519 return;
520 }
521 self.compose_picker_index = if self.compose_picker_index == 0 {
522 self.team.channels.len() - 1
523 } else {
524 self.compose_picker_index - 1
525 };
526 }
527 pub fn picker_confirm(&mut self) {
528 if let Some(ch) = self.team.channels.get(self.compose_picker_index) {
529 self.compose_target = Some(ComposeTarget::Broadcast {
530 channel_id: ch.id.clone(),
531 project_id: ch.project_id.clone(),
532 });
533 }
534 self.compose_picker_open = false;
535 }
536
537 pub fn open_compose_attach_input(&mut self) {
540 self.compose_attach_input_open = true;
541 self.compose_attach_buffer.clear();
542 }
543
544 pub fn confirm_compose_attach_input(&mut self) {
550 let path = self.compose_attach_buffer.trim().to_string();
551 if !path.is_empty() {
552 let marker = format!("📎 attachment: {path}");
553 if let Some(last) = self.compose_editor.lines.last_mut() {
558 if !last.is_empty() {
559 self.compose_editor.lines.push(marker);
560 } else {
561 *last = marker;
562 }
563 } else {
564 self.compose_editor.lines.push(marker);
565 }
566 self.compose_editor.cursor_row = self.compose_editor.lines.len() - 1;
569 self.compose_editor.cursor_col = self
570 .compose_editor
571 .lines
572 .last()
573 .map(|l| l.len())
574 .unwrap_or(0);
575 }
576 self.close_compose_attach_input();
577 }
578
579 pub fn close_compose_attach_input(&mut self) {
580 self.compose_attach_input_open = false;
581 self.compose_attach_buffer.clear();
582 }
583
584 pub fn cycle_mailbox_tab(&mut self) {
585 self.mailbox_tab = self.mailbox_tab.next();
586 }
587
588 pub fn cycle_mailbox_tab_back(&mut self) {
589 self.mailbox_tab = self.mailbox_tab.prev();
590 }
591
592 pub fn mailbox_cursor_down(&mut self) {
597 self.mailbox.move_cursor_down(self.mailbox_tab);
598 }
599
600 pub fn mailbox_cursor_up(&mut self) {
601 self.mailbox.move_cursor_up(self.mailbox_tab);
602 }
603
604 pub fn mailbox_page_down(&mut self) {
605 self.mailbox.page_cursor_down(self.mailbox_tab);
606 }
607
608 pub fn mailbox_page_up(&mut self) {
609 self.mailbox.page_cursor_up(self.mailbox_tab);
610 }
611
612 pub fn mailbox_cursor_home(&mut self) {
613 self.mailbox.cursor_home(self.mailbox_tab);
614 }
615
616 pub fn mailbox_cursor_end(&mut self) {
617 self.mailbox.cursor_end(self.mailbox_tab);
618 }
619
620 pub fn open_mailbox_filter_input(&mut self) {
627 self.mailbox_input_snapshot = self.mailbox.filter_text(self.mailbox_tab).to_string();
628 self.mailbox_input_mode = Some(MailboxInputKind::Filter);
629 }
630
631 pub fn open_mailbox_search_input(&mut self) {
634 self.mailbox_input_snapshot = self.mailbox.search_text(self.mailbox_tab).to_string();
635 self.mailbox_input_mode = Some(MailboxInputKind::Search);
636 }
637
638 pub fn mailbox_input_push_char(&mut self, c: char) {
641 if let Some(kind) = self.mailbox_input_mode {
642 self.mailbox.input_push_char(self.mailbox_tab, kind, c);
643 }
644 }
645
646 pub fn mailbox_input_pop_char(&mut self) {
648 if let Some(kind) = self.mailbox_input_mode {
649 self.mailbox.input_pop_char(self.mailbox_tab, kind);
650 }
651 }
652
653 pub fn mailbox_input_confirm(&mut self) {
655 self.mailbox_input_mode = None;
656 self.mailbox_input_snapshot.clear();
657 }
658
659 pub fn mailbox_input_cancel(&mut self) {
663 if let Some(kind) = self.mailbox_input_mode {
664 let snapshot = std::mem::take(&mut self.mailbox_input_snapshot);
665 self.mailbox.set_input(self.mailbox_tab, kind, snapshot);
666 }
667 self.mailbox_input_mode = None;
668 self.mailbox_input_snapshot.clear();
669 }
670
671 pub fn open_mailbox_detail_modal(&mut self) {
681 let tab = self.mailbox_tab;
682 let visible = self.mailbox.visible_indices(tab);
683 if visible.is_empty() {
684 return;
685 }
686 let idx = self.mailbox.cursor(tab).selected_idx.min(visible.len() - 1);
687 let row_idx = visible[idx];
688 let row = self.mailbox.rows(tab).get(row_idx).cloned();
689 if let Some(row) = row {
690 self.mailbox_detail_modal = Some(row);
691 self.mailbox_detail_scroll = 0;
692 self.stage = Stage::MailboxDetailModal;
693 }
694 }
695
696 pub fn close_mailbox_detail_modal(&mut self) {
699 self.mailbox_detail_modal = None;
700 self.mailbox_detail_scroll = 0;
701 self.stage = Stage::Triptych;
702 }
703
704 pub fn mailbox_detail_scroll_down(&mut self) {
708 self.mailbox_detail_scroll = self.mailbox_detail_scroll.saturating_add(1);
713 }
714
715 pub fn mailbox_detail_scroll_up(&mut self) {
717 self.mailbox_detail_scroll = self.mailbox_detail_scroll.saturating_sub(1);
718 }
719
720 pub fn cycle_focus_back(&mut self) {
721 self.focused_pane = self.focused_pane.prev();
722 }
723
724 pub fn has_pending_approvals(&self) -> bool {
725 !self.pending_approvals.is_empty()
726 }
727
728 pub fn enter_approvals_modal(&mut self) {
729 if self.pending_approvals.is_empty() {
730 return;
731 }
732 self.previous_stage = self.stage;
733 self.stage = Stage::ApprovalsModal;
734 self.selected_approval = 0;
735 self.approval_error = None;
736 }
737
738 pub fn close_approvals_modal(&mut self) {
739 self.stage = self.previous_stage;
740 self.approval_error = None;
741 }
742
743 pub fn cycle_approval_next(&mut self) {
744 if self.pending_approvals.is_empty() {
745 return;
746 }
747 self.selected_approval = (self.selected_approval + 1) % self.pending_approvals.len();
748 }
749
750 pub fn cycle_approval_prev(&mut self) {
751 if self.pending_approvals.is_empty() {
752 return;
753 }
754 self.selected_approval = if self.selected_approval == 0 {
755 self.pending_approvals.len() - 1
756 } else {
757 self.selected_approval - 1
758 };
759 }
760
761 pub fn focused_approval(&self) -> Option<&Approval> {
762 self.pending_approvals.get(self.selected_approval)
763 }
764
765 pub fn replace_approvals(&mut self, approvals: Vec<Approval>) {
771 self.pending_approvals = approvals;
772 if self.pending_approvals.is_empty() {
773 if matches!(self.stage, Stage::ApprovalsModal) {
774 self.close_approvals_modal();
775 }
776 self.selected_approval = 0;
777 } else if self.selected_approval >= self.pending_approvals.len() {
778 self.selected_approval = self.pending_approvals.len() - 1;
779 }
780 }
781
782 pub fn apply_decision<D: ApprovalDecider>(&mut self, decider: &D, kind: Decision, note: &str) {
789 let Some(approval) = self.focused_approval().cloned() else {
790 return;
791 };
792 match decider.decide(&self.team.root, approval.id, kind, note) {
793 Ok(()) => {
794 self.pending_approvals.retain(|a| a.id != approval.id);
795 self.approval_error = None;
796 if self.pending_approvals.is_empty() {
797 self.close_approvals_modal();
798 } else if self.selected_approval >= self.pending_approvals.len() {
799 self.selected_approval = self.pending_approvals.len() - 1;
800 }
801 }
802 Err(err) => {
803 self.approval_error = Some(err.to_string());
804 }
805 }
806 }
807
808 pub fn enter_compose_dm_for_focused(&mut self) {
811 let Some(info) = self
812 .selected_agent
813 .and_then(|i| self.team.agents.get(i))
814 .cloned()
815 else {
816 return;
817 };
818 self.previous_stage = self.stage;
819 self.stage = Stage::ComposeModal;
820 self.compose_target = Some(ComposeTarget::Dm {
821 agent_id: info.id.clone(),
822 project_id: info.project.clone(),
823 });
824 self.compose_editor = Editor::default();
825 self.compose_error = None;
826 }
827
828 pub fn enter_compose_broadcast(&mut self) {
836 let project_id = self
837 .selected_agent
838 .and_then(|i| self.team.agents.get(i))
839 .map(|a| a.project.clone())
840 .or_else(|| self.team.agents.first().map(|a| a.project.clone()));
841 let Some(project_id) = project_id else {
842 return;
843 };
844 let channel_id = format!("{project_id}:all");
845 self.previous_stage = self.stage;
846 self.stage = Stage::ComposeModal;
847 self.compose_target = Some(ComposeTarget::Broadcast {
848 channel_id,
849 project_id,
850 });
851 self.compose_editor = Editor::default();
852 self.compose_error = None;
853 }
854
855 pub fn close_compose_modal(&mut self) {
856 self.stage = self.previous_stage;
857 self.compose_target = None;
858 self.compose_editor = Editor::default();
859 self.compose_error = None;
860 self.compose_attach_input_open = false;
863 self.compose_attach_buffer.clear();
864 }
865
866 pub fn apply_send<S: MessageSender, M: MailboxSource>(
872 &mut self,
873 sender: &S,
874 mailbox_source: &M,
875 ) {
876 let Some(target) = self.compose_target.clone() else {
877 return;
878 };
879 let body = self.compose_editor.body();
880 if body.is_empty() {
881 self.compose_error = Some("body is empty".into());
882 return;
883 }
884 let result = match &target {
885 ComposeTarget::Dm { agent_id, .. } => sender.send_dm(&self.team.root, agent_id, &body),
886 ComposeTarget::Broadcast { channel_id, .. } => {
887 sender.broadcast(&self.team.root, channel_id, &body)
888 }
889 };
890 match result {
891 Ok(()) => {
892 self.close_compose_modal();
893 refresh_mailbox(self, mailbox_source);
896 }
897 Err(err) => {
898 self.compose_error = Some(err.to_string());
899 }
900 }
901 }
902
903 pub fn dismiss_splash(&mut self) {
904 if matches!(self.stage, Stage::Splash) {
905 self.stage = Stage::Triptych;
906 self.previous_stage = Stage::Triptych;
907 }
908 }
909
910 pub fn cycle_focus(&mut self) {
911 self.focused_pane = self.focused_pane.next();
912 }
913
914 pub fn select_prev(&mut self) {
920 if self.team.agents.is_empty() {
921 self.selected_agent = None;
922 return;
923 }
924 let prior = self.selected_agent_id();
925 self.selected_agent = Some(match self.selected_agent {
926 None | Some(0) => self.team.agents.len() - 1,
927 Some(i) => i - 1,
928 });
929 if prior != self.selected_agent_id() {
930 self.mailbox.reset();
931 }
932 }
933
934 pub fn select_next(&mut self) {
937 if self.team.agents.is_empty() {
938 self.selected_agent = None;
939 return;
940 }
941 let prior = self.selected_agent_id();
942 self.selected_agent = Some(match self.selected_agent {
943 None => 0,
944 Some(i) => (i + 1) % self.team.agents.len(),
945 });
946 if prior != self.selected_agent_id() {
947 self.mailbox.reset();
948 }
949 }
950
951 pub fn selected_agent_id(&self) -> Option<String> {
953 self.selected_agent
954 .and_then(|i| self.team.agents.get(i))
955 .map(|a| a.id.clone())
956 }
957
958 pub fn enter_quit_confirm(&mut self) {
959 self.previous_stage = self.stage;
960 self.stage = Stage::QuitConfirm;
961 }
962
963 pub fn cancel_quit(&mut self) {
964 self.stage = self.previous_stage;
965 }
966
967 pub fn confirm_quit(&mut self) {
968 self.running = false;
969 }
970
971 pub fn replace_team(&mut self, team: TeamSnapshot) {
978 let prior_id = self.selected_agent_id();
979 self.team = team;
980 self.selected_agent = match (prior_id.clone(), self.team.agents.is_empty()) {
981 (_, true) => None,
982 (Some(id), false) => self.team.agents.iter().position(|a| a.id == id).or(Some(0)),
983 (None, false) => Some(0),
984 };
985 if prior_id != self.selected_agent_id() {
986 self.mailbox.reset();
987 }
988 }
989
990 pub fn focused_session(&self) -> Option<&str> {
993 self.selected_agent
994 .and_then(|i| self.team.agents.get(i))
995 .map(|a| a.tmux_session.as_str())
996 }
997
998 pub fn stream_target_session(&self) -> Option<String> {
1005 if self.detail_splits.is_empty() || self.selected_split == 0 {
1006 return self.focused_session().map(|s| s.to_string());
1007 }
1008 let split_idx = self.selected_split - 1;
1009 let agent_id = self.detail_splits.get(split_idx).map(|(id, _)| id)?;
1010 self.team
1011 .agents
1012 .iter()
1013 .find(|a| &a.id == agent_id)
1014 .map(|a| a.tmux_session.clone())
1015 }
1016
1017 pub fn enter_stream_keys(&mut self) {
1022 if self.stream_target_session().is_none() {
1023 return;
1024 }
1025 self.previous_stage = self.stage;
1026 self.stage = Stage::StreamKeys;
1027 }
1028
1029 pub fn exit_stream_keys(&mut self) {
1033 self.stage = self.previous_stage;
1034 }
1035
1036 pub fn set_detail_buffer(&mut self, lines: Vec<String>) {
1038 let len = lines.len();
1039 let start = len.saturating_sub(MAX_DETAIL_LINES);
1040 self.detail_buffer = lines[start..].to_vec();
1041 }
1042}
1043
1044impl Default for App {
1045 fn default() -> Self {
1046 Self::new()
1047 }
1048}
1049
1050pub fn refresh<P: PaneSource, M: MailboxSource, A: ApprovalSource>(
1055 app: &mut App,
1056 pane_source: &P,
1057 mailbox_source: &M,
1058 approval_source: &A,
1059) {
1060 if let Ok(Some(snapshot)) = TeamSnapshot::discover_and_load() {
1061 app.replace_team(snapshot);
1062 }
1063 if let Some(session) = app.focused_session().map(|s| s.to_string()) {
1064 if let Ok(lines) = pane_source.capture(&session) {
1065 app.set_detail_buffer(lines);
1066 }
1067 } else {
1068 app.detail_buffer.clear();
1069 }
1070 refresh_mailbox(app, mailbox_source);
1071 refresh_approvals(app, approval_source);
1072 app.last_refresh = Instant::now();
1073 app.last_pane_refresh = Instant::now();
1074}
1075
1076pub fn refresh_approvals<A: ApprovalSource>(app: &mut App, approval_source: &A) {
1082 let approvals = approval_source.pending().unwrap_or_default();
1083 app.replace_approvals(approvals);
1084}
1085
1086pub fn refresh_mailbox<M: MailboxSource>(app: &mut App, mailbox_source: &M) {
1091 let Some(agent_id) = app.selected_agent_id() else {
1092 return;
1095 };
1096 let project_id = app
1097 .selected_agent
1098 .and_then(|i| app.team.agents.get(i))
1099 .map(|a| a.project.clone())
1100 .unwrap_or_default();
1101 if let Ok(batch) = mailbox_source.inbox(&agent_id, app.mailbox.inbox_after) {
1102 app.mailbox.extend(MailboxTab::Inbox, batch);
1103 }
1104 if let Ok(batch) = mailbox_source.sent(&agent_id, app.mailbox.sent_after) {
1105 app.mailbox.extend(MailboxTab::Sent, batch);
1106 }
1107 if let Ok(batch) = mailbox_source.channel_feed(&agent_id, app.mailbox.channel_after) {
1108 app.mailbox.extend(MailboxTab::Channel, batch);
1109 }
1110 if let Ok(batch) = mailbox_source.wire(&project_id, app.mailbox.wire_after) {
1111 app.mailbox.extend(MailboxTab::Wire, batch);
1112 }
1113}
1114
1115pub fn run<B: Backend>(terminal: &mut Terminal<B>) -> Result<()> {
1116 let mut app = App::new();
1117 let pane_source = TmuxPaneSource;
1118 let decider = CliApprovalDecider;
1119 let sender = CliMessageSender;
1120 let key_sender = TmuxKeySender;
1121 let pane_resizer = crate::pane_resize::TmuxPaneResizer;
1122 refresh_with_default_sources(&mut app, &pane_source);
1125 let mut watch = Watch::try_new(&app.team.root.join("state"));
1126 while app.running {
1127 app.now_secs = chrono::Utc::now().timestamp() as f64;
1132 terminal.draw(|f| draw(f, &app))?;
1133 let term_sz = terminal.size()?;
1141 let term_area = ratatui::layout::Rect::new(0, 0, term_sz.width, term_sz.height);
1142 sync_focused_pane_size_to(&mut app, term_area, &pane_resizer);
1143 if event::poll(POLL_INTERVAL)? {
1144 let db_path = app.team.root.join("state/mailbox.db");
1148 let mailbox_source = BrokerMailboxSource::new(db_path);
1149 handle_event(
1150 &mut app,
1151 event::read()?,
1152 &decider,
1153 &sender,
1154 &mailbox_source,
1155 &key_sender,
1156 );
1157 }
1158 if matches!(app.stage, Stage::Splash) && app.splash_started.elapsed() >= SPLASH_AUTO_DISMISS
1159 {
1160 app.dismiss_splash();
1161 }
1162 let dirty = watch.take_dirty();
1169 if dirty || app.last_refresh.elapsed() >= REFRESH_INTERVAL {
1170 let prior_root = app.team.root.clone();
1171 refresh_with_default_sources(&mut app, &pane_source);
1172 if app.team.root != prior_root {
1175 watch = Watch::try_new(&app.team.root.join("state"));
1176 }
1177 } else if app.last_pane_refresh.elapsed() >= PANE_REFRESH_INTERVAL {
1178 recapture_focused_pane(&mut app, &pane_source);
1181 }
1182 }
1183 Ok(())
1184}
1185
1186fn recapture_focused_pane<P: PaneSource>(app: &mut App, pane_source: &P) {
1191 if let Some(session) = app.focused_session().map(|s| s.to_string()) {
1192 if let Ok(lines) = pane_source.capture(&session) {
1193 app.set_detail_buffer(lines);
1194 }
1195 }
1196 app.last_pane_refresh = Instant::now();
1197}
1198
1199pub fn sync_focused_pane_size_to<R: crate::pane_resize::PaneResizer>(
1214 app: &mut App,
1215 total_area: ratatui::layout::Rect,
1216 resizer: &R,
1217) {
1218 if !matches!(app.layout, MainLayout::Triptych) {
1219 return;
1220 }
1221 let Some(detail) =
1222 crate::pane_resize::triptych_detail_area(total_area, app.has_pending_approvals())
1223 else {
1224 return;
1225 };
1226 let Some(session) = app.focused_session().map(|s| s.to_string()) else {
1227 return;
1228 };
1229 let inner_w = detail.width.saturating_sub(2);
1236 let inner_h = detail.height.saturating_sub(2);
1237 if inner_w == 0 || inner_h == 0 {
1238 return;
1239 }
1240 let target = (inner_w, inner_h);
1241 if !crate::pane_resize::should_sync(&app.last_synced_pane_sizes, &session, target) {
1242 return;
1243 }
1244 resizer.resize(&session, target.0, target.1);
1245 app.last_synced_pane_sizes.insert(session, target);
1246}
1247
1248fn refresh_with_default_sources<P: PaneSource>(app: &mut App, pane_source: &P) {
1253 if let Ok(Some(snapshot)) = TeamSnapshot::discover_and_load() {
1254 app.replace_team(snapshot);
1255 }
1256 let db_path = app.team.root.join("state/mailbox.db");
1257 let mailbox_source = BrokerMailboxSource::new(db_path.clone());
1258 let approval_source = BrokerApprovalSource::new(db_path);
1259 if let Some(session) = app.focused_session().map(|s| s.to_string()) {
1260 if let Ok(lines) = pane_source.capture(&session) {
1261 app.set_detail_buffer(lines);
1262 }
1263 } else {
1264 app.detail_buffer.clear();
1265 }
1266 refresh_mailbox(app, &mailbox_source);
1267 refresh_approvals(app, &approval_source);
1268 app.sysinfo.refresh_cpu_usage();
1274 app.sysinfo.refresh_memory();
1275 app.last_refresh = Instant::now();
1276 app.last_pane_refresh = Instant::now();
1277}
1278
1279pub fn draw(f: &mut Frame<'_>, app: &App) {
1280 let area = f.area();
1281 match app.stage {
1282 Stage::Splash => splash::draw(f, app),
1283 Stage::Triptych => draw_main(f, area, app),
1284 Stage::StreamKeys => draw_main(f, area, app),
1289 Stage::QuitConfirm => {
1290 draw_main(f, area, app);
1291 draw_quit_confirm(f, area);
1292 }
1293 Stage::ApprovalsModal => {
1294 draw_main(f, area, app);
1295 draw_approvals_modal(f, area, app);
1296 }
1297 Stage::ComposeModal => {
1298 draw_main(f, area, app);
1299 draw_compose_modal(f, area, app);
1300 }
1301 Stage::HelpOverlay => {
1302 draw_main(f, area, app);
1303 let buf = f.buffer_mut();
1304 render_help_overlay(area, buf, app);
1305 }
1306 Stage::Tutorial => {
1307 draw_main(f, area, app);
1308 let buf = f.buffer_mut();
1309 render_tutorial(area, buf, app);
1310 }
1311 Stage::MailboxDetailModal => {
1312 draw_main(f, area, app);
1313 let buf = f.buffer_mut();
1314 render_mailbox_detail_modal(area, buf, app);
1315 }
1316 }
1317}
1318
1319fn render_help_overlay(area: Rect, buf: &mut Buffer, app: &App) {
1320 let popup_w = 70u16.min(area.width.saturating_sub(4));
1321 let popup_h = 24u16.min(area.height.saturating_sub(2));
1322 let popup = centered_rect(popup_w, popup_h, area);
1323 Clear.render(popup, buf);
1324 let block = Block::default()
1325 .title("help · ? to close")
1326 .borders(Borders::ALL)
1327 .border_style(Style::default().fg(app.capabilities.accent()));
1328 let inner = block.inner(popup);
1329 block.render(popup, buf);
1330 let muted = Style::default().fg(app.capabilities.muted());
1331 let bold = Style::default().add_modifier(Modifier::BOLD);
1332 let mut lines: Vec<ratatui::text::Line<'_>> = Vec::new();
1333 for group in crate::help::ALL_GROUPS {
1334 lines.push(ratatui::text::Line::styled(group.title, bold));
1335 for b in group.bindings {
1336 lines.push(ratatui::text::Line::raw(format!(
1337 " {:<22} {}",
1338 b.chord, b.description
1339 )));
1340 }
1341 lines.push(ratatui::text::Line::styled("", muted));
1342 }
1343 Paragraph::new(lines).render(inner, buf);
1344}
1345
1346fn render_mailbox_detail_modal(area: Rect, buf: &mut Buffer, app: &App) {
1354 let Some(row) = app.mailbox_detail_modal.as_ref() else {
1355 return;
1356 };
1357 let popup_w = 80u16.min(area.width.saturating_sub(4));
1358 let popup_h = 24u16.min(area.height.saturating_sub(2));
1359 let popup = centered_rect(popup_w, popup_h, area);
1360 Clear.render(popup, buf);
1361 let title = format!("MESSAGE · id {} · Esc/q to close", row.id);
1362 let block = Block::default()
1363 .title(title)
1364 .borders(Borders::ALL)
1365 .border_style(Style::default().fg(app.capabilities.accent()));
1366 let inner = block.inner(popup);
1367 block.render(popup, buf);
1368 if inner.height == 0 {
1369 return;
1370 }
1371
1372 const META_LINES: u16 = 6;
1377 let meta_h = META_LINES.min(inner.height);
1378 let body_h = inner.height.saturating_sub(meta_h);
1379 let meta_area = Rect {
1380 x: inner.x,
1381 y: inner.y,
1382 width: inner.width,
1383 height: meta_h,
1384 };
1385 let body_area = Rect {
1386 x: inner.x,
1387 y: inner.y + meta_h,
1388 width: inner.width,
1389 height: body_h,
1390 };
1391
1392 let ts = chrono::DateTime::<chrono::Utc>::from_timestamp(
1397 row.sent_at as i64,
1398 ((row.sent_at.fract() * 1_000_000_000.0) as u32).min(999_999_999),
1399 )
1400 .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
1401 .unwrap_or_else(|| "—".to_string());
1402
1403 let muted = Style::default().fg(app.capabilities.muted());
1404 let meta_lines = vec![
1405 ratatui::text::Line::raw(format!("from: {}", row.sender)),
1406 ratatui::text::Line::raw(format!("to: {}", row.recipient)),
1407 ratatui::text::Line::raw(format!("kind: {}", crate::mailbox::kind_label(row))),
1408 ratatui::text::Line::raw(format!("time: {ts}")),
1409 ratatui::text::Line::raw(format!(
1410 "transport: {}",
1411 crate::mailbox::transport_label(row)
1412 )),
1413 ratatui::text::Line::styled("", muted),
1414 ];
1415 Paragraph::new(meta_lines)
1416 .style(Style::default())
1417 .render(meta_area, buf);
1418
1419 Paragraph::new(row.text.clone())
1425 .wrap(Wrap { trim: false })
1426 .scroll((app.mailbox_detail_scroll, 0))
1427 .render(body_area, buf);
1428}
1429
1430fn render_tutorial(area: Rect, buf: &mut Buffer, app: &App) {
1431 let popup_w = 64u16.min(area.width.saturating_sub(4));
1432 let popup_h = 14u16.min(area.height.saturating_sub(2));
1433 let popup = centered_rect(popup_w, popup_h, area);
1434 Clear.render(popup, buf);
1435 let total = crate::onboarding::STEPS.len();
1436 let i = app.tutorial_step.min(total.saturating_sub(1));
1437 let step = &crate::onboarding::STEPS[i];
1438 let block = Block::default()
1439 .title(format!("tutorial · {}/{total}", i + 1))
1440 .borders(Borders::ALL)
1441 .border_style(Style::default().fg(app.capabilities.accent()));
1442 let inner = block.inner(popup);
1443 block.render(popup, buf);
1444 let muted = Style::default().fg(app.capabilities.muted());
1445 let lines = vec![
1446 ratatui::text::Line::styled(step.heading, Style::default().add_modifier(Modifier::BOLD)),
1447 ratatui::text::Line::raw(""),
1448 ratatui::text::Line::raw(step.body),
1449 ratatui::text::Line::raw(""),
1450 ratatui::text::Line::styled("any key next · k / ↑ / p back · Esc skip", muted),
1451 ];
1452 Paragraph::new(lines)
1458 .wrap(ratatui::widgets::Wrap { trim: true })
1459 .render(inner, buf);
1460}
1461
1462fn draw_main(f: &mut Frame<'_>, area: Rect, app: &App) {
1463 let chunks = Layout::default()
1468 .direction(Direction::Vertical)
1469 .constraints([
1470 Constraint::Min(3),
1471 Constraint::Length(1), Constraint::Length(1), ])
1474 .split(area);
1475 let buf = f.buffer_mut();
1476 match app.layout {
1477 crate::triptych::MainLayout::Triptych => {
1478 triptych::Triptych { app }.render(chunks[0], buf);
1479 }
1480 crate::triptych::MainLayout::Wall => {
1481 layouts::Wall { app }.render(chunks[0], buf);
1482 }
1483 crate::triptych::MainLayout::MailboxFirst => {
1484 layouts::MailboxFirst { app }.render(chunks[0], buf);
1485 }
1486 }
1487 statusline::Statusline { app }.render(chunks[1], buf);
1488 status_bar::StatusBar { app }.render(chunks[2], buf);
1489}
1490
1491fn draw_approvals_modal(f: &mut Frame<'_>, area: Rect, app: &App) {
1492 let buf = f.buffer_mut();
1493 render_approvals_modal(area, buf, app);
1494}
1495
1496fn draw_compose_modal(f: &mut Frame<'_>, area: Rect, app: &App) {
1497 let buf = f.buffer_mut();
1498 render_compose_modal(area, buf, app);
1499}
1500
1501fn render_compose_picker_body(inner: Rect, buf: &mut Buffer, app: &App) {
1502 let muted = Style::default().fg(app.capabilities.muted());
1503 let chunks = Layout::default()
1504 .direction(Direction::Vertical)
1505 .constraints([
1506 Constraint::Min(1),
1507 Constraint::Length(1),
1508 Constraint::Length(1),
1509 ])
1510 .split(inner);
1511 let lines: Vec<ratatui::text::Line<'_>> = if app.team.channels.is_empty() {
1512 vec![ratatui::text::Line::styled(
1513 "(no channels declared in team-compose)",
1514 muted,
1515 )]
1516 } else {
1517 app.team
1518 .channels
1519 .iter()
1520 .enumerate()
1521 .map(|(i, ch)| {
1522 let label = format!(" #{} ({})", ch.name, ch.project_id);
1523 let style = if i == app.compose_picker_index {
1524 Style::default()
1525 .fg(app.capabilities.accent())
1526 .add_modifier(Modifier::REVERSED)
1527 } else {
1528 Style::default()
1529 };
1530 ratatui::text::Line::styled(label, style)
1531 })
1532 .collect()
1533 };
1534 Paragraph::new(lines).render(chunks[0], buf);
1535 Paragraph::new("pick a channel to broadcast to")
1536 .style(muted)
1537 .render(chunks[1], buf);
1538 Paragraph::new("Enter pick · j/k navigate · Esc cancel")
1539 .style(muted)
1540 .render(chunks[2], buf);
1541}
1542
1543fn render_compose_modal(area: Rect, buf: &mut Buffer, app: &App) {
1544 let popup_w = 80u16.min(area.width.saturating_sub(4));
1545 let popup_h = 16u16.min(area.height.saturating_sub(2));
1546 let popup = centered_rect(popup_w, popup_h, area);
1547 Clear.render(popup, buf);
1548 let title = app
1549 .compose_target
1550 .as_ref()
1551 .map(|t| t.title(&app.team))
1552 .unwrap_or_else(|| "→ ?".into());
1553 let block = Block::default()
1554 .title(title)
1555 .borders(Borders::ALL)
1556 .border_style(Style::default().fg(app.capabilities.accent()));
1557 let inner = block.inner(popup);
1558 block.render(popup, buf);
1559
1560 if inner.height < 3 {
1561 return;
1562 }
1563 if app.compose_picker_open {
1567 render_compose_picker_body(inner, buf, app);
1568 return;
1569 }
1570 if app.compose_attach_input_open {
1571 render_compose_attach_input(inner, buf, app);
1572 return;
1573 }
1574 let chunks = Layout::default()
1577 .direction(Direction::Vertical)
1578 .constraints([
1579 Constraint::Min(1), Constraint::Length(1), Constraint::Length(1), ])
1583 .split(inner);
1584
1585 let muted = Style::default().fg(app.capabilities.muted());
1590 let body_lines: Vec<ratatui::text::Line<'_>> = app
1591 .compose_editor
1592 .lines
1593 .iter()
1594 .enumerate()
1595 .map(|(row, line)| {
1596 if row == app.compose_editor.cursor_row
1597 && app.compose_editor.mode == crate::compose::VimMode::Insert
1598 {
1599 let col = app.compose_editor.cursor_col.min(line.len());
1600 let (head, tail) = line.split_at(col);
1601 ratatui::text::Line::from(vec![
1602 ratatui::text::Span::raw(head.to_string()),
1603 ratatui::text::Span::styled(
1604 "▏",
1605 Style::default().fg(app.capabilities.accent()),
1606 ),
1607 ratatui::text::Span::raw(tail.to_string()),
1608 ])
1609 } else {
1610 ratatui::text::Line::raw(line.clone())
1611 }
1612 })
1613 .collect();
1614 Paragraph::new(body_lines).render(chunks[0], buf);
1615
1616 let error_line = match (&app.compose_error, app.compose_editor.mode) {
1617 (Some(e), _) => format!("error: {e}"),
1618 (None, crate::compose::VimMode::Ex) => format!(":{}", app.compose_editor.ex_buffer),
1619 (None, crate::compose::VimMode::Normal) => "-- NORMAL --".into(),
1620 (None, crate::compose::VimMode::Insert) => "-- INSERT --".into(),
1621 };
1622 let style = if app.compose_error.is_some() {
1623 Style::default().fg(app.capabilities.accent())
1624 } else {
1625 muted
1626 };
1627 Paragraph::new(error_line)
1628 .style(style)
1629 .render(chunks[1], buf);
1630
1631 Paragraph::new("Esc Enter send · Esc Esc cancel · Tab attach")
1632 .style(muted)
1633 .render(chunks[2], buf);
1634}
1635
1636fn render_compose_attach_input(inner: Rect, buf: &mut Buffer, app: &App) {
1641 let muted = Style::default().fg(app.capabilities.muted());
1642 let chunks = Layout::default()
1643 .direction(Direction::Vertical)
1644 .constraints([
1645 Constraint::Min(1),
1646 Constraint::Length(1),
1647 Constraint::Length(1),
1648 ])
1649 .split(inner);
1650 let line = ratatui::text::Line::from(vec![
1651 ratatui::text::Span::raw(format!("path: {}", app.compose_attach_buffer)),
1652 ratatui::text::Span::styled("▏", Style::default().fg(app.capabilities.accent())),
1653 ]);
1654 Paragraph::new(line).render(chunks[0], buf);
1655 Paragraph::new("type or paste an absolute path; the agent reads it via the broker")
1656 .style(muted)
1657 .render(chunks[1], buf);
1658 Paragraph::new("Enter confirm · Esc cancel")
1659 .style(muted)
1660 .render(chunks[2], buf);
1661}
1662
1663fn render_approvals_modal(area: Rect, buf: &mut Buffer, app: &App) {
1664 let popup_w = 80u16.min(area.width.saturating_sub(4));
1665 let popup_h = 18u16.min(area.height.saturating_sub(2));
1666 let popup = centered_rect(popup_w, popup_h, area);
1667 Clear.render(popup, buf);
1668 let n = app.pending_approvals.len();
1669 let i = app.selected_approval.min(n.saturating_sub(1));
1670 let title = format!("approvals · {}/{n}", i + 1);
1671 let block = Block::default()
1672 .title(title)
1673 .borders(Borders::ALL)
1674 .border_style(Style::default().fg(app.capabilities.accent()));
1675 let inner = block.inner(popup);
1676 block.render(popup, buf);
1677
1678 let muted = Style::default().fg(app.capabilities.muted());
1679 let bold = Style::default().add_modifier(Modifier::BOLD);
1680
1681 let Some(a) = app.focused_approval() else {
1682 Paragraph::new("(no pending approvals)")
1683 .style(muted)
1684 .alignment(Alignment::Center)
1685 .render(inner, buf);
1686 return;
1687 };
1688
1689 let mut lines: Vec<ratatui::text::Line<'_>> = vec![
1690 ratatui::text::Line::styled(format!("#{} {}", a.id, a.action), bold),
1691 ratatui::text::Line::styled(
1692 format!("from: {}", crate::data::agent_label(&app.team, &a.agent_id)),
1693 muted,
1694 ),
1695 ratatui::text::Line::raw(""),
1696 ratatui::text::Line::raw(a.summary.clone()),
1697 ];
1698 if !a.payload_json.is_empty() && a.payload_json != "{}" {
1699 lines.push(ratatui::text::Line::raw(""));
1700 lines.push(ratatui::text::Line::styled("payload:", muted));
1701 for chunk in a.payload_json.lines().take(4) {
1702 lines.push(ratatui::text::Line::raw(chunk.to_string()));
1703 }
1704 }
1705 if let Some(err) = &app.approval_error {
1706 lines.push(ratatui::text::Line::raw(""));
1707 lines.push(ratatui::text::Line::styled(
1708 format!("error: {err}"),
1709 Style::default().fg(app.capabilities.accent()),
1710 ));
1711 }
1712 lines.push(ratatui::text::Line::raw(""));
1713 lines.push(ratatui::text::Line::styled(
1714 "[y] approve · [Shift-N] deny · [j/k] cycle · [Esc] close",
1715 muted,
1716 ));
1717 Paragraph::new(lines).render(inner, buf);
1718}
1719
1720fn draw_quit_confirm(f: &mut Frame<'_>, area: Rect) {
1721 let popup_w = 36u16.min(area.width.saturating_sub(2));
1722 let popup_h = 5u16.min(area.height.saturating_sub(2));
1723 let popup = centered_rect(popup_w, popup_h, area);
1724 let buf = f.buffer_mut();
1725 Clear.render(popup, buf);
1726 Paragraph::new("Quit teamctl-ui? [y / n]")
1727 .alignment(Alignment::Center)
1728 .block(Block::default().borders(Borders::ALL).title("confirm"))
1729 .render(popup, buf);
1730}
1731
1732fn centered_rect(w: u16, h: u16, area: Rect) -> Rect {
1733 let x = area.x + area.width.saturating_sub(w) / 2;
1734 let y = area.y + area.height.saturating_sub(h) / 2;
1735 Rect {
1736 x,
1737 y,
1738 width: w,
1739 height: h,
1740 }
1741}
1742
1743pub fn handle_event<D: ApprovalDecider, S: MessageSender, M: MailboxSource, K: KeySender>(
1744 app: &mut App,
1745 ev: Event,
1746 decider: &D,
1747 sender: &S,
1748 mailbox_source: &M,
1749 key_sender: &K,
1750) {
1751 use crossterm::event::KeyModifiers;
1752 match ev {
1753 Event::Key(k) if k.kind == KeyEventKind::Press => match app.stage {
1754 Stage::Splash => app.dismiss_splash(),
1755 Stage::Triptych => match k.code {
1756 KeyCode::Enter if app.mailbox_input_mode.is_some() => app.mailbox_input_confirm(),
1765 KeyCode::Esc if app.mailbox_input_mode.is_some() => app.mailbox_input_cancel(),
1766 KeyCode::Backspace if app.mailbox_input_mode.is_some() => {
1767 app.mailbox_input_pop_char()
1768 }
1769 KeyCode::Char(c)
1777 if app.mailbox_input_mode.is_some()
1778 && (k.modifiers.is_empty() || k.modifiers == KeyModifiers::SHIFT) =>
1779 {
1780 app.mailbox_input_push_char(c)
1781 }
1782 _ if app.mailbox_input_mode.is_some() => {}
1783
1784 KeyCode::Char('q') if app.pending_chord == Some(KeyCode::Char('w')) => {
1789 app.pending_chord = None;
1790 app.close_focused_split();
1791 }
1792 KeyCode::Char('o') if app.pending_chord == Some(KeyCode::Char('w')) => {
1793 app.pending_chord = None;
1794 if !app.detail_splits.is_empty() {
1795 let keep = app.selected_split.min(app.detail_splits.len() - 1);
1796 let kept = app.detail_splits.remove(keep);
1797 app.detail_splits.clear();
1798 app.detail_splits.push(kept);
1799 app.selected_split = 0;
1800 }
1801 }
1802 KeyCode::Char('q') if k.modifiers.is_empty() => app.enter_quit_confirm(),
1803 KeyCode::Char('a') => app.enter_approvals_modal(),
1807 KeyCode::Char('@') => app.enter_compose_dm_for_focused(),
1812 KeyCode::Char('!') => app.enter_compose_broadcast_with_picker(),
1813 KeyCode::Char('w') | KeyCode::Char('W')
1823 if k.modifiers.contains(KeyModifiers::CONTROL)
1824 && !app.detail_splits.is_empty() =>
1825 {
1826 app.pending_chord = Some(KeyCode::Char('w'))
1827 }
1828 KeyCode::Char('w') | KeyCode::Char('W')
1833 if k.modifiers.contains(KeyModifiers::CONTROL) =>
1834 {
1835 app.toggle_wall_layout()
1836 }
1837 KeyCode::Char('m') | KeyCode::Char('M')
1838 if k.modifiers.contains(KeyModifiers::CONTROL) =>
1839 {
1840 app.toggle_mailbox_first_layout()
1841 }
1842 KeyCode::Char('|') if k.modifiers.contains(KeyModifiers::CONTROL) => {
1846 app.add_detail_split_vertical()
1847 }
1848 KeyCode::Char('-') if k.modifiers.contains(KeyModifiers::CONTROL) => {
1849 app.add_detail_split_horizontal()
1850 }
1851 KeyCode::Char('h')
1856 | KeyCode::Char('H')
1857 | KeyCode::Char('k')
1858 | KeyCode::Char('K')
1859 if k.modifiers.contains(KeyModifiers::CONTROL) =>
1860 {
1861 app.cycle_split_prev()
1862 }
1863 KeyCode::Char('l')
1864 | KeyCode::Char('L')
1865 | KeyCode::Char('j')
1866 | KeyCode::Char('J')
1867 if k.modifiers.contains(KeyModifiers::CONTROL) =>
1868 {
1869 app.cycle_split_next()
1870 }
1871 KeyCode::Char('q') | KeyCode::Char('Q')
1876 if k.modifiers.contains(KeyModifiers::CONTROL) =>
1877 {
1878 app.close_focused_split()
1879 }
1880 KeyCode::Char('e') | KeyCode::Char('E')
1888 if k.modifiers.contains(KeyModifiers::CONTROL)
1889 && app.focused_pane == Pane::Detail =>
1890 {
1891 app.enter_stream_keys()
1892 }
1893 KeyCode::Char('?')
1901 if k.modifiers.is_empty() || k.modifiers == KeyModifiers::SHIFT =>
1902 {
1903 app.enter_help_overlay()
1904 }
1905 KeyCode::Char('t') if k.modifiers.is_empty() => app.enter_tutorial(),
1906 KeyCode::BackTab => app.cycle_focus_back(),
1910 KeyCode::Tab if k.modifiers.contains(KeyModifiers::SHIFT) => app.cycle_focus_back(),
1911 KeyCode::Tab => app.cycle_focus(),
1919 KeyCode::Right if app.focused_pane == Pane::Mailbox => app.cycle_mailbox_tab(),
1926 KeyCode::Left if app.focused_pane == Pane::Mailbox => app.cycle_mailbox_tab_back(),
1927 KeyCode::Up | KeyCode::Char('k') if matches!(app.layout, MainLayout::Wall) => {
1932 app.wall_scroll_up()
1933 }
1934 KeyCode::Down | KeyCode::Char('j') if matches!(app.layout, MainLayout::Wall) => {
1935 app.wall_scroll_down()
1936 }
1937 KeyCode::Up | KeyCode::Char('k')
1940 if matches!(app.layout, MainLayout::MailboxFirst) =>
1941 {
1942 app.select_prev_channel()
1943 }
1944 KeyCode::Down | KeyCode::Char('j')
1945 if matches!(app.layout, MainLayout::MailboxFirst) =>
1946 {
1947 app.select_next_channel()
1948 }
1949 KeyCode::Up | KeyCode::Char('k') if app.focused_pane == Pane::Mailbox => {
1962 app.mailbox_cursor_up()
1963 }
1964 KeyCode::Down | KeyCode::Char('j') if app.focused_pane == Pane::Mailbox => {
1965 app.mailbox_cursor_down()
1966 }
1967 KeyCode::PageUp if app.focused_pane == Pane::Mailbox => app.mailbox_page_up(),
1968 KeyCode::PageDown if app.focused_pane == Pane::Mailbox => app.mailbox_page_down(),
1969 KeyCode::Home if app.focused_pane == Pane::Mailbox => app.mailbox_cursor_home(),
1970 KeyCode::End if app.focused_pane == Pane::Mailbox => app.mailbox_cursor_end(),
1971 KeyCode::Char('f') if app.focused_pane == Pane::Mailbox => {
1978 app.open_mailbox_filter_input()
1979 }
1980 KeyCode::Char('/') if app.focused_pane == Pane::Mailbox => {
1981 app.open_mailbox_search_input()
1982 }
1983 KeyCode::Enter if app.focused_pane == Pane::Mailbox => {
1994 app.open_mailbox_detail_modal()
1995 }
1996 KeyCode::Up | KeyCode::Char('k') if app.focused_pane == Pane::Roster => {
2000 app.select_prev()
2001 }
2002 KeyCode::Down | KeyCode::Char('j') if app.focused_pane == Pane::Roster => {
2003 app.select_next()
2004 }
2005 _ => {}
2006 },
2007 Stage::QuitConfirm => match k.code {
2008 KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => app.confirm_quit(),
2009 KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => app.cancel_quit(),
2010 _ => {}
2011 },
2012 Stage::ApprovalsModal => match k.code {
2013 KeyCode::Char('y') | KeyCode::Char('Y') => {
2022 app.apply_decision(decider, Decision::Approve, "")
2023 }
2024 KeyCode::Char('N') => app.apply_decision(decider, Decision::Deny, ""),
2025 KeyCode::Char('j') | KeyCode::Down => app.cycle_approval_next(),
2026 KeyCode::Char('k') | KeyCode::Up => app.cycle_approval_prev(),
2027 KeyCode::Esc | KeyCode::Char('q') => app.close_approvals_modal(),
2028 _ => {}
2029 },
2030 Stage::ComposeModal => {
2031 if app.compose_picker_open {
2035 match k.code {
2036 KeyCode::Down | KeyCode::Char('j') => app.picker_next(),
2037 KeyCode::Up | KeyCode::Char('k') => app.picker_prev(),
2038 KeyCode::Enter => app.picker_confirm(),
2039 KeyCode::Esc => {
2048 app.compose_picker_open = false;
2049 app.compose_picker_index = 0;
2050 }
2051 _ => {}
2052 }
2053 } else if app.compose_attach_input_open {
2054 match k.code {
2061 KeyCode::Char(c) => app.compose_attach_buffer.push(c),
2062 KeyCode::Backspace => {
2063 app.compose_attach_buffer.pop();
2064 }
2065 KeyCode::Enter => app.confirm_compose_attach_input(),
2066 KeyCode::Esc => app.close_compose_attach_input(),
2067 _ => {}
2068 }
2069 } else if k.code == KeyCode::Tab {
2070 app.open_compose_attach_input();
2075 } else {
2076 match app.compose_editor.apply_key(k) {
2079 EditorAction::Continue => {}
2080 EditorAction::Send => app.apply_send(sender, mailbox_source),
2081 EditorAction::Cancel => app.close_compose_modal(),
2082 }
2083 }
2084 }
2085 Stage::HelpOverlay => match k.code {
2086 KeyCode::Esc | KeyCode::Char('?') | KeyCode::Char('q') => app.close_help_overlay(),
2087 _ => {}
2088 },
2089 Stage::MailboxDetailModal => match k.code {
2095 KeyCode::Esc | KeyCode::Char('q') => app.close_mailbox_detail_modal(),
2096 KeyCode::Char('j') | KeyCode::Down => app.mailbox_detail_scroll_down(),
2097 KeyCode::Char('k') | KeyCode::Up => app.mailbox_detail_scroll_up(),
2098 _ => {}
2099 },
2100 Stage::Tutorial => match k.code {
2101 KeyCode::Esc => app.close_tutorial(),
2102 KeyCode::Char('k') | KeyCode::Up | KeyCode::Char('p') => app.tutorial_back(),
2103 _ => app.tutorial_advance(),
2104 },
2105 Stage::StreamKeys => {
2116 let ctrl = k.modifiers.contains(KeyModifiers::CONTROL);
2117 let ctrl_shift = k
2118 .modifiers
2119 .contains(KeyModifiers::CONTROL | KeyModifiers::SHIFT);
2120 if ctrl && matches!(k.code, KeyCode::Char('e') | KeyCode::Char('E')) {
2121 app.exit_stream_keys();
2122 } else if ctrl_shift && matches!(k.code, KeyCode::Up | KeyCode::Down) {
2123 if app.detail_splits.is_empty() || app.selected_split == 0 {
2138 if matches!(k.code, KeyCode::Up) {
2139 app.select_prev();
2140 } else {
2141 app.select_next();
2142 }
2143 }
2144 } else if let Some(session) = app.stream_target_session() {
2145 if let Some(encoded) = encode_key(k) {
2146 let _ = key_sender.send(&session, &encoded);
2151 }
2152 } else {
2153 app.exit_stream_keys();
2158 }
2159 }
2160 },
2161 Event::Resize(_, _) => {
2162 }
2164 Event::Mouse(m) if matches!(app.stage, Stage::Triptych) => {
2174 use crossterm::event::MouseEventKind;
2175 let direction = match m.kind {
2176 MouseEventKind::ScrollUp => Some(ScrollDirection::Up),
2177 MouseEventKind::ScrollDown => Some(ScrollDirection::Down),
2178 _ => None,
2179 };
2180 if let Some(dir) = direction {
2181 match app.focused_pane {
2182 Pane::Detail => {
2183 if let Some(session) = app.focused_session().map(|s| s.to_string()) {
2184 let _ = key_sender.scroll(&session, dir);
2189 }
2190 }
2191 Pane::Roster => match dir {
2192 ScrollDirection::Up => app.select_prev(),
2193 ScrollDirection::Down => app.select_next(),
2194 },
2195 Pane::Mailbox => match dir {
2196 ScrollDirection::Up => app.mailbox_cursor_up(),
2197 ScrollDirection::Down => app.mailbox_cursor_down(),
2198 },
2199 }
2200 }
2201 }
2202 _ => {}
2203 }
2204}
2205
2206pub fn render_to_buffer(app: &App, width: u16, height: u16) -> Buffer {
2210 let area = Rect::new(0, 0, width, height);
2211 let mut buf = Buffer::empty(area);
2212 match app.stage {
2213 Stage::Splash => splash::Splash { app }.render(area, &mut buf),
2214 Stage::Triptych => render_main(app, area, &mut buf),
2215 Stage::StreamKeys => render_main(app, area, &mut buf),
2216 Stage::QuitConfirm => {
2217 render_main(app, area, &mut buf);
2218 render_quit_confirm(area, &mut buf);
2219 }
2220 Stage::ApprovalsModal => {
2221 render_main(app, area, &mut buf);
2222 render_approvals_modal(area, &mut buf, app);
2223 }
2224 Stage::ComposeModal => {
2225 render_main(app, area, &mut buf);
2226 render_compose_modal(area, &mut buf, app);
2227 }
2228 Stage::HelpOverlay => {
2229 render_main(app, area, &mut buf);
2230 render_help_overlay(area, &mut buf, app);
2231 }
2232 Stage::Tutorial => {
2233 render_main(app, area, &mut buf);
2234 render_tutorial(area, &mut buf, app);
2235 }
2236 Stage::MailboxDetailModal => {
2237 render_main(app, area, &mut buf);
2238 render_mailbox_detail_modal(area, &mut buf, app);
2239 }
2240 }
2241 buf
2242}
2243
2244fn render_main(app: &App, area: Rect, buf: &mut Buffer) {
2245 let chunks = Layout::default()
2248 .direction(Direction::Vertical)
2249 .constraints([
2250 Constraint::Min(3),
2251 Constraint::Length(1), Constraint::Length(1), ])
2254 .split(area);
2255 match app.layout {
2256 crate::triptych::MainLayout::Triptych => {
2257 triptych::Triptych { app }.render(chunks[0], buf);
2258 }
2259 crate::triptych::MainLayout::Wall => {
2260 layouts::Wall { app }.render(chunks[0], buf);
2261 }
2262 crate::triptych::MainLayout::MailboxFirst => {
2263 layouts::MailboxFirst { app }.render(chunks[0], buf);
2264 }
2265 }
2266 statusline::Statusline { app }.render(chunks[1], buf);
2267 status_bar::StatusBar { app }.render(chunks[2], buf);
2268}
2269
2270fn render_quit_confirm(area: Rect, buf: &mut Buffer) {
2271 let popup_w = 36u16.min(area.width.saturating_sub(2));
2272 let popup_h = 5u16.min(area.height.saturating_sub(2));
2273 let popup = centered_rect(popup_w, popup_h, area);
2274 Clear.render(popup, buf);
2275 Paragraph::new("Quit teamctl-ui? [y / n]")
2276 .alignment(Alignment::Center)
2277 .block(Block::default().borders(Borders::ALL).title("confirm"))
2278 .render(popup, buf);
2279}
2280
2281#[cfg(test)]
2282mod tests {
2283 use super::*;
2284 use crate::data::AgentInfo;
2285 use crossterm::event::{KeyEvent, KeyEventState, KeyModifiers};
2286 use team_core::supervisor::AgentState;
2287
2288 fn key(code: KeyCode) -> Event {
2289 Event::Key(KeyEvent {
2290 code,
2291 modifiers: KeyModifiers::NONE,
2292 kind: KeyEventKind::Press,
2293 state: KeyEventState::NONE,
2294 })
2295 }
2296
2297 fn key_with(code: KeyCode, modifiers: KeyModifiers) -> Event {
2298 Event::Key(KeyEvent {
2299 code,
2300 modifiers,
2301 kind: KeyEventKind::Press,
2302 state: KeyEventState::NONE,
2303 })
2304 }
2305
2306 struct NoopDecider;
2308 impl crate::approvals::ApprovalDecider for NoopDecider {
2309 fn decide(
2310 &self,
2311 _root: &std::path::Path,
2312 _id: i64,
2313 _kind: crate::approvals::Decision,
2314 _note: &str,
2315 ) -> anyhow::Result<()> {
2316 Ok(())
2317 }
2318 }
2319
2320 struct NoopSender;
2322 impl crate::compose::MessageSender for NoopSender {
2323 fn send_dm(
2324 &self,
2325 _root: &std::path::Path,
2326 _agent: &str,
2327 _body: &str,
2328 ) -> anyhow::Result<()> {
2329 Ok(())
2330 }
2331 fn broadcast(
2332 &self,
2333 _root: &std::path::Path,
2334 _channel: &str,
2335 _body: &str,
2336 ) -> anyhow::Result<()> {
2337 Ok(())
2338 }
2339 }
2340
2341 struct EmptyMailbox;
2344 impl crate::mailbox::MailboxSource for EmptyMailbox {
2345 fn inbox(&self, _id: &str, _after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
2346 Ok(Vec::new())
2347 }
2348 fn sent(&self, _id: &str, _after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
2349 Ok(Vec::new())
2350 }
2351 fn channel_feed(
2352 &self,
2353 _id: &str,
2354 _after: i64,
2355 ) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
2356 Ok(Vec::new())
2357 }
2358 fn wire(&self, _id: &str, _after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
2359 Ok(Vec::new())
2360 }
2361 }
2362
2363 fn dispatch(app: &mut App, ev: Event) {
2366 super::handle_event(
2367 app,
2368 ev,
2369 &NoopDecider,
2370 &NoopSender,
2371 &EmptyMailbox,
2372 &crate::keysender::test_support::MockKeySender::default(),
2373 );
2374 }
2375
2376 fn agent(id: &str, state: AgentState) -> AgentInfo {
2377 AgentInfo {
2378 id: id.into(),
2379 agent: id
2380 .split_once(':')
2381 .map(|(_, a)| a.to_string())
2382 .unwrap_or_default(),
2383 project: id
2384 .split_once(':')
2385 .map(|(p, _)| p.to_string())
2386 .unwrap_or_default(),
2387 tmux_session: format!("t-{}", id.replace(':', "-")),
2388 state,
2389 unread_mail: 0,
2390 pending_approvals: 0,
2391 is_manager: false,
2392 display_name: None,
2393 rate_limit_resets_at: None,
2394 last_activity_at: None,
2395 reports_to: None,
2396 }
2397 }
2398
2399 pub fn fixture_team(agents: Vec<AgentInfo>) -> TeamSnapshot {
2400 TeamSnapshot {
2401 root: std::path::PathBuf::from("/fixture"),
2402 team_name: "fixture".into(),
2403 agents,
2404 channels: Vec::new(),
2405 }
2406 }
2407
2408 #[test]
2409 fn splash_dismissed_by_any_key() {
2410 let mut app = App::new();
2411 assert_eq!(app.stage, Stage::Splash);
2412 dispatch(&mut app, key(KeyCode::Char(' ')));
2413 assert_eq!(app.stage, Stage::Triptych);
2414 }
2415
2416 #[test]
2417 fn tab_cycles_panes_uniformly_and_wraps_through_mailbox() {
2418 let mut app = App::new();
2425 app.dismiss_splash();
2426 assert_eq!(app.focused_pane, Pane::Roster);
2427 dispatch(&mut app, key(KeyCode::Tab));
2428 assert_eq!(app.focused_pane, Pane::Detail);
2429 dispatch(&mut app, key(KeyCode::Tab));
2430 assert_eq!(app.focused_pane, Pane::Mailbox);
2431 assert_eq!(
2432 app.mailbox_tab,
2433 MailboxTab::Inbox,
2434 "Tab into mailbox does NOT touch the active mailbox tab"
2435 );
2436 dispatch(&mut app, key(KeyCode::Tab));
2437 assert_eq!(
2438 app.focused_pane,
2439 Pane::Roster,
2440 "Tab from mailbox wraps to roster, not into mailbox subtabs"
2441 );
2442 assert_eq!(
2443 app.mailbox_tab,
2444 MailboxTab::Inbox,
2445 "mailbox tab still untouched"
2446 );
2447 }
2448
2449 #[test]
2450 fn arrow_keys_walk_mailbox_tabs_when_mailbox_focused() {
2451 let mut app = App::new();
2456 app.dismiss_splash();
2457 dispatch(&mut app, key(KeyCode::Tab));
2459 dispatch(&mut app, key(KeyCode::Tab));
2460 assert_eq!(app.focused_pane, Pane::Mailbox);
2461 assert_eq!(app.mailbox_tab, MailboxTab::Inbox);
2462
2463 dispatch(&mut app, key(KeyCode::Right));
2464 assert_eq!(app.mailbox_tab, MailboxTab::Sent);
2465 dispatch(&mut app, key(KeyCode::Right));
2466 assert_eq!(app.mailbox_tab, MailboxTab::Channel);
2467 dispatch(&mut app, key(KeyCode::Right));
2468 assert_eq!(app.mailbox_tab, MailboxTab::Wire);
2469 dispatch(&mut app, key(KeyCode::Right));
2470 assert_eq!(app.mailbox_tab, MailboxTab::Inbox, "→ wraps");
2471
2472 dispatch(&mut app, key(KeyCode::Left));
2473 assert_eq!(app.mailbox_tab, MailboxTab::Wire, "← walks back");
2474 }
2475
2476 #[test]
2477 fn arrow_keys_no_op_when_mailbox_not_focused() {
2478 let mut app = App::new();
2481 app.dismiss_splash();
2482 assert_eq!(app.focused_pane, Pane::Roster);
2483 let initial = app.mailbox_tab;
2484 dispatch(&mut app, key(KeyCode::Right));
2485 dispatch(&mut app, key(KeyCode::Left));
2486 assert_eq!(
2487 app.mailbox_tab, initial,
2488 "←/→ from non-mailbox panes must not flip the active tab"
2489 );
2490 }
2491
2492 #[test]
2493 fn brackets_no_longer_cycle_mailbox_tabs() {
2494 let mut app = App::new();
2499 app.dismiss_splash();
2500 dispatch(&mut app, key(KeyCode::Tab));
2501 dispatch(&mut app, key(KeyCode::Tab));
2502 assert_eq!(app.focused_pane, Pane::Mailbox);
2503 let initial = app.mailbox_tab;
2504
2505 dispatch(&mut app, key(KeyCode::Char(']')));
2506 dispatch(&mut app, key(KeyCode::Char('[')));
2507 assert_eq!(
2508 app.mailbox_tab, initial,
2509 "`[` / `]` must no longer cycle mailbox tabs (T-124 hard-swap)",
2510 );
2511 }
2512
2513 #[test]
2514 fn q_opens_confirm_then_n_cancels() {
2515 let mut app = App::new();
2516 app.dismiss_splash();
2517 dispatch(&mut app, key(KeyCode::Char('q')));
2518 assert_eq!(app.stage, Stage::QuitConfirm);
2519 dispatch(&mut app, key(KeyCode::Char('n')));
2520 assert_eq!(app.stage, Stage::Triptych);
2521 assert!(app.running, "n must not exit");
2522 }
2523
2524 #[test]
2525 fn q_then_y_exits() {
2526 let mut app = App::new();
2527 app.dismiss_splash();
2528 dispatch(&mut app, key(KeyCode::Char('q')));
2529 dispatch(&mut app, key(KeyCode::Char('y')));
2530 assert!(!app.running);
2531 }
2532
2533 #[test]
2534 fn esc_cancels_quit_confirm() {
2535 let mut app = App::new();
2536 app.dismiss_splash();
2537 app.enter_quit_confirm();
2538 dispatch(&mut app, key(KeyCode::Esc));
2539 assert_eq!(app.stage, Stage::Triptych);
2540 }
2541
2542 #[test]
2543 fn render_does_not_panic_at_minimal_size() {
2544 let app = App::new();
2545 let _ = render_to_buffer(&app, 20, 8);
2546 }
2547
2548 #[test]
2549 fn render_does_not_panic_at_huge_size() {
2550 let app = App::new();
2551 let _ = render_to_buffer(&app, 240, 80);
2552 }
2553
2554 #[test]
2555 fn select_next_wraps_through_team() {
2556 let mut app = App::new();
2557 app.replace_team(fixture_team(vec![
2558 agent("p:a", AgentState::Running),
2559 agent("p:b", AgentState::Running),
2560 agent("p:c", AgentState::Running),
2561 ]));
2562 assert_eq!(app.selected_agent, Some(0));
2563 app.select_next();
2564 assert_eq!(app.selected_agent, Some(1));
2565 app.select_next();
2566 assert_eq!(app.selected_agent, Some(2));
2567 app.select_next();
2568 assert_eq!(app.selected_agent, Some(0)); }
2570
2571 #[test]
2572 fn select_prev_wraps_at_top() {
2573 let mut app = App::new();
2574 app.replace_team(fixture_team(vec![
2575 agent("p:a", AgentState::Running),
2576 agent("p:b", AgentState::Running),
2577 ]));
2578 app.selected_agent = Some(0);
2579 app.select_prev();
2580 assert_eq!(app.selected_agent, Some(1));
2581 }
2582
2583 #[test]
2584 fn select_no_op_on_empty_team() {
2585 let mut app = App::new();
2586 app.select_next();
2587 assert_eq!(app.selected_agent, None);
2588 app.select_prev();
2589 assert_eq!(app.selected_agent, None);
2590 }
2591
2592 #[test]
2593 fn replace_team_preserves_selection_when_agent_still_present() {
2594 let mut app = App::new();
2595 app.replace_team(fixture_team(vec![
2596 agent("p:a", AgentState::Running),
2597 agent("p:b", AgentState::Running),
2598 ]));
2599 app.selected_agent = Some(1);
2600 app.replace_team(fixture_team(vec![
2601 agent("p:a", AgentState::Running),
2602 agent("p:b", AgentState::Stopped), ]));
2604 assert_eq!(app.selected_agent, Some(1), "selection follows the id");
2605 }
2606
2607 #[test]
2608 fn replace_team_resets_selection_when_agent_disappears() {
2609 let mut app = App::new();
2610 app.replace_team(fixture_team(vec![
2611 agent("p:a", AgentState::Running),
2612 agent("p:gone", AgentState::Running),
2613 ]));
2614 app.selected_agent = Some(1);
2615 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
2616 assert_eq!(app.selected_agent, Some(0), "falls back to first agent");
2617 }
2618
2619 #[test]
2620 fn switching_agent_resets_mailbox_buffers() {
2621 let mut app = App::new();
2625 app.replace_team(fixture_team(vec![
2626 agent("p:a", AgentState::Running),
2627 agent("p:b", AgentState::Running),
2628 ]));
2629 app.mailbox.extend(
2630 crate::mailbox::MailboxTab::Inbox,
2631 vec![crate::mailbox::MessageRow {
2632 id: 7,
2633 sender: "p:b".into(),
2634 recipient: "p:a".into(),
2635 text: "hi".into(),
2636 sent_at: 0.0,
2637 }],
2638 );
2639 assert_eq!(app.mailbox.inbox.len(), 1);
2640 assert_eq!(app.mailbox.inbox_after, 7);
2641 app.select_next();
2643 assert_eq!(app.selected_agent_id().as_deref(), Some("p:b"));
2644 assert!(app.mailbox.inbox.is_empty());
2645 assert_eq!(app.mailbox.inbox_after, 0);
2646 }
2647
2648 struct TripleFilterMock {
2653 inbox: Vec<crate::mailbox::MessageRow>,
2654 sent: Vec<crate::mailbox::MessageRow>,
2655 channel: Vec<crate::mailbox::MessageRow>,
2656 wire: Vec<crate::mailbox::MessageRow>,
2657 calls: std::sync::Mutex<Vec<(&'static str, String, i64)>>,
2658 }
2659 impl crate::mailbox::MailboxSource for TripleFilterMock {
2660 fn inbox(&self, id: &str, after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
2661 self.calls.lock().unwrap().push(("inbox", id.into(), after));
2662 Ok(self.inbox.clone())
2663 }
2664 fn sent(&self, id: &str, after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
2665 self.calls.lock().unwrap().push(("sent", id.into(), after));
2666 Ok(self.sent.clone())
2667 }
2668 fn channel_feed(
2669 &self,
2670 id: &str,
2671 after: i64,
2672 ) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
2673 self.calls
2674 .lock()
2675 .unwrap()
2676 .push(("channel", id.into(), after));
2677 Ok(self.channel.clone())
2678 }
2679 fn wire(&self, id: &str, after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
2680 self.calls.lock().unwrap().push(("wire", id.into(), after));
2681 Ok(self.wire.clone())
2682 }
2683 }
2684
2685 #[test]
2686 fn refresh_mailbox_fans_out_to_four_filters() {
2687 use crate::mailbox::MessageRow;
2688 let mut app = App::new();
2689 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
2690 let mock = TripleFilterMock {
2691 inbox: vec![MessageRow {
2692 id: 1,
2693 sender: "p:b".into(),
2694 recipient: "p:a".into(),
2695 text: "dm".into(),
2696 sent_at: 0.0,
2697 }],
2698 sent: vec![MessageRow {
2699 id: 4,
2700 sender: "p:a".into(),
2701 recipient: "p:b".into(),
2702 text: "outgoing dm".into(),
2703 sent_at: 0.0,
2704 }],
2705 channel: vec![MessageRow {
2706 id: 2,
2707 sender: "p:b".into(),
2708 recipient: "channel:p:editorial".into(),
2709 text: "ch".into(),
2710 sent_at: 0.0,
2711 }],
2712 wire: vec![MessageRow {
2713 id: 3,
2714 sender: "p:b".into(),
2715 recipient: "channel:p:all".into(),
2716 text: "wire".into(),
2717 sent_at: 0.0,
2718 }],
2719 calls: std::sync::Mutex::new(Vec::new()),
2720 };
2721 super::refresh_mailbox(&mut app, &mock);
2722 assert_eq!(app.mailbox.inbox.len(), 1);
2723 assert_eq!(app.mailbox.sent.len(), 1);
2724 assert_eq!(app.mailbox.channel.len(), 1);
2725 assert_eq!(app.mailbox.wire.len(), 1);
2726 let calls = mock.calls.lock().unwrap();
2727 assert!(calls.contains(&("inbox", "p:a".into(), 0)));
2730 assert!(calls.contains(&("sent", "p:a".into(), 0)));
2731 assert!(calls.contains(&("channel", "p:a".into(), 0)));
2732 assert!(calls.contains(&("wire", "p".into(), 0)));
2733 }
2734
2735 fn ap(id: i64) -> crate::approvals::Approval {
2736 crate::approvals::Approval {
2737 id,
2738 project_id: "p".into(),
2739 agent_id: "p:m".into(),
2740 action: "publish".into(),
2741 summary: format!("approval #{id}"),
2742 payload_json: String::new(),
2743 }
2744 }
2745
2746 #[test]
2747 fn has_pending_approvals_tracks_replace_calls() {
2748 let mut app = App::new();
2749 assert!(!app.has_pending_approvals());
2750 app.replace_approvals(vec![ap(1), ap(2)]);
2751 assert!(app.has_pending_approvals());
2752 app.replace_approvals(vec![]);
2753 assert!(!app.has_pending_approvals());
2754 }
2755
2756 #[test]
2757 fn enter_approvals_modal_no_op_when_queue_empty() {
2758 let mut app = App::new();
2759 app.dismiss_splash();
2760 app.enter_approvals_modal();
2761 assert_eq!(app.stage, Stage::Triptych, "no pending → no modal");
2762 }
2763
2764 #[test]
2765 fn a_chord_opens_modal_when_pending() {
2766 let mut app = App::new();
2767 app.dismiss_splash();
2768 app.replace_approvals(vec![ap(1), ap(2)]);
2769 dispatch(&mut app, key(KeyCode::Char('a')));
2770 assert_eq!(app.stage, Stage::ApprovalsModal);
2771 assert_eq!(app.selected_approval, 0);
2772 }
2773
2774 #[test]
2775 fn modal_cycle_jk_walks_approvals() {
2776 let mut app = App::new();
2777 app.dismiss_splash();
2778 app.replace_approvals(vec![ap(1), ap(2), ap(3)]);
2779 app.enter_approvals_modal();
2780 dispatch(&mut app, key(KeyCode::Char('j')));
2781 assert_eq!(app.selected_approval, 1);
2782 dispatch(&mut app, key(KeyCode::Char('j')));
2783 assert_eq!(app.selected_approval, 2);
2784 dispatch(&mut app, key(KeyCode::Char('j')));
2785 assert_eq!(app.selected_approval, 0, "wraps");
2786 dispatch(&mut app, key(KeyCode::Char('k')));
2787 assert_eq!(app.selected_approval, 2, "k wraps too");
2788 }
2789
2790 #[test]
2791 fn capital_y_routes_approve_through_decider() {
2792 use crate::approvals::test_support::MockApprovalDecider;
2793 let dec = MockApprovalDecider::default();
2794 let mut app = App::new();
2795 app.dismiss_splash();
2796 app.replace_approvals(vec![ap(7), ap(8)]);
2797 app.enter_approvals_modal();
2798 super::handle_event(
2799 &mut app,
2800 key(KeyCode::Char('Y')),
2801 &dec,
2802 &NoopSender,
2803 &EmptyMailbox,
2804 &crate::keysender::test_support::MockKeySender::default(),
2805 );
2806 let calls = dec.calls.lock().unwrap().clone();
2807 assert_eq!(calls.len(), 1);
2808 assert_eq!(calls[0].0, 7);
2809 assert_eq!(calls[0].1, crate::approvals::Decision::Approve);
2810 assert_eq!(app.pending_approvals.len(), 1);
2812 assert_eq!(app.pending_approvals[0].id, 8);
2813 }
2814
2815 #[test]
2816 fn capital_n_routes_deny_through_decider() {
2817 use crate::approvals::test_support::MockApprovalDecider;
2818 let dec = MockApprovalDecider::default();
2819 let mut app = App::new();
2820 app.dismiss_splash();
2821 app.replace_approvals(vec![ap(7)]);
2822 app.enter_approvals_modal();
2823 super::handle_event(
2824 &mut app,
2825 key(KeyCode::Char('N')),
2826 &dec,
2827 &NoopSender,
2828 &EmptyMailbox,
2829 &crate::keysender::test_support::MockKeySender::default(),
2830 );
2831 let calls = dec.calls.lock().unwrap().clone();
2832 assert_eq!(calls.len(), 1);
2833 assert_eq!(calls[0].1, crate::approvals::Decision::Deny);
2834 assert_eq!(app.stage, Stage::Triptych);
2836 }
2837
2838 #[test]
2839 fn esc_closes_approvals_modal() {
2840 let mut app = App::new();
2841 app.dismiss_splash();
2842 app.replace_approvals(vec![ap(1)]);
2843 app.enter_approvals_modal();
2844 dispatch(&mut app, key(KeyCode::Esc));
2845 assert_eq!(app.stage, Stage::Triptych);
2846 }
2847
2848 #[test]
2849 fn lowercase_y_routes_approve_through_decider() {
2850 use crate::approvals::test_support::MockApprovalDecider;
2854 let dec = MockApprovalDecider::default();
2855 let mut app = App::new();
2856 app.dismiss_splash();
2857 app.replace_approvals(vec![ap(7)]);
2858 app.enter_approvals_modal();
2859 super::handle_event(
2860 &mut app,
2861 key(KeyCode::Char('y')),
2862 &dec,
2863 &NoopSender,
2864 &EmptyMailbox,
2865 &crate::keysender::test_support::MockKeySender::default(),
2866 );
2867 let calls = dec.calls.lock().unwrap().clone();
2868 assert_eq!(calls.len(), 1);
2869 assert_eq!(calls[0].1, crate::approvals::Decision::Approve);
2870 }
2871
2872 #[test]
2873 fn lowercase_n_does_not_deny() {
2874 use crate::approvals::test_support::MockApprovalDecider;
2879 let dec = MockApprovalDecider::default();
2880 let mut app = App::new();
2881 app.dismiss_splash();
2882 app.replace_approvals(vec![ap(7)]);
2883 app.enter_approvals_modal();
2884 super::handle_event(
2885 &mut app,
2886 key(KeyCode::Char('n')),
2887 &dec,
2888 &NoopSender,
2889 &EmptyMailbox,
2890 &crate::keysender::test_support::MockKeySender::default(),
2891 );
2892 assert!(
2893 dec.calls.lock().unwrap().is_empty(),
2894 "lowercase n must not route through the decider"
2895 );
2896 assert_eq!(
2897 app.stage,
2898 Stage::ApprovalsModal,
2899 "stale lowercase n leaves the modal open"
2900 );
2901 }
2902
2903 #[test]
2904 fn shift_tab_cycles_panes_backward() {
2905 use crossterm::event::KeyModifiers;
2906 let mut app = App::new();
2907 app.dismiss_splash();
2908 assert_eq!(app.focused_pane, Pane::Roster);
2909 dispatch(&mut app, key(KeyCode::BackTab));
2912 assert_eq!(app.focused_pane, Pane::Mailbox);
2913 dispatch(&mut app, key_with(KeyCode::Tab, KeyModifiers::SHIFT));
2915 assert_eq!(app.focused_pane, Pane::Detail);
2916 }
2917
2918 #[test]
2919 fn at_chord_opens_compose_dm_to_focused_agent() {
2920 let mut app = App::new();
2921 app.replace_team(fixture_team(vec![
2922 agent("writing:manager", AgentState::Running),
2923 agent("writing:dev1", AgentState::Running),
2924 ]));
2925 app.dismiss_splash();
2926 app.select_next();
2927 dispatch(&mut app, key(KeyCode::Char('@')));
2928 assert_eq!(app.stage, Stage::ComposeModal);
2929 match app.compose_target.as_ref() {
2930 Some(crate::compose::ComposeTarget::Dm { agent_id, .. }) => {
2931 assert_eq!(agent_id, "writing:dev1");
2932 }
2933 other => panic!("expected DM target, got {other:?}"),
2934 }
2935 }
2936
2937 #[test]
2938 fn bang_chord_opens_compose_broadcast_to_all_channel() {
2939 let mut app = App::new();
2940 app.replace_team(fixture_team(vec![agent(
2941 "writing:manager",
2942 AgentState::Running,
2943 )]));
2944 app.dismiss_splash();
2945 dispatch(&mut app, key(KeyCode::Char('!')));
2946 assert_eq!(app.stage, Stage::ComposeModal);
2947 match app.compose_target.as_ref() {
2948 Some(crate::compose::ComposeTarget::Broadcast { channel_id, .. }) => {
2949 assert_eq!(channel_id, "writing:all");
2950 }
2951 other => panic!("expected Broadcast target, got {other:?}"),
2952 }
2953 }
2954
2955 #[test]
2956 fn send_routes_dm_through_mock_sender() {
2957 use crate::compose::test_support::MockMessageSender;
2958 let sender = MockMessageSender::default();
2959 let mailbox = EmptyMailbox;
2960 let mut app = App::new();
2961 app.replace_team(fixture_team(vec![agent(
2962 "writing:dev1",
2963 AgentState::Running,
2964 )]));
2965 app.dismiss_splash();
2966 app.enter_compose_dm_for_focused();
2967 for c in "ship it".chars() {
2968 super::handle_event(
2969 &mut app,
2970 key(KeyCode::Char(c)),
2971 &NoopDecider,
2972 &sender,
2973 &mailbox,
2974 &crate::keysender::test_support::MockKeySender::default(),
2975 );
2976 }
2977 super::handle_event(
2978 &mut app,
2979 key_with(KeyCode::Enter, crossterm::event::KeyModifiers::CONTROL),
2980 &NoopDecider,
2981 &sender,
2982 &mailbox,
2983 &crate::keysender::test_support::MockKeySender::default(),
2984 );
2985 let calls = sender.dm_calls.lock().unwrap().clone();
2986 assert_eq!(calls.len(), 1);
2987 assert_eq!(calls[0].0, "writing:dev1");
2988 assert_eq!(calls[0].1, "ship it");
2989 assert_eq!(app.stage, Stage::Triptych, "modal closes on send");
2990 }
2991
2992 #[test]
2993 fn esc_esc_cancels_compose_without_send() {
2994 use crate::compose::test_support::MockMessageSender;
2995 let sender = MockMessageSender::default();
2996 let mailbox = EmptyMailbox;
2997 let mut app = App::new();
2998 app.replace_team(fixture_team(vec![agent(
2999 "writing:dev1",
3000 AgentState::Running,
3001 )]));
3002 app.dismiss_splash();
3003 app.enter_compose_dm_for_focused();
3004 for c in "draft".chars() {
3005 super::handle_event(
3006 &mut app,
3007 key(KeyCode::Char(c)),
3008 &NoopDecider,
3009 &sender,
3010 &mailbox,
3011 &crate::keysender::test_support::MockKeySender::default(),
3012 );
3013 }
3014 super::handle_event(
3015 &mut app,
3016 key(KeyCode::Esc),
3017 &NoopDecider,
3018 &sender,
3019 &mailbox,
3020 &crate::keysender::test_support::MockKeySender::default(),
3021 );
3022 super::handle_event(
3023 &mut app,
3024 key(KeyCode::Esc),
3025 &NoopDecider,
3026 &sender,
3027 &mailbox,
3028 &crate::keysender::test_support::MockKeySender::default(),
3029 );
3030 assert_eq!(app.stage, Stage::Triptych);
3031 assert!(sender.dm_calls.lock().unwrap().is_empty());
3032 }
3033
3034 #[test]
3035 fn send_failure_surfaces_error_inline_keeps_modal_open() {
3036 use crate::compose::test_support::MockMessageSender;
3037 let sender = MockMessageSender::default();
3038 *sender.fail_next.lock().unwrap() = Some("rate limit".into());
3039 let mailbox = EmptyMailbox;
3040 let mut app = App::new();
3041 app.replace_team(fixture_team(vec![agent(
3042 "writing:dev1",
3043 AgentState::Running,
3044 )]));
3045 app.dismiss_splash();
3046 app.enter_compose_dm_for_focused();
3047 super::handle_event(
3048 &mut app,
3049 key(KeyCode::Char('x')),
3050 &NoopDecider,
3051 &sender,
3052 &mailbox,
3053 &crate::keysender::test_support::MockKeySender::default(),
3054 );
3055 super::handle_event(
3056 &mut app,
3057 key_with(KeyCode::Enter, crossterm::event::KeyModifiers::CONTROL),
3058 &NoopDecider,
3059 &sender,
3060 &mailbox,
3061 &crate::keysender::test_support::MockKeySender::default(),
3062 );
3063 assert_eq!(app.stage, Stage::ComposeModal, "modal stays open on err");
3064 assert!(app
3065 .compose_error
3066 .as_deref()
3067 .unwrap_or_default()
3068 .contains("rate limit"));
3069 }
3070
3071 fn channel(id: &str, project: &str) -> crate::data::ChannelInfo {
3072 crate::data::ChannelInfo {
3073 id: id.into(),
3074 name: id
3075 .rsplit_once(':')
3076 .map(|(_, n)| n.to_string())
3077 .unwrap_or_default(),
3078 project_id: project.into(),
3079 }
3080 }
3081
3082 fn fixture_team_with_channels(
3083 agents: Vec<AgentInfo>,
3084 channels: Vec<crate::data::ChannelInfo>,
3085 ) -> TeamSnapshot {
3086 TeamSnapshot {
3087 root: std::path::PathBuf::from("/fixture"),
3088 team_name: "fixture".into(),
3089 agents,
3090 channels,
3091 }
3092 }
3093
3094 #[test]
3095 fn ctrl_w_toggles_wall_layout() {
3096 use crossterm::event::KeyModifiers;
3097 let mut app = App::new();
3098 app.dismiss_splash();
3099 assert_eq!(app.layout, MainLayout::Triptych);
3100 dispatch(
3101 &mut app,
3102 key_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
3103 );
3104 assert_eq!(app.layout, MainLayout::Wall);
3105 dispatch(
3106 &mut app,
3107 key_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
3108 );
3109 assert_eq!(app.layout, MainLayout::Triptych);
3110 }
3111
3112 #[test]
3113 fn ctrl_m_toggles_mailbox_first_layout() {
3114 use crossterm::event::KeyModifiers;
3115 let mut app = App::new();
3116 app.dismiss_splash();
3117 dispatch(
3118 &mut app,
3119 key_with(KeyCode::Char('m'), KeyModifiers::CONTROL),
3120 );
3121 assert_eq!(app.layout, MainLayout::MailboxFirst);
3122 dispatch(
3123 &mut app,
3124 key_with(KeyCode::Char('m'), KeyModifiers::CONTROL),
3125 );
3126 assert_eq!(app.layout, MainLayout::Triptych);
3127 }
3128
3129 #[test]
3130 fn wall_scroll_pages_through_overflow_agents() {
3131 let mut app = App::new();
3132 let mut agents: Vec<_> = (1..=10)
3133 .map(|i| agent(&format!("p:agent-{i:02}"), AgentState::Running))
3134 .collect();
3135 for a in agents.iter_mut() {
3137 a.is_manager = false;
3138 }
3139 app.replace_team(fixture_team(agents));
3140 app.dismiss_splash();
3141 app.toggle_wall_layout();
3142 assert_eq!(app.wall_scroll, 0);
3143 app.wall_scroll_down();
3144 assert_eq!(app.wall_scroll, 4);
3145 app.wall_scroll_down();
3146 assert_eq!(app.wall_scroll, 8);
3147 app.wall_scroll_down();
3149 assert_eq!(app.wall_scroll, 8, "scroll capped at last full window");
3150 app.wall_scroll_up();
3151 assert_eq!(app.wall_scroll, 4);
3152 }
3153
3154 #[test]
3155 fn ctrl_pipe_adds_detail_split_capped_at_four() {
3156 use crossterm::event::KeyModifiers;
3157 let mut app = App::new();
3158 app.replace_team(fixture_team(vec![
3159 agent("p:a", AgentState::Running),
3160 agent("p:b", AgentState::Running),
3161 ]));
3162 app.dismiss_splash();
3163 for _ in 0..6 {
3164 dispatch(
3165 &mut app,
3166 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
3167 );
3168 }
3169 assert_eq!(app.detail_splits.len(), 4, "split count capped at 4");
3170 }
3171
3172 #[test]
3173 fn ctrl_q_closes_focused_split() {
3174 use crossterm::event::KeyModifiers;
3175 let mut app = App::new();
3176 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3177 app.dismiss_splash();
3178 dispatch(
3179 &mut app,
3180 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
3181 );
3182 dispatch(
3183 &mut app,
3184 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
3185 );
3186 assert_eq!(app.detail_splits.len(), 2);
3187 dispatch(
3188 &mut app,
3189 key_with(KeyCode::Char('Q'), KeyModifiers::CONTROL),
3190 );
3191 assert_eq!(app.detail_splits.len(), 1);
3192 }
3193
3194 #[test]
3195 fn ctrl_hjkl_cycles_splits() {
3196 use crossterm::event::KeyModifiers;
3197 let mut app = App::new();
3198 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3199 app.dismiss_splash();
3200 for _ in 0..3 {
3201 dispatch(
3202 &mut app,
3203 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
3204 );
3205 }
3206 assert_eq!(app.selected_split, 2);
3207 dispatch(
3208 &mut app,
3209 key_with(KeyCode::Char('l'), KeyModifiers::CONTROL),
3210 );
3211 assert_eq!(app.selected_split, 0, "wraps");
3212 dispatch(
3213 &mut app,
3214 key_with(KeyCode::Char('h'), KeyModifiers::CONTROL),
3215 );
3216 assert_eq!(app.selected_split, 2);
3217 }
3218
3219 #[test]
3220 fn wall_scroll_at_exactly_cap_agents_does_not_scroll() {
3221 let mut app = App::new();
3226 let agents: Vec<_> = (1..=4)
3227 .map(|i| agent(&format!("p:agent-{i}"), AgentState::Running))
3228 .collect();
3229 app.replace_team(fixture_team(agents));
3230 app.dismiss_splash();
3231 app.toggle_wall_layout();
3232 assert_eq!(app.wall_scroll, 0);
3233 app.wall_scroll_down();
3234 assert_eq!(app.wall_scroll, 0, "exactly-cap should not advance");
3235 app.wall_scroll_up();
3236 assert_eq!(app.wall_scroll, 0);
3237 }
3238
3239 #[test]
3240 fn wall_scroll_at_cap_plus_one_advances_then_stops() {
3241 let mut app = App::new();
3246 let agents: Vec<_> = (1..=5)
3247 .map(|i| agent(&format!("p:agent-{i}"), AgentState::Running))
3248 .collect();
3249 app.replace_team(fixture_team(agents));
3250 app.dismiss_splash();
3251 app.toggle_wall_layout();
3252 app.wall_scroll_down();
3253 assert_eq!(app.wall_scroll, 4, "first scroll exposes agent 5");
3254 app.wall_scroll_down();
3255 assert_eq!(app.wall_scroll, 4, "second scroll caps; nothing past");
3256 }
3257
3258 #[test]
3259 fn esc_in_picker_dismisses_overlay_only_keeps_modal_open() {
3260 let mut app = App::new();
3266 app.replace_team(fixture_team_with_channels(
3267 vec![agent("writing:manager", AgentState::Running)],
3268 vec![
3269 channel("writing:all", "writing"),
3270 channel("writing:editorial", "writing"),
3271 ],
3272 ));
3273 app.dismiss_splash();
3274 dispatch(&mut app, key(KeyCode::Char('!')));
3275 assert!(app.compose_picker_open);
3276 assert_eq!(app.stage, Stage::ComposeModal);
3277 dispatch(&mut app, key(KeyCode::Esc));
3278 assert!(!app.compose_picker_open, "picker dismissed");
3279 assert_eq!(app.stage, Stage::ComposeModal, "compose modal stays open");
3280 }
3281
3282 #[test]
3283 fn send_routes_broadcast_through_mock_sender_via_picker() {
3284 use crate::compose::test_support::MockMessageSender;
3290 let sender = MockMessageSender::default();
3291 let mailbox = EmptyMailbox;
3292 let mut app = App::new();
3293 app.replace_team(fixture_team_with_channels(
3294 vec![agent("writing:manager", AgentState::Running)],
3295 vec![
3296 channel("writing:all", "writing"),
3297 channel("writing:editorial", "writing"),
3298 channel("writing:critique", "writing"),
3299 ],
3300 ));
3301 app.dismiss_splash();
3302 super::handle_event(
3305 &mut app,
3306 key(KeyCode::Char('!')),
3307 &NoopDecider,
3308 &sender,
3309 &mailbox,
3310 &crate::keysender::test_support::MockKeySender::default(),
3311 );
3312 super::handle_event(
3313 &mut app,
3314 key(KeyCode::Char('j')),
3315 &NoopDecider,
3316 &sender,
3317 &mailbox,
3318 &crate::keysender::test_support::MockKeySender::default(),
3319 );
3320 super::handle_event(
3321 &mut app,
3322 key(KeyCode::Enter),
3323 &NoopDecider,
3324 &sender,
3325 &mailbox,
3326 &crate::keysender::test_support::MockKeySender::default(),
3327 );
3328 for c in "ship docs".chars() {
3329 super::handle_event(
3330 &mut app,
3331 key(KeyCode::Char(c)),
3332 &NoopDecider,
3333 &sender,
3334 &mailbox,
3335 &crate::keysender::test_support::MockKeySender::default(),
3336 );
3337 }
3338 super::handle_event(
3339 &mut app,
3340 key_with(KeyCode::Enter, crossterm::event::KeyModifiers::CONTROL),
3341 &NoopDecider,
3342 &sender,
3343 &mailbox,
3344 &crate::keysender::test_support::MockKeySender::default(),
3345 );
3346 let dm_calls = sender.dm_calls.lock().unwrap().clone();
3347 let bcast_calls = sender.broadcast_calls.lock().unwrap().clone();
3348 assert!(dm_calls.is_empty(), "broadcast must not route via send_dm");
3349 assert_eq!(bcast_calls.len(), 1);
3350 assert_eq!(
3351 bcast_calls[0].0, "writing:editorial",
3352 "channel id from picker selection"
3353 );
3354 assert_eq!(bcast_calls[0].1, "ship docs");
3355 assert_eq!(app.stage, Stage::Triptych, "modal closes on send");
3356 }
3357
3358 #[test]
3359 fn bang_chord_opens_picker_when_channels_available() {
3360 let mut app = App::new();
3361 app.replace_team(fixture_team_with_channels(
3362 vec![agent("writing:manager", AgentState::Running)],
3363 vec![
3364 channel("writing:all", "writing"),
3365 channel("writing:editorial", "writing"),
3366 channel("writing:critique", "writing"),
3367 ],
3368 ));
3369 app.dismiss_splash();
3370 dispatch(&mut app, key(KeyCode::Char('!')));
3371 assert_eq!(app.stage, Stage::ComposeModal);
3372 assert!(app.compose_picker_open);
3373 dispatch(&mut app, key(KeyCode::Char('j')));
3375 assert_eq!(app.compose_picker_index, 1);
3376 dispatch(&mut app, key(KeyCode::Enter));
3378 assert!(!app.compose_picker_open, "picker closes on confirm");
3379 match app.compose_target.as_ref() {
3380 Some(crate::compose::ComposeTarget::Broadcast { channel_id, .. }) => {
3381 assert_eq!(channel_id, "writing:editorial");
3382 }
3383 other => panic!("expected Broadcast target, got {other:?}"),
3384 }
3385 }
3386
3387 #[test]
3388 fn mailbox_first_layout_seeds_channel_selection_on_entry() {
3389 let mut app = App::new();
3390 app.replace_team(fixture_team_with_channels(
3391 vec![agent("writing:manager", AgentState::Running)],
3392 vec![
3393 channel("writing:all", "writing"),
3394 channel("writing:editorial", "writing"),
3395 ],
3396 ));
3397 app.dismiss_splash();
3398 assert!(app.selected_channel.is_none());
3399 app.toggle_mailbox_first_layout();
3400 assert_eq!(app.selected_channel, Some(0));
3401 }
3402
3403 #[test]
3404 fn help_overlay_opens_on_question_mark_closes_on_esc() {
3405 let mut app = App::new();
3406 app.dismiss_splash();
3407 dispatch(&mut app, key(KeyCode::Char('?')));
3408 assert_eq!(app.stage, Stage::HelpOverlay);
3409 dispatch(&mut app, key(KeyCode::Esc));
3410 assert_eq!(app.stage, Stage::Triptych);
3411 }
3412
3413 #[test]
3414 fn tutorial_opens_on_t_advances_and_closes() {
3415 let mut app = App::new();
3416 app.dismiss_splash();
3417 dispatch(&mut app, key(KeyCode::Char('t')));
3418 assert_eq!(app.stage, Stage::Tutorial);
3419 assert_eq!(app.tutorial_step, 0);
3420 dispatch(&mut app, key(KeyCode::Char(' ')));
3422 assert_eq!(app.tutorial_step, 1);
3423 dispatch(&mut app, key(KeyCode::Char('k')));
3425 assert_eq!(app.tutorial_step, 0);
3426 dispatch(&mut app, key(KeyCode::Esc));
3428 assert_eq!(app.stage, Stage::Triptych);
3429 }
3430
3431 #[test]
3432 fn tutorial_walk_back_at_step_zero_is_no_op() {
3433 let mut app = App::new();
3438 app.dismiss_splash();
3439 app.enter_tutorial();
3440 assert_eq!(app.tutorial_step, 0);
3441 dispatch(&mut app, key(KeyCode::Char('k')));
3442 assert_eq!(app.tutorial_step, 0, "step-0 walk-back is no-op");
3443 assert_eq!(app.stage, Stage::Tutorial);
3446 }
3447
3448 #[test]
3449 fn ctrl_pipe_adds_vertical_split_ctrl_minus_adds_horizontal() {
3450 use crossterm::event::KeyModifiers;
3451 let mut app = App::new();
3452 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3453 app.dismiss_splash();
3454 dispatch(
3455 &mut app,
3456 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
3457 );
3458 dispatch(
3459 &mut app,
3460 key_with(KeyCode::Char('-'), KeyModifiers::CONTROL),
3461 );
3462 assert_eq!(app.detail_splits.len(), 2);
3463 assert_eq!(app.detail_splits[0].1, SplitOrientation::Vertical);
3464 assert_eq!(app.detail_splits[1].1, SplitOrientation::Horizontal);
3465 }
3466
3467 #[test]
3468 fn ctrl_w_q_chord_prefix_closes_focused_split() {
3469 use crossterm::event::KeyModifiers;
3470 let mut app = App::new();
3471 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3472 app.dismiss_splash();
3473 dispatch(
3476 &mut app,
3477 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
3478 );
3479 dispatch(
3480 &mut app,
3481 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
3482 );
3483 dispatch(
3484 &mut app,
3485 key_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
3486 );
3487 assert_eq!(app.pending_chord, Some(KeyCode::Char('w')));
3488 dispatch(&mut app, key(KeyCode::Char('q')));
3491 assert_eq!(app.detail_splits.len(), 1);
3492 assert_eq!(app.stage, Stage::Triptych, "must not enter quit confirm");
3493 assert_eq!(app.pending_chord, None, "chord cleared");
3494 }
3495
3496 #[test]
3497 fn ctrl_w_o_chord_keeps_only_focused_split() {
3498 use crossterm::event::KeyModifiers;
3499 let mut app = App::new();
3500 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3501 app.dismiss_splash();
3502 for _ in 0..3 {
3503 dispatch(
3504 &mut app,
3505 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
3506 );
3507 }
3508 app.selected_split = 1;
3510 let kept_id = app.detail_splits[1].0.clone();
3511 dispatch(
3512 &mut app,
3513 key_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
3514 );
3515 dispatch(&mut app, key(KeyCode::Char('o')));
3516 assert_eq!(app.detail_splits.len(), 1);
3517 assert_eq!(app.detail_splits[0].0, kept_id);
3518 assert_eq!(app.selected_split, 0);
3519 }
3520
3521 #[test]
3522 fn add_detail_split_saturates_at_four_with_explicit_4_and_5_calls() {
3523 let mut app = App::new();
3528 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3529 for _ in 0..4 {
3530 app.add_detail_split();
3531 }
3532 assert_eq!(app.detail_splits.len(), 4);
3533 let snapshot_len = app.detail_splits.len();
3534 app.add_detail_split();
3535 assert_eq!(app.detail_splits.len(), snapshot_len, "5th call rejected");
3536 }
3537
3538 #[test]
3539 fn replace_approvals_clamps_selection_in_range() {
3540 let mut app = App::new();
3541 app.replace_approvals(vec![ap(1), ap(2), ap(3)]);
3542 app.selected_approval = 2;
3543 app.replace_approvals(vec![ap(1), ap(2)]);
3545 assert_eq!(app.selected_approval, 1, "clamps to last index");
3546 }
3547
3548 #[test]
3549 fn arrow_keys_navigate_only_when_roster_focused() {
3550 let mut app = App::new();
3551 app.replace_team(fixture_team(vec![
3552 agent("p:a", AgentState::Running),
3553 agent("p:b", AgentState::Running),
3554 ]));
3555 app.dismiss_splash();
3556 app.selected_agent = Some(0);
3558 dispatch(&mut app, key(KeyCode::Down));
3559 assert_eq!(app.selected_agent, Some(1));
3560 app.cycle_focus();
3562 dispatch(&mut app, key(KeyCode::Down));
3563 assert_eq!(
3564 app.selected_agent,
3565 Some(1),
3566 "non-roster focus ignores arrows"
3567 );
3568 }
3569
3570 fn stream_keys_fixture() -> App {
3576 let mut app = App::new();
3577 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3578 app.dismiss_splash();
3579 app.cycle_focus(); assert_eq!(app.focused_pane, Pane::Detail);
3581 assert_eq!(app.selected_agent, Some(0));
3582 app
3583 }
3584
3585 fn stream_dispatch(
3586 app: &mut App,
3587 ev: Event,
3588 key_sender: &crate::keysender::test_support::MockKeySender,
3589 ) {
3590 super::handle_event(
3591 app,
3592 ev,
3593 &NoopDecider,
3594 &NoopSender,
3595 &EmptyMailbox,
3596 key_sender,
3597 );
3598 }
3599
3600 #[test]
3601 fn ctrl_e_enters_stream_keys_when_detail_focused() {
3602 use crate::keysender::test_support::MockKeySender;
3603 use crossterm::event::KeyModifiers;
3604 let mut app = stream_keys_fixture();
3605 let ks = MockKeySender::default();
3606 stream_dispatch(
3607 &mut app,
3608 key_with(KeyCode::Char('e'), KeyModifiers::CONTROL),
3609 &ks,
3610 );
3611 assert_eq!(app.stage, Stage::StreamKeys);
3612 assert!(
3613 ks.calls.lock().unwrap().is_empty(),
3614 "the activation chord itself never forwards a keystroke"
3615 );
3616 }
3617
3618 #[test]
3619 fn ctrl_e_no_op_when_detail_not_focused() {
3620 use crate::keysender::test_support::MockKeySender;
3625 use crossterm::event::KeyModifiers;
3626 let mut app = App::new();
3627 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3628 app.dismiss_splash();
3629 assert_eq!(app.focused_pane, Pane::Roster);
3630 let ks = MockKeySender::default();
3631 stream_dispatch(
3632 &mut app,
3633 key_with(KeyCode::Char('e'), KeyModifiers::CONTROL),
3634 &ks,
3635 );
3636 assert_eq!(app.stage, Stage::Triptych);
3637 }
3638
3639 #[test]
3640 fn ctrl_e_no_op_when_no_agent_selected() {
3641 use crate::keysender::test_support::MockKeySender;
3644 use crossterm::event::KeyModifiers;
3645 let mut app = App::new();
3646 app.dismiss_splash();
3647 app.cycle_focus(); assert_eq!(app.selected_agent, None);
3649 let ks = MockKeySender::default();
3650 stream_dispatch(
3651 &mut app,
3652 key_with(KeyCode::Char('e'), KeyModifiers::CONTROL),
3653 &ks,
3654 );
3655 assert_eq!(app.stage, Stage::Triptych);
3656 }
3657
3658 #[test]
3659 fn esc_forwards_to_pane_in_stream_keys() {
3660 use crate::keysender::test_support::MockKeySender;
3664 use crossterm::event::KeyModifiers;
3665 let mut app = stream_keys_fixture();
3666 app.enter_stream_keys();
3667 assert_eq!(app.stage, Stage::StreamKeys);
3668 let ks = MockKeySender::default();
3669 stream_dispatch(&mut app, key_with(KeyCode::Esc, KeyModifiers::NONE), &ks);
3670 assert_eq!(
3671 app.stage,
3672 Stage::StreamKeys,
3673 "Esc does NOT exit stream-keys"
3674 );
3675 let calls = ks.calls.lock().unwrap();
3676 assert_eq!(calls.len(), 1, "Esc forwards as one keystroke");
3677 assert_eq!(calls[0].0, "t-p-a");
3678 assert_eq!(calls[0].1.args, vec!["Escape".to_string()]);
3679 }
3680
3681 #[test]
3682 fn ctrl_e_exits_stream_keys() {
3683 use crate::keysender::test_support::MockKeySender;
3686 use crossterm::event::KeyModifiers;
3687 let mut app = stream_keys_fixture();
3688 app.enter_stream_keys();
3689 assert_eq!(app.stage, Stage::StreamKeys);
3690 let ks = MockKeySender::default();
3691 stream_dispatch(
3692 &mut app,
3693 key_with(KeyCode::Char('e'), KeyModifiers::CONTROL),
3694 &ks,
3695 );
3696 assert_eq!(app.stage, Stage::Triptych);
3697 assert!(
3698 ks.calls.lock().unwrap().is_empty(),
3699 "Ctrl+E is the exit chord — it must not forward as a keystroke"
3700 );
3701 }
3702
3703 #[test]
3704 fn stream_mode_forwards_printable_chars_to_target_session() {
3705 use crate::keysender::test_support::MockKeySender;
3706 let mut app = stream_keys_fixture();
3707 app.enter_stream_keys();
3708 let ks = MockKeySender::default();
3709 for c in "hi".chars() {
3710 stream_dispatch(&mut app, key(KeyCode::Char(c)), &ks);
3711 }
3712 let calls = ks.calls.lock().unwrap();
3713 assert_eq!(calls.len(), 2, "one tmux send-keys per keystroke");
3714 assert_eq!(calls[0].0, "t-p-a");
3717 assert_eq!(calls[0].1.args, vec!["-l".to_string(), "h".to_string()]);
3718 assert_eq!(calls[1].1.args, vec!["-l".to_string(), "i".to_string()]);
3719 }
3720
3721 #[test]
3722 fn stream_mode_passes_ctrl_c_through_to_agent() {
3723 use crate::keysender::test_support::MockKeySender;
3727 use crossterm::event::KeyModifiers;
3728 let mut app = stream_keys_fixture();
3729 app.enter_stream_keys();
3730 let ks = MockKeySender::default();
3731 stream_dispatch(
3732 &mut app,
3733 key_with(KeyCode::Char('c'), KeyModifiers::CONTROL),
3734 &ks,
3735 );
3736 assert_eq!(app.stage, Stage::StreamKeys, "Ctrl+C does NOT exit");
3737 let calls = ks.calls.lock().unwrap();
3738 assert_eq!(calls.len(), 1);
3739 assert_eq!(calls[0].1.args, vec!["C-c".to_string()]);
3740 }
3741
3742 #[test]
3743 fn stream_mode_forwards_enter_and_arrows() {
3744 use crate::keysender::test_support::MockKeySender;
3745 let mut app = stream_keys_fixture();
3746 app.enter_stream_keys();
3747 let ks = MockKeySender::default();
3748 stream_dispatch(&mut app, key(KeyCode::Enter), &ks);
3749 stream_dispatch(&mut app, key(KeyCode::Up), &ks);
3750 let calls = ks.calls.lock().unwrap();
3751 assert_eq!(calls[0].1.args, vec!["Enter".to_string()]);
3752 assert_eq!(calls[1].1.args, vec!["Up".to_string()]);
3753 }
3754
3755 #[test]
3756 fn stream_target_session_uses_focused_split_when_present() {
3757 let mut app = App::new();
3762 app.replace_team(fixture_team(vec![
3763 agent("p:a", AgentState::Running),
3764 agent("p:b", AgentState::Running),
3765 ]));
3766 app.dismiss_splash();
3767 app.cycle_focus(); app.selected_agent = Some(0);
3769 app.detail_splits
3771 .push(("p:b".into(), crate::app::SplitOrientation::Vertical));
3772 app.selected_split = 1; let target = app.stream_target_session();
3774 assert_eq!(
3775 target.as_deref(),
3776 Some("t-p-b"),
3777 "selected split's agent drives the target"
3778 );
3779 }
3780
3781 #[test]
3782 fn stream_mode_drops_back_when_target_session_disappears() {
3783 use crate::keysender::test_support::MockKeySender;
3788 let mut app = stream_keys_fixture();
3789 app.enter_stream_keys();
3790 app.selected_agent = None;
3792 app.team.agents.clear();
3793 let ks = MockKeySender::default();
3794 stream_dispatch(&mut app, key(KeyCode::Char('a')), &ks);
3795 assert_eq!(app.stage, Stage::Triptych);
3796 assert!(ks.calls.lock().unwrap().is_empty());
3797 }
3798
3799 #[test]
3802 fn recapture_focused_pane_sets_buffer_and_advances_clock() {
3803 use crate::pane::test_support::MockPaneSource;
3808 let mut app = App::new();
3809 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3810 app.dismiss_splash();
3811 assert_eq!(app.selected_agent, Some(0));
3812 let mock = MockPaneSource {
3813 lines: vec!["hello".into(), "world".into()],
3814 asked: std::sync::Mutex::new(Vec::new()),
3815 };
3816 let before = Instant::now() - PANE_REFRESH_INTERVAL;
3818 app.last_pane_refresh = before;
3819
3820 super::recapture_focused_pane(&mut app, &mock);
3821
3822 assert_eq!(app.detail_buffer, vec!["hello", "world"]);
3823 assert_eq!(mock.asked.lock().unwrap().clone(), vec!["t-p-a"]);
3825 assert!(
3826 app.last_pane_refresh > before,
3827 "re-capture advances the fast-cadence clock"
3828 );
3829 }
3830
3831 #[test]
3832 fn recapture_focused_pane_no_op_when_no_agent_focused() {
3833 use crate::pane::test_support::MockPaneSource;
3836 let mut app = App::new();
3837 app.dismiss_splash();
3838 assert_eq!(app.selected_agent, None);
3839 let mock = MockPaneSource {
3840 lines: vec!["unused".into()],
3841 asked: std::sync::Mutex::new(Vec::new()),
3842 };
3843
3844 super::recapture_focused_pane(&mut app, &mock);
3845
3846 assert!(
3847 mock.asked.lock().unwrap().is_empty(),
3848 "no focused agent → no capture call"
3849 );
3850 assert!(
3851 app.detail_buffer.is_empty(),
3852 "detail buffer untouched with no agent"
3853 );
3854 }
3855
3856 #[test]
3857 fn recapture_focused_pane_no_op_when_selection_cleared() {
3858 use crate::pane::test_support::MockPaneSource;
3861 let mut app = App::new();
3862 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3863 app.dismiss_splash();
3864 app.selected_agent = None;
3865 let mock = MockPaneSource {
3866 lines: vec!["unused".into()],
3867 asked: std::sync::Mutex::new(Vec::new()),
3868 };
3869
3870 super::recapture_focused_pane(&mut app, &mock);
3871
3872 assert!(mock.asked.lock().unwrap().is_empty());
3873 assert!(app.detail_buffer.is_empty());
3874 }
3875
3876 fn stream_keys_fixture_two_agents() -> App {
3882 let mut app = App::new();
3883 app.replace_team(fixture_team(vec![
3884 agent("p:a", AgentState::Running),
3885 agent("p:b", AgentState::Running),
3886 ]));
3887 app.dismiss_splash();
3888 app.cycle_focus(); assert_eq!(app.focused_pane, Pane::Detail);
3890 assert_eq!(app.selected_agent, Some(0));
3891 app.enter_stream_keys();
3892 assert_eq!(app.stage, Stage::StreamKeys);
3893 app
3894 }
3895
3896 #[test]
3897 fn ctrl_shift_down_moves_selection_to_next_agent_no_split() {
3898 use crate::keysender::test_support::MockKeySender;
3902 let mut app = stream_keys_fixture_two_agents();
3903 let ks = MockKeySender::default();
3904 stream_dispatch(
3905 &mut app,
3906 key_with(KeyCode::Down, KeyModifiers::CONTROL | KeyModifiers::SHIFT),
3907 &ks,
3908 );
3909 assert_eq!(app.selected_agent, Some(1), "switched to next agent");
3910 assert_eq!(app.stage, Stage::StreamKeys, "stays in stream-keys");
3911 assert!(
3912 ks.calls.lock().unwrap().is_empty(),
3913 "the switch chord never forwards a keystroke"
3914 );
3915 }
3916
3917 #[test]
3918 fn ctrl_shift_up_moves_selection_to_prev_agent_no_split() {
3919 use crate::keysender::test_support::MockKeySender;
3922 let mut app = stream_keys_fixture_two_agents();
3923 let ks = MockKeySender::default();
3924 stream_dispatch(
3925 &mut app,
3926 key_with(KeyCode::Up, KeyModifiers::CONTROL | KeyModifiers::SHIFT),
3927 &ks,
3928 );
3929 assert_eq!(
3930 app.selected_agent,
3931 Some(1),
3932 "Up from agent 0 wraps to the last agent"
3933 );
3934 assert_eq!(app.stage, Stage::StreamKeys);
3935 assert!(ks.calls.lock().unwrap().is_empty());
3936 }
3937
3938 #[test]
3939 fn ctrl_shift_switch_no_op_when_split_focused() {
3940 use crate::keysender::test_support::MockKeySender;
3947 for code in [KeyCode::Up, KeyCode::Down] {
3948 let mut app = stream_keys_fixture_two_agents();
3949 app.detail_splits
3952 .push(("p:b".into(), SplitOrientation::Vertical));
3953 app.selected_split = 1;
3954 let ks = MockKeySender::default();
3955 stream_dispatch(
3956 &mut app,
3957 key_with(code, KeyModifiers::CONTROL | KeyModifiers::SHIFT),
3958 &ks,
3959 );
3960 assert_eq!(
3961 app.selected_agent,
3962 Some(0),
3963 "split focused → selection must not move ({code:?})"
3964 );
3965 assert_eq!(app.stage, Stage::StreamKeys);
3966 assert!(
3967 ks.calls.lock().unwrap().is_empty(),
3968 "split-focused switch chord is consumed, not forwarded ({code:?})"
3969 );
3970 }
3971 }
3972
3973 #[test]
3974 fn ctrl_shift_switch_single_agent_is_no_op() {
3975 use crate::keysender::test_support::MockKeySender;
3979 let mut app = stream_keys_fixture(); app.enter_stream_keys();
3981 assert_eq!(app.selected_agent, Some(0));
3982 let ks = MockKeySender::default();
3983 stream_dispatch(
3984 &mut app,
3985 key_with(KeyCode::Down, KeyModifiers::CONTROL | KeyModifiers::SHIFT),
3986 &ks,
3987 );
3988 assert_eq!(app.selected_agent, Some(0), "single agent → stays at 0");
3989 assert_eq!(app.stage, Stage::StreamKeys);
3990 assert!(ks.calls.lock().unwrap().is_empty());
3991 }
3992
3993 fn pane_sync_fixture() -> App {
3996 let mut app = App::new();
3997 app.team = fixture_team(vec![
3998 agent("hello:mgr", AgentState::Running),
3999 agent("hello:dev", AgentState::Running),
4000 ]);
4001 app.selected_agent = Some(0);
4002 app.stage = Stage::Triptych;
4003 app.layout = MainLayout::Triptych;
4004 app
4005 }
4006
4007 #[test]
4008 fn sync_fires_resize_on_first_frame() {
4009 let mut app = pane_sync_fixture();
4010 let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
4011 sync_focused_pane_size_to(
4012 &mut app,
4013 ratatui::layout::Rect::new(0, 0, 120, 40),
4014 &resizer,
4015 );
4016 let calls = resizer.calls.lock().unwrap();
4017 assert_eq!(calls.len(), 1);
4020 assert_eq!(calls[0].0, "t-hello-mgr");
4021 assert_eq!(calls[0].1, 90); assert_eq!(calls[0].2, 22); }
4024
4025 #[test]
4026 fn sync_skips_when_size_unchanged() {
4027 let mut app = pane_sync_fixture();
4028 let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
4029 sync_focused_pane_size_to(
4031 &mut app,
4032 ratatui::layout::Rect::new(0, 0, 120, 40),
4033 &resizer,
4034 );
4035 sync_focused_pane_size_to(
4036 &mut app,
4037 ratatui::layout::Rect::new(0, 0, 120, 40),
4038 &resizer,
4039 );
4040 assert_eq!(resizer.calls.lock().unwrap().len(), 1);
4041 }
4042
4043 #[test]
4044 fn sync_fires_again_when_terminal_resizes() {
4045 let mut app = pane_sync_fixture();
4046 let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
4047 sync_focused_pane_size_to(
4048 &mut app,
4049 ratatui::layout::Rect::new(0, 0, 120, 40),
4050 &resizer,
4051 );
4052 sync_focused_pane_size_to(
4054 &mut app,
4055 ratatui::layout::Rect::new(0, 0, 200, 60),
4056 &resizer,
4057 );
4058 let calls = resizer.calls.lock().unwrap();
4059 assert_eq!(calls.len(), 2);
4060 assert_eq!(calls[0].1, 90); assert_eq!(calls[0].2, 22); assert_eq!(calls[1].1, 170); assert_eq!(calls[1].2, 34);
4065 }
4066
4067 #[test]
4068 fn sync_fires_on_focus_switch_to_unsynced_session() {
4069 let mut app = pane_sync_fixture();
4070 let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
4071 sync_focused_pane_size_to(
4072 &mut app,
4073 ratatui::layout::Rect::new(0, 0, 120, 40),
4074 &resizer,
4075 );
4076 app.selected_agent = Some(1);
4078 sync_focused_pane_size_to(
4079 &mut app,
4080 ratatui::layout::Rect::new(0, 0, 120, 40),
4081 &resizer,
4082 );
4083 let calls = resizer.calls.lock().unwrap();
4084 assert_eq!(calls.len(), 2);
4085 assert_eq!(calls[0].0, "t-hello-mgr");
4086 assert_eq!(calls[1].0, "t-hello-dev");
4087 }
4088
4089 #[test]
4090 fn sync_is_noop_when_no_agent_focused() {
4091 let mut app = pane_sync_fixture();
4092 app.selected_agent = None;
4093 let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
4094 sync_focused_pane_size_to(
4095 &mut app,
4096 ratatui::layout::Rect::new(0, 0, 120, 40),
4097 &resizer,
4098 );
4099 assert!(resizer.calls.lock().unwrap().is_empty());
4100 }
4101
4102 #[test]
4103 fn sync_is_noop_when_layout_is_not_triptych() {
4104 let mut app = pane_sync_fixture();
4105 app.layout = MainLayout::Wall;
4106 let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
4107 sync_focused_pane_size_to(
4108 &mut app,
4109 ratatui::layout::Rect::new(0, 0, 120, 40),
4110 &resizer,
4111 );
4112 assert!(resizer.calls.lock().unwrap().is_empty());
4115 }
4116
4117 #[test]
4118 fn sync_is_noop_on_degenerate_terminal_area() {
4119 let mut app = pane_sync_fixture();
4120 let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
4121 sync_focused_pane_size_to(&mut app, ratatui::layout::Rect::new(0, 0, 28, 40), &resizer);
4123 assert!(resizer.calls.lock().unwrap().is_empty());
4124 }
4125
4126 #[test]
4127 fn sync_accounts_for_approvals_stripe_when_present() {
4128 let mut app = pane_sync_fixture();
4129 app.pending_approvals = vec![crate::approvals::Approval {
4131 id: 1,
4132 project_id: "hello".into(),
4133 agent_id: "hello:dev".into(),
4134 action: "test".into(),
4135 summary: "test approval".into(),
4136 payload_json: String::new(),
4137 }];
4138 assert!(app.has_pending_approvals());
4139 let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
4140 sync_focused_pane_size_to(
4141 &mut app,
4142 ratatui::layout::Rect::new(0, 0, 120, 40),
4143 &resizer,
4144 );
4145 let calls = resizer.calls.lock().unwrap();
4146 assert_eq!(calls.len(), 1);
4149 assert_eq!(calls[0].2, 21);
4150 }
4151
4152 fn app_with_mailbox_focused() -> App {
4158 let mut app = App::new();
4159 app.dismiss_splash();
4160 app.cycle_focus();
4162 app.cycle_focus();
4163 assert_eq!(app.focused_pane, Pane::Mailbox);
4164 app
4165 }
4166
4167 #[test]
4168 fn f_opens_filter_input_when_mailbox_focused() {
4169 let mut app = app_with_mailbox_focused();
4170 assert!(app.mailbox_input_mode.is_none());
4171 dispatch(&mut app, key(KeyCode::Char('f')));
4172 assert_eq!(app.mailbox_input_mode, Some(MailboxInputKind::Filter));
4173 }
4174
4175 #[test]
4176 fn slash_opens_search_input_when_mailbox_focused() {
4177 let mut app = app_with_mailbox_focused();
4178 dispatch(&mut app, key(KeyCode::Char('/')));
4179 assert_eq!(app.mailbox_input_mode, Some(MailboxInputKind::Search));
4180 }
4181
4182 #[test]
4183 fn f_does_not_open_filter_when_roster_focused() {
4184 let mut app = App::new();
4188 app.dismiss_splash();
4189 assert_eq!(app.focused_pane, Pane::Roster);
4190 dispatch(&mut app, key(KeyCode::Char('f')));
4191 assert!(app.mailbox_input_mode.is_none());
4192 }
4193
4194 #[test]
4195 fn typing_into_filter_input_mutates_active_tab_buffer() {
4196 let mut app = app_with_mailbox_focused();
4197 dispatch(&mut app, key(KeyCode::Char('f')));
4198 dispatch(&mut app, key(KeyCode::Char('a')));
4199 dispatch(&mut app, key(KeyCode::Char('d')));
4200 dispatch(&mut app, key(KeyCode::Char('a')));
4201 assert_eq!(app.mailbox.filter_text(app.mailbox_tab), "ada");
4202 assert_eq!(app.mailbox.filter_text(MailboxTab::Sent), "");
4204 }
4205
4206 #[test]
4207 fn backspace_pops_input_buffer() {
4208 let mut app = app_with_mailbox_focused();
4209 dispatch(&mut app, key(KeyCode::Char('/')));
4210 for c in "abc".chars() {
4211 dispatch(&mut app, key(KeyCode::Char(c)));
4212 }
4213 assert_eq!(app.mailbox.search_text(app.mailbox_tab), "abc");
4214 dispatch(&mut app, key(KeyCode::Backspace));
4215 assert_eq!(app.mailbox.search_text(app.mailbox_tab), "ab");
4216 }
4217
4218 #[test]
4219 fn enter_confirms_keeps_typed_text() {
4220 let mut app = app_with_mailbox_focused();
4221 dispatch(&mut app, key(KeyCode::Char('f')));
4222 for c in "kian".chars() {
4223 dispatch(&mut app, key(KeyCode::Char(c)));
4224 }
4225 dispatch(&mut app, key(KeyCode::Enter));
4226 assert!(
4227 app.mailbox_input_mode.is_none(),
4228 "input must close on Enter"
4229 );
4230 assert_eq!(
4231 app.mailbox.filter_text(app.mailbox_tab),
4232 "kian",
4233 "Enter must keep the typed text (confirm-keep semantics)"
4234 );
4235 }
4236
4237 #[test]
4238 fn esc_cancels_reverts_to_snapshot() {
4239 let mut app = app_with_mailbox_focused();
4240 app.mailbox
4242 .set_input(app.mailbox_tab, MailboxInputKind::Filter, "previous".into());
4243 dispatch(&mut app, key(KeyCode::Char('f')));
4244 dispatch(&mut app, key(KeyCode::Backspace));
4246 dispatch(&mut app, key(KeyCode::Backspace));
4247 dispatch(&mut app, key(KeyCode::Char('x')));
4248 assert_eq!(app.mailbox.filter_text(app.mailbox_tab), "previox");
4249 dispatch(&mut app, key(KeyCode::Esc));
4251 assert!(app.mailbox_input_mode.is_none());
4252 assert_eq!(
4253 app.mailbox.filter_text(app.mailbox_tab),
4254 "previous",
4255 "Esc must revert the active buffer to the pre-open snapshot"
4256 );
4257 }
4258
4259 #[test]
4260 fn open_input_swallows_pr1_cursor_keys() {
4261 let mut app = app_with_mailbox_focused();
4265 app.mailbox.extend(
4267 app.mailbox_tab,
4268 (1..=10)
4269 .map(|i| crate::mailbox::MessageRow {
4270 id: i,
4271 sender: "p:a".into(),
4272 recipient: "p:dev".into(),
4273 text: "x".into(),
4274 sent_at: 0.0,
4275 })
4276 .collect(),
4277 );
4278 let seated = app.mailbox.cursor(app.mailbox_tab).selected_idx;
4279 assert_eq!(seated, 9, "extend seats cursor at tail (PR-1 contract)");
4280 dispatch(&mut app, key(KeyCode::Char('f')));
4282 dispatch(&mut app, key(KeyCode::Up));
4283 dispatch(&mut app, key(KeyCode::PageUp));
4284 dispatch(&mut app, key(KeyCode::Home));
4285 assert_eq!(app.mailbox.cursor(app.mailbox_tab).selected_idx, 9);
4290 }
4291
4292 #[test]
4293 fn ctrl_modifier_char_does_not_inject_into_input() {
4294 let mut app = app_with_mailbox_focused();
4300 dispatch(&mut app, key(KeyCode::Char('f'))); dispatch(
4302 &mut app,
4303 key_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
4304 );
4305 dispatch(
4306 &mut app,
4307 key_with(KeyCode::Char('c'), KeyModifiers::CONTROL),
4308 );
4309 dispatch(&mut app, key_with(KeyCode::Char('a'), KeyModifiers::ALT));
4310 assert_eq!(
4311 app.mailbox.filter_text(app.mailbox_tab),
4312 "",
4313 "modifier+Char combos must not leak into the filter buffer"
4314 );
4315 dispatch(&mut app, key(KeyCode::Char('w')));
4318 assert_eq!(app.mailbox.filter_text(app.mailbox_tab), "w");
4319 dispatch(&mut app, key_with(KeyCode::Char('X'), KeyModifiers::SHIFT));
4322 assert_eq!(app.mailbox.filter_text(app.mailbox_tab), "wX");
4323 }
4324
4325 #[test]
4326 fn open_input_swallows_q_quit() {
4327 let mut app = app_with_mailbox_focused();
4332 dispatch(&mut app, key(KeyCode::Char('f')));
4333 dispatch(&mut app, key(KeyCode::Char('q')));
4334 assert_eq!(
4335 app.stage,
4336 Stage::Triptych,
4337 "q must NOT trigger quit while input is open"
4338 );
4339 assert_eq!(
4340 app.mailbox.filter_text(app.mailbox_tab),
4341 "q",
4342 "q must land in the filter buffer"
4343 );
4344 }
4345
4346 fn seed_inbox_rows(app: &mut App, n: i64) {
4350 let rows: Vec<MessageRow> = (1..=n)
4351 .map(|i| MessageRow {
4352 id: i,
4353 sender: "p:dev".into(),
4354 recipient: "p:mgr".into(),
4355 text: format!("body #{i}"),
4356 sent_at: 1_700_000_000.0 + i as f64,
4357 })
4358 .collect();
4359 app.mailbox.extend(MailboxTab::Inbox, rows);
4360 }
4361
4362 #[test]
4363 fn enter_on_mailbox_opens_detail_modal_with_snapshot() {
4364 let mut app = app_with_mailbox_focused();
4365 seed_inbox_rows(&mut app, 5);
4366 dispatch(&mut app, key(KeyCode::Enter));
4368 assert_eq!(app.stage, Stage::MailboxDetailModal);
4369 let snap = app.mailbox_detail_modal.as_ref().expect("modal open");
4370 assert_eq!(snap.id, 5);
4371 assert_eq!(snap.text, "body #5");
4372 assert_eq!(app.mailbox_detail_scroll, 0, "scroll resets on open");
4373 }
4374
4375 #[test]
4376 fn enter_on_empty_visible_indices_is_noop() {
4377 let mut app = app_with_mailbox_focused();
4379 seed_inbox_rows(&mut app, 3);
4380 app.mailbox.set_input(
4381 MailboxTab::Inbox,
4382 MailboxInputKind::Filter,
4383 "no-such-sender".into(),
4384 );
4385 assert!(app.mailbox.visible_indices(MailboxTab::Inbox).is_empty());
4386 dispatch(&mut app, key(KeyCode::Enter));
4387 assert_eq!(app.stage, Stage::Triptych);
4388 assert!(app.mailbox_detail_modal.is_none());
4389 }
4390
4391 #[test]
4392 fn snapshot_stable_across_underlying_drain() {
4393 let mut app = app_with_mailbox_focused();
4397 seed_inbox_rows(&mut app, 5);
4398 app.mailbox.cursor_home(MailboxTab::Inbox);
4399 app.mailbox.move_cursor_down(MailboxTab::Inbox);
4400 app.mailbox.move_cursor_down(MailboxTab::Inbox); dispatch(&mut app, key(KeyCode::Enter));
4402 let snap_id = app.mailbox_detail_modal.as_ref().expect("open").id;
4403 assert_eq!(snap_id, 3);
4404 let more: Vec<MessageRow> = (6..=600)
4407 .map(|i| MessageRow {
4408 id: i,
4409 sender: "p:dev".into(),
4410 recipient: "p:mgr".into(),
4411 text: format!("body #{i}"),
4412 sent_at: 1_700_000_000.0 + i as f64,
4413 })
4414 .collect();
4415 app.mailbox.extend(MailboxTab::Inbox, more);
4416 let still_there = app
4418 .mailbox
4419 .rows(MailboxTab::Inbox)
4420 .iter()
4421 .any(|r| r.id == 3);
4422 assert!(!still_there, "row id 3 must have been drained");
4423 let snap = app.mailbox_detail_modal.as_ref().expect("still open");
4426 assert_eq!(snap.id, 3, "snapshot id must survive underlying drain");
4427 assert_eq!(snap.text, "body #3");
4428 }
4429
4430 #[test]
4431 fn esc_closes_detail_modal() {
4432 let mut app = app_with_mailbox_focused();
4433 seed_inbox_rows(&mut app, 3);
4434 dispatch(&mut app, key(KeyCode::Enter));
4435 assert_eq!(app.stage, Stage::MailboxDetailModal);
4436 dispatch(&mut app, key(KeyCode::Esc));
4437 assert_eq!(app.stage, Stage::Triptych);
4438 assert!(app.mailbox_detail_modal.is_none());
4439 }
4440
4441 #[test]
4442 fn q_closes_detail_modal() {
4443 let mut app = app_with_mailbox_focused();
4444 seed_inbox_rows(&mut app, 3);
4445 dispatch(&mut app, key(KeyCode::Enter));
4446 dispatch(&mut app, key(KeyCode::Char('q')));
4447 assert_eq!(app.stage, Stage::Triptych);
4448 assert!(app.mailbox_detail_modal.is_none());
4449 }
4450
4451 #[test]
4452 fn j_and_k_scroll_body_in_modal() {
4453 let mut app = app_with_mailbox_focused();
4454 seed_inbox_rows(&mut app, 3);
4455 dispatch(&mut app, key(KeyCode::Enter));
4456 assert_eq!(app.mailbox_detail_scroll, 0);
4457 dispatch(&mut app, key(KeyCode::Char('j')));
4458 dispatch(&mut app, key(KeyCode::Char('j')));
4459 dispatch(&mut app, key(KeyCode::Down));
4460 assert_eq!(app.mailbox_detail_scroll, 3);
4461 dispatch(&mut app, key(KeyCode::Char('k')));
4462 dispatch(&mut app, key(KeyCode::Up));
4463 assert_eq!(app.mailbox_detail_scroll, 1);
4464 for _ in 0..10 {
4466 dispatch(&mut app, key(KeyCode::Char('k')));
4467 }
4468 assert_eq!(app.mailbox_detail_scroll, 0);
4469 }
4470
4471 #[test]
4472 fn unrelated_keys_swallowed_in_modal() {
4473 let mut app = app_with_mailbox_focused();
4476 seed_inbox_rows(&mut app, 3);
4477 dispatch(&mut app, key(KeyCode::Enter));
4478 assert_eq!(app.stage, Stage::MailboxDetailModal);
4479 let focused_before = app.focused_pane;
4480 dispatch(&mut app, key(KeyCode::Char('f')));
4481 dispatch(&mut app, key(KeyCode::Char('/')));
4482 dispatch(&mut app, key(KeyCode::Tab));
4483 assert_eq!(app.stage, Stage::MailboxDetailModal, "stage stays");
4484 assert!(app.mailbox_input_mode.is_none(), "filter/search not opened");
4485 assert_eq!(
4486 app.focused_pane, focused_before,
4487 "Tab must not cycle panes underneath an open modal"
4488 );
4489 }
4490}