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 reports_to: None,
2395 }
2396 }
2397
2398 pub fn fixture_team(agents: Vec<AgentInfo>) -> TeamSnapshot {
2399 TeamSnapshot {
2400 root: std::path::PathBuf::from("/fixture"),
2401 team_name: "fixture".into(),
2402 agents,
2403 channels: Vec::new(),
2404 }
2405 }
2406
2407 #[test]
2408 fn splash_dismissed_by_any_key() {
2409 let mut app = App::new();
2410 assert_eq!(app.stage, Stage::Splash);
2411 dispatch(&mut app, key(KeyCode::Char(' ')));
2412 assert_eq!(app.stage, Stage::Triptych);
2413 }
2414
2415 #[test]
2416 fn tab_cycles_panes_uniformly_and_wraps_through_mailbox() {
2417 let mut app = App::new();
2424 app.dismiss_splash();
2425 assert_eq!(app.focused_pane, Pane::Roster);
2426 dispatch(&mut app, key(KeyCode::Tab));
2427 assert_eq!(app.focused_pane, Pane::Detail);
2428 dispatch(&mut app, key(KeyCode::Tab));
2429 assert_eq!(app.focused_pane, Pane::Mailbox);
2430 assert_eq!(
2431 app.mailbox_tab,
2432 MailboxTab::Inbox,
2433 "Tab into mailbox does NOT touch the active mailbox tab"
2434 );
2435 dispatch(&mut app, key(KeyCode::Tab));
2436 assert_eq!(
2437 app.focused_pane,
2438 Pane::Roster,
2439 "Tab from mailbox wraps to roster, not into mailbox subtabs"
2440 );
2441 assert_eq!(
2442 app.mailbox_tab,
2443 MailboxTab::Inbox,
2444 "mailbox tab still untouched"
2445 );
2446 }
2447
2448 #[test]
2449 fn arrow_keys_walk_mailbox_tabs_when_mailbox_focused() {
2450 let mut app = App::new();
2455 app.dismiss_splash();
2456 dispatch(&mut app, key(KeyCode::Tab));
2458 dispatch(&mut app, key(KeyCode::Tab));
2459 assert_eq!(app.focused_pane, Pane::Mailbox);
2460 assert_eq!(app.mailbox_tab, MailboxTab::Inbox);
2461
2462 dispatch(&mut app, key(KeyCode::Right));
2463 assert_eq!(app.mailbox_tab, MailboxTab::Sent);
2464 dispatch(&mut app, key(KeyCode::Right));
2465 assert_eq!(app.mailbox_tab, MailboxTab::Channel);
2466 dispatch(&mut app, key(KeyCode::Right));
2467 assert_eq!(app.mailbox_tab, MailboxTab::Wire);
2468 dispatch(&mut app, key(KeyCode::Right));
2469 assert_eq!(app.mailbox_tab, MailboxTab::Inbox, "→ wraps");
2470
2471 dispatch(&mut app, key(KeyCode::Left));
2472 assert_eq!(app.mailbox_tab, MailboxTab::Wire, "← walks back");
2473 }
2474
2475 #[test]
2476 fn arrow_keys_no_op_when_mailbox_not_focused() {
2477 let mut app = App::new();
2480 app.dismiss_splash();
2481 assert_eq!(app.focused_pane, Pane::Roster);
2482 let initial = app.mailbox_tab;
2483 dispatch(&mut app, key(KeyCode::Right));
2484 dispatch(&mut app, key(KeyCode::Left));
2485 assert_eq!(
2486 app.mailbox_tab, initial,
2487 "←/→ from non-mailbox panes must not flip the active tab"
2488 );
2489 }
2490
2491 #[test]
2492 fn brackets_no_longer_cycle_mailbox_tabs() {
2493 let mut app = App::new();
2498 app.dismiss_splash();
2499 dispatch(&mut app, key(KeyCode::Tab));
2500 dispatch(&mut app, key(KeyCode::Tab));
2501 assert_eq!(app.focused_pane, Pane::Mailbox);
2502 let initial = app.mailbox_tab;
2503
2504 dispatch(&mut app, key(KeyCode::Char(']')));
2505 dispatch(&mut app, key(KeyCode::Char('[')));
2506 assert_eq!(
2507 app.mailbox_tab, initial,
2508 "`[` / `]` must no longer cycle mailbox tabs (T-124 hard-swap)",
2509 );
2510 }
2511
2512 #[test]
2513 fn q_opens_confirm_then_n_cancels() {
2514 let mut app = App::new();
2515 app.dismiss_splash();
2516 dispatch(&mut app, key(KeyCode::Char('q')));
2517 assert_eq!(app.stage, Stage::QuitConfirm);
2518 dispatch(&mut app, key(KeyCode::Char('n')));
2519 assert_eq!(app.stage, Stage::Triptych);
2520 assert!(app.running, "n must not exit");
2521 }
2522
2523 #[test]
2524 fn q_then_y_exits() {
2525 let mut app = App::new();
2526 app.dismiss_splash();
2527 dispatch(&mut app, key(KeyCode::Char('q')));
2528 dispatch(&mut app, key(KeyCode::Char('y')));
2529 assert!(!app.running);
2530 }
2531
2532 #[test]
2533 fn esc_cancels_quit_confirm() {
2534 let mut app = App::new();
2535 app.dismiss_splash();
2536 app.enter_quit_confirm();
2537 dispatch(&mut app, key(KeyCode::Esc));
2538 assert_eq!(app.stage, Stage::Triptych);
2539 }
2540
2541 #[test]
2542 fn render_does_not_panic_at_minimal_size() {
2543 let app = App::new();
2544 let _ = render_to_buffer(&app, 20, 8);
2545 }
2546
2547 #[test]
2548 fn render_does_not_panic_at_huge_size() {
2549 let app = App::new();
2550 let _ = render_to_buffer(&app, 240, 80);
2551 }
2552
2553 #[test]
2554 fn select_next_wraps_through_team() {
2555 let mut app = App::new();
2556 app.replace_team(fixture_team(vec![
2557 agent("p:a", AgentState::Running),
2558 agent("p:b", AgentState::Running),
2559 agent("p:c", AgentState::Running),
2560 ]));
2561 assert_eq!(app.selected_agent, Some(0));
2562 app.select_next();
2563 assert_eq!(app.selected_agent, Some(1));
2564 app.select_next();
2565 assert_eq!(app.selected_agent, Some(2));
2566 app.select_next();
2567 assert_eq!(app.selected_agent, Some(0)); }
2569
2570 #[test]
2571 fn select_prev_wraps_at_top() {
2572 let mut app = App::new();
2573 app.replace_team(fixture_team(vec![
2574 agent("p:a", AgentState::Running),
2575 agent("p:b", AgentState::Running),
2576 ]));
2577 app.selected_agent = Some(0);
2578 app.select_prev();
2579 assert_eq!(app.selected_agent, Some(1));
2580 }
2581
2582 #[test]
2583 fn select_no_op_on_empty_team() {
2584 let mut app = App::new();
2585 app.select_next();
2586 assert_eq!(app.selected_agent, None);
2587 app.select_prev();
2588 assert_eq!(app.selected_agent, None);
2589 }
2590
2591 #[test]
2592 fn replace_team_preserves_selection_when_agent_still_present() {
2593 let mut app = App::new();
2594 app.replace_team(fixture_team(vec![
2595 agent("p:a", AgentState::Running),
2596 agent("p:b", AgentState::Running),
2597 ]));
2598 app.selected_agent = Some(1);
2599 app.replace_team(fixture_team(vec![
2600 agent("p:a", AgentState::Running),
2601 agent("p:b", AgentState::Stopped), ]));
2603 assert_eq!(app.selected_agent, Some(1), "selection follows the id");
2604 }
2605
2606 #[test]
2607 fn replace_team_resets_selection_when_agent_disappears() {
2608 let mut app = App::new();
2609 app.replace_team(fixture_team(vec![
2610 agent("p:a", AgentState::Running),
2611 agent("p:gone", AgentState::Running),
2612 ]));
2613 app.selected_agent = Some(1);
2614 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
2615 assert_eq!(app.selected_agent, Some(0), "falls back to first agent");
2616 }
2617
2618 #[test]
2619 fn switching_agent_resets_mailbox_buffers() {
2620 let mut app = App::new();
2624 app.replace_team(fixture_team(vec![
2625 agent("p:a", AgentState::Running),
2626 agent("p:b", AgentState::Running),
2627 ]));
2628 app.mailbox.extend(
2629 crate::mailbox::MailboxTab::Inbox,
2630 vec![crate::mailbox::MessageRow {
2631 id: 7,
2632 sender: "p:b".into(),
2633 recipient: "p:a".into(),
2634 text: "hi".into(),
2635 sent_at: 0.0,
2636 }],
2637 );
2638 assert_eq!(app.mailbox.inbox.len(), 1);
2639 assert_eq!(app.mailbox.inbox_after, 7);
2640 app.select_next();
2642 assert_eq!(app.selected_agent_id().as_deref(), Some("p:b"));
2643 assert!(app.mailbox.inbox.is_empty());
2644 assert_eq!(app.mailbox.inbox_after, 0);
2645 }
2646
2647 struct TripleFilterMock {
2652 inbox: Vec<crate::mailbox::MessageRow>,
2653 sent: Vec<crate::mailbox::MessageRow>,
2654 channel: Vec<crate::mailbox::MessageRow>,
2655 wire: Vec<crate::mailbox::MessageRow>,
2656 calls: std::sync::Mutex<Vec<(&'static str, String, i64)>>,
2657 }
2658 impl crate::mailbox::MailboxSource for TripleFilterMock {
2659 fn inbox(&self, id: &str, after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
2660 self.calls.lock().unwrap().push(("inbox", id.into(), after));
2661 Ok(self.inbox.clone())
2662 }
2663 fn sent(&self, id: &str, after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
2664 self.calls.lock().unwrap().push(("sent", id.into(), after));
2665 Ok(self.sent.clone())
2666 }
2667 fn channel_feed(
2668 &self,
2669 id: &str,
2670 after: i64,
2671 ) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
2672 self.calls
2673 .lock()
2674 .unwrap()
2675 .push(("channel", id.into(), after));
2676 Ok(self.channel.clone())
2677 }
2678 fn wire(&self, id: &str, after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
2679 self.calls.lock().unwrap().push(("wire", id.into(), after));
2680 Ok(self.wire.clone())
2681 }
2682 }
2683
2684 #[test]
2685 fn refresh_mailbox_fans_out_to_four_filters() {
2686 use crate::mailbox::MessageRow;
2687 let mut app = App::new();
2688 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
2689 let mock = TripleFilterMock {
2690 inbox: vec![MessageRow {
2691 id: 1,
2692 sender: "p:b".into(),
2693 recipient: "p:a".into(),
2694 text: "dm".into(),
2695 sent_at: 0.0,
2696 }],
2697 sent: vec![MessageRow {
2698 id: 4,
2699 sender: "p:a".into(),
2700 recipient: "p:b".into(),
2701 text: "outgoing dm".into(),
2702 sent_at: 0.0,
2703 }],
2704 channel: vec![MessageRow {
2705 id: 2,
2706 sender: "p:b".into(),
2707 recipient: "channel:p:editorial".into(),
2708 text: "ch".into(),
2709 sent_at: 0.0,
2710 }],
2711 wire: vec![MessageRow {
2712 id: 3,
2713 sender: "p:b".into(),
2714 recipient: "channel:p:all".into(),
2715 text: "wire".into(),
2716 sent_at: 0.0,
2717 }],
2718 calls: std::sync::Mutex::new(Vec::new()),
2719 };
2720 super::refresh_mailbox(&mut app, &mock);
2721 assert_eq!(app.mailbox.inbox.len(), 1);
2722 assert_eq!(app.mailbox.sent.len(), 1);
2723 assert_eq!(app.mailbox.channel.len(), 1);
2724 assert_eq!(app.mailbox.wire.len(), 1);
2725 let calls = mock.calls.lock().unwrap();
2726 assert!(calls.contains(&("inbox", "p:a".into(), 0)));
2729 assert!(calls.contains(&("sent", "p:a".into(), 0)));
2730 assert!(calls.contains(&("channel", "p:a".into(), 0)));
2731 assert!(calls.contains(&("wire", "p".into(), 0)));
2732 }
2733
2734 fn ap(id: i64) -> crate::approvals::Approval {
2735 crate::approvals::Approval {
2736 id,
2737 project_id: "p".into(),
2738 agent_id: "p:m".into(),
2739 action: "publish".into(),
2740 summary: format!("approval #{id}"),
2741 payload_json: String::new(),
2742 }
2743 }
2744
2745 #[test]
2746 fn has_pending_approvals_tracks_replace_calls() {
2747 let mut app = App::new();
2748 assert!(!app.has_pending_approvals());
2749 app.replace_approvals(vec![ap(1), ap(2)]);
2750 assert!(app.has_pending_approvals());
2751 app.replace_approvals(vec![]);
2752 assert!(!app.has_pending_approvals());
2753 }
2754
2755 #[test]
2756 fn enter_approvals_modal_no_op_when_queue_empty() {
2757 let mut app = App::new();
2758 app.dismiss_splash();
2759 app.enter_approvals_modal();
2760 assert_eq!(app.stage, Stage::Triptych, "no pending → no modal");
2761 }
2762
2763 #[test]
2764 fn a_chord_opens_modal_when_pending() {
2765 let mut app = App::new();
2766 app.dismiss_splash();
2767 app.replace_approvals(vec![ap(1), ap(2)]);
2768 dispatch(&mut app, key(KeyCode::Char('a')));
2769 assert_eq!(app.stage, Stage::ApprovalsModal);
2770 assert_eq!(app.selected_approval, 0);
2771 }
2772
2773 #[test]
2774 fn modal_cycle_jk_walks_approvals() {
2775 let mut app = App::new();
2776 app.dismiss_splash();
2777 app.replace_approvals(vec![ap(1), ap(2), ap(3)]);
2778 app.enter_approvals_modal();
2779 dispatch(&mut app, key(KeyCode::Char('j')));
2780 assert_eq!(app.selected_approval, 1);
2781 dispatch(&mut app, key(KeyCode::Char('j')));
2782 assert_eq!(app.selected_approval, 2);
2783 dispatch(&mut app, key(KeyCode::Char('j')));
2784 assert_eq!(app.selected_approval, 0, "wraps");
2785 dispatch(&mut app, key(KeyCode::Char('k')));
2786 assert_eq!(app.selected_approval, 2, "k wraps too");
2787 }
2788
2789 #[test]
2790 fn capital_y_routes_approve_through_decider() {
2791 use crate::approvals::test_support::MockApprovalDecider;
2792 let dec = MockApprovalDecider::default();
2793 let mut app = App::new();
2794 app.dismiss_splash();
2795 app.replace_approvals(vec![ap(7), ap(8)]);
2796 app.enter_approvals_modal();
2797 super::handle_event(
2798 &mut app,
2799 key(KeyCode::Char('Y')),
2800 &dec,
2801 &NoopSender,
2802 &EmptyMailbox,
2803 &crate::keysender::test_support::MockKeySender::default(),
2804 );
2805 let calls = dec.calls.lock().unwrap().clone();
2806 assert_eq!(calls.len(), 1);
2807 assert_eq!(calls[0].0, 7);
2808 assert_eq!(calls[0].1, crate::approvals::Decision::Approve);
2809 assert_eq!(app.pending_approvals.len(), 1);
2811 assert_eq!(app.pending_approvals[0].id, 8);
2812 }
2813
2814 #[test]
2815 fn capital_n_routes_deny_through_decider() {
2816 use crate::approvals::test_support::MockApprovalDecider;
2817 let dec = MockApprovalDecider::default();
2818 let mut app = App::new();
2819 app.dismiss_splash();
2820 app.replace_approvals(vec![ap(7)]);
2821 app.enter_approvals_modal();
2822 super::handle_event(
2823 &mut app,
2824 key(KeyCode::Char('N')),
2825 &dec,
2826 &NoopSender,
2827 &EmptyMailbox,
2828 &crate::keysender::test_support::MockKeySender::default(),
2829 );
2830 let calls = dec.calls.lock().unwrap().clone();
2831 assert_eq!(calls.len(), 1);
2832 assert_eq!(calls[0].1, crate::approvals::Decision::Deny);
2833 assert_eq!(app.stage, Stage::Triptych);
2835 }
2836
2837 #[test]
2838 fn esc_closes_approvals_modal() {
2839 let mut app = App::new();
2840 app.dismiss_splash();
2841 app.replace_approvals(vec![ap(1)]);
2842 app.enter_approvals_modal();
2843 dispatch(&mut app, key(KeyCode::Esc));
2844 assert_eq!(app.stage, Stage::Triptych);
2845 }
2846
2847 #[test]
2848 fn lowercase_y_routes_approve_through_decider() {
2849 use crate::approvals::test_support::MockApprovalDecider;
2853 let dec = MockApprovalDecider::default();
2854 let mut app = App::new();
2855 app.dismiss_splash();
2856 app.replace_approvals(vec![ap(7)]);
2857 app.enter_approvals_modal();
2858 super::handle_event(
2859 &mut app,
2860 key(KeyCode::Char('y')),
2861 &dec,
2862 &NoopSender,
2863 &EmptyMailbox,
2864 &crate::keysender::test_support::MockKeySender::default(),
2865 );
2866 let calls = dec.calls.lock().unwrap().clone();
2867 assert_eq!(calls.len(), 1);
2868 assert_eq!(calls[0].1, crate::approvals::Decision::Approve);
2869 }
2870
2871 #[test]
2872 fn lowercase_n_does_not_deny() {
2873 use crate::approvals::test_support::MockApprovalDecider;
2878 let dec = MockApprovalDecider::default();
2879 let mut app = App::new();
2880 app.dismiss_splash();
2881 app.replace_approvals(vec![ap(7)]);
2882 app.enter_approvals_modal();
2883 super::handle_event(
2884 &mut app,
2885 key(KeyCode::Char('n')),
2886 &dec,
2887 &NoopSender,
2888 &EmptyMailbox,
2889 &crate::keysender::test_support::MockKeySender::default(),
2890 );
2891 assert!(
2892 dec.calls.lock().unwrap().is_empty(),
2893 "lowercase n must not route through the decider"
2894 );
2895 assert_eq!(
2896 app.stage,
2897 Stage::ApprovalsModal,
2898 "stale lowercase n leaves the modal open"
2899 );
2900 }
2901
2902 #[test]
2903 fn shift_tab_cycles_panes_backward() {
2904 use crossterm::event::KeyModifiers;
2905 let mut app = App::new();
2906 app.dismiss_splash();
2907 assert_eq!(app.focused_pane, Pane::Roster);
2908 dispatch(&mut app, key(KeyCode::BackTab));
2911 assert_eq!(app.focused_pane, Pane::Mailbox);
2912 dispatch(&mut app, key_with(KeyCode::Tab, KeyModifiers::SHIFT));
2914 assert_eq!(app.focused_pane, Pane::Detail);
2915 }
2916
2917 #[test]
2918 fn at_chord_opens_compose_dm_to_focused_agent() {
2919 let mut app = App::new();
2920 app.replace_team(fixture_team(vec![
2921 agent("writing:manager", AgentState::Running),
2922 agent("writing:dev1", AgentState::Running),
2923 ]));
2924 app.dismiss_splash();
2925 app.select_next();
2926 dispatch(&mut app, key(KeyCode::Char('@')));
2927 assert_eq!(app.stage, Stage::ComposeModal);
2928 match app.compose_target.as_ref() {
2929 Some(crate::compose::ComposeTarget::Dm { agent_id, .. }) => {
2930 assert_eq!(agent_id, "writing:dev1");
2931 }
2932 other => panic!("expected DM target, got {other:?}"),
2933 }
2934 }
2935
2936 #[test]
2937 fn bang_chord_opens_compose_broadcast_to_all_channel() {
2938 let mut app = App::new();
2939 app.replace_team(fixture_team(vec![agent(
2940 "writing:manager",
2941 AgentState::Running,
2942 )]));
2943 app.dismiss_splash();
2944 dispatch(&mut app, key(KeyCode::Char('!')));
2945 assert_eq!(app.stage, Stage::ComposeModal);
2946 match app.compose_target.as_ref() {
2947 Some(crate::compose::ComposeTarget::Broadcast { channel_id, .. }) => {
2948 assert_eq!(channel_id, "writing:all");
2949 }
2950 other => panic!("expected Broadcast target, got {other:?}"),
2951 }
2952 }
2953
2954 #[test]
2955 fn send_routes_dm_through_mock_sender() {
2956 use crate::compose::test_support::MockMessageSender;
2957 let sender = MockMessageSender::default();
2958 let mailbox = EmptyMailbox;
2959 let mut app = App::new();
2960 app.replace_team(fixture_team(vec![agent(
2961 "writing:dev1",
2962 AgentState::Running,
2963 )]));
2964 app.dismiss_splash();
2965 app.enter_compose_dm_for_focused();
2966 for c in "ship it".chars() {
2967 super::handle_event(
2968 &mut app,
2969 key(KeyCode::Char(c)),
2970 &NoopDecider,
2971 &sender,
2972 &mailbox,
2973 &crate::keysender::test_support::MockKeySender::default(),
2974 );
2975 }
2976 super::handle_event(
2977 &mut app,
2978 key_with(KeyCode::Enter, crossterm::event::KeyModifiers::CONTROL),
2979 &NoopDecider,
2980 &sender,
2981 &mailbox,
2982 &crate::keysender::test_support::MockKeySender::default(),
2983 );
2984 let calls = sender.dm_calls.lock().unwrap().clone();
2985 assert_eq!(calls.len(), 1);
2986 assert_eq!(calls[0].0, "writing:dev1");
2987 assert_eq!(calls[0].1, "ship it");
2988 assert_eq!(app.stage, Stage::Triptych, "modal closes on send");
2989 }
2990
2991 #[test]
2992 fn esc_esc_cancels_compose_without_send() {
2993 use crate::compose::test_support::MockMessageSender;
2994 let sender = MockMessageSender::default();
2995 let mailbox = EmptyMailbox;
2996 let mut app = App::new();
2997 app.replace_team(fixture_team(vec![agent(
2998 "writing:dev1",
2999 AgentState::Running,
3000 )]));
3001 app.dismiss_splash();
3002 app.enter_compose_dm_for_focused();
3003 for c in "draft".chars() {
3004 super::handle_event(
3005 &mut app,
3006 key(KeyCode::Char(c)),
3007 &NoopDecider,
3008 &sender,
3009 &mailbox,
3010 &crate::keysender::test_support::MockKeySender::default(),
3011 );
3012 }
3013 super::handle_event(
3014 &mut app,
3015 key(KeyCode::Esc),
3016 &NoopDecider,
3017 &sender,
3018 &mailbox,
3019 &crate::keysender::test_support::MockKeySender::default(),
3020 );
3021 super::handle_event(
3022 &mut app,
3023 key(KeyCode::Esc),
3024 &NoopDecider,
3025 &sender,
3026 &mailbox,
3027 &crate::keysender::test_support::MockKeySender::default(),
3028 );
3029 assert_eq!(app.stage, Stage::Triptych);
3030 assert!(sender.dm_calls.lock().unwrap().is_empty());
3031 }
3032
3033 #[test]
3034 fn send_failure_surfaces_error_inline_keeps_modal_open() {
3035 use crate::compose::test_support::MockMessageSender;
3036 let sender = MockMessageSender::default();
3037 *sender.fail_next.lock().unwrap() = Some("rate limit".into());
3038 let mailbox = EmptyMailbox;
3039 let mut app = App::new();
3040 app.replace_team(fixture_team(vec![agent(
3041 "writing:dev1",
3042 AgentState::Running,
3043 )]));
3044 app.dismiss_splash();
3045 app.enter_compose_dm_for_focused();
3046 super::handle_event(
3047 &mut app,
3048 key(KeyCode::Char('x')),
3049 &NoopDecider,
3050 &sender,
3051 &mailbox,
3052 &crate::keysender::test_support::MockKeySender::default(),
3053 );
3054 super::handle_event(
3055 &mut app,
3056 key_with(KeyCode::Enter, crossterm::event::KeyModifiers::CONTROL),
3057 &NoopDecider,
3058 &sender,
3059 &mailbox,
3060 &crate::keysender::test_support::MockKeySender::default(),
3061 );
3062 assert_eq!(app.stage, Stage::ComposeModal, "modal stays open on err");
3063 assert!(app
3064 .compose_error
3065 .as_deref()
3066 .unwrap_or_default()
3067 .contains("rate limit"));
3068 }
3069
3070 fn channel(id: &str, project: &str) -> crate::data::ChannelInfo {
3071 crate::data::ChannelInfo {
3072 id: id.into(),
3073 name: id
3074 .rsplit_once(':')
3075 .map(|(_, n)| n.to_string())
3076 .unwrap_or_default(),
3077 project_id: project.into(),
3078 }
3079 }
3080
3081 fn fixture_team_with_channels(
3082 agents: Vec<AgentInfo>,
3083 channels: Vec<crate::data::ChannelInfo>,
3084 ) -> TeamSnapshot {
3085 TeamSnapshot {
3086 root: std::path::PathBuf::from("/fixture"),
3087 team_name: "fixture".into(),
3088 agents,
3089 channels,
3090 }
3091 }
3092
3093 #[test]
3094 fn ctrl_w_toggles_wall_layout() {
3095 use crossterm::event::KeyModifiers;
3096 let mut app = App::new();
3097 app.dismiss_splash();
3098 assert_eq!(app.layout, MainLayout::Triptych);
3099 dispatch(
3100 &mut app,
3101 key_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
3102 );
3103 assert_eq!(app.layout, MainLayout::Wall);
3104 dispatch(
3105 &mut app,
3106 key_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
3107 );
3108 assert_eq!(app.layout, MainLayout::Triptych);
3109 }
3110
3111 #[test]
3112 fn ctrl_m_toggles_mailbox_first_layout() {
3113 use crossterm::event::KeyModifiers;
3114 let mut app = App::new();
3115 app.dismiss_splash();
3116 dispatch(
3117 &mut app,
3118 key_with(KeyCode::Char('m'), KeyModifiers::CONTROL),
3119 );
3120 assert_eq!(app.layout, MainLayout::MailboxFirst);
3121 dispatch(
3122 &mut app,
3123 key_with(KeyCode::Char('m'), KeyModifiers::CONTROL),
3124 );
3125 assert_eq!(app.layout, MainLayout::Triptych);
3126 }
3127
3128 #[test]
3129 fn wall_scroll_pages_through_overflow_agents() {
3130 let mut app = App::new();
3131 let mut agents: Vec<_> = (1..=10)
3132 .map(|i| agent(&format!("p:agent-{i:02}"), AgentState::Running))
3133 .collect();
3134 for a in agents.iter_mut() {
3136 a.is_manager = false;
3137 }
3138 app.replace_team(fixture_team(agents));
3139 app.dismiss_splash();
3140 app.toggle_wall_layout();
3141 assert_eq!(app.wall_scroll, 0);
3142 app.wall_scroll_down();
3143 assert_eq!(app.wall_scroll, 4);
3144 app.wall_scroll_down();
3145 assert_eq!(app.wall_scroll, 8);
3146 app.wall_scroll_down();
3148 assert_eq!(app.wall_scroll, 8, "scroll capped at last full window");
3149 app.wall_scroll_up();
3150 assert_eq!(app.wall_scroll, 4);
3151 }
3152
3153 #[test]
3154 fn ctrl_pipe_adds_detail_split_capped_at_four() {
3155 use crossterm::event::KeyModifiers;
3156 let mut app = App::new();
3157 app.replace_team(fixture_team(vec![
3158 agent("p:a", AgentState::Running),
3159 agent("p:b", AgentState::Running),
3160 ]));
3161 app.dismiss_splash();
3162 for _ in 0..6 {
3163 dispatch(
3164 &mut app,
3165 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
3166 );
3167 }
3168 assert_eq!(app.detail_splits.len(), 4, "split count capped at 4");
3169 }
3170
3171 #[test]
3172 fn ctrl_q_closes_focused_split() {
3173 use crossterm::event::KeyModifiers;
3174 let mut app = App::new();
3175 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3176 app.dismiss_splash();
3177 dispatch(
3178 &mut app,
3179 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
3180 );
3181 dispatch(
3182 &mut app,
3183 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
3184 );
3185 assert_eq!(app.detail_splits.len(), 2);
3186 dispatch(
3187 &mut app,
3188 key_with(KeyCode::Char('Q'), KeyModifiers::CONTROL),
3189 );
3190 assert_eq!(app.detail_splits.len(), 1);
3191 }
3192
3193 #[test]
3194 fn ctrl_hjkl_cycles_splits() {
3195 use crossterm::event::KeyModifiers;
3196 let mut app = App::new();
3197 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3198 app.dismiss_splash();
3199 for _ in 0..3 {
3200 dispatch(
3201 &mut app,
3202 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
3203 );
3204 }
3205 assert_eq!(app.selected_split, 2);
3206 dispatch(
3207 &mut app,
3208 key_with(KeyCode::Char('l'), KeyModifiers::CONTROL),
3209 );
3210 assert_eq!(app.selected_split, 0, "wraps");
3211 dispatch(
3212 &mut app,
3213 key_with(KeyCode::Char('h'), KeyModifiers::CONTROL),
3214 );
3215 assert_eq!(app.selected_split, 2);
3216 }
3217
3218 #[test]
3219 fn wall_scroll_at_exactly_cap_agents_does_not_scroll() {
3220 let mut app = App::new();
3225 let agents: Vec<_> = (1..=4)
3226 .map(|i| agent(&format!("p:agent-{i}"), AgentState::Running))
3227 .collect();
3228 app.replace_team(fixture_team(agents));
3229 app.dismiss_splash();
3230 app.toggle_wall_layout();
3231 assert_eq!(app.wall_scroll, 0);
3232 app.wall_scroll_down();
3233 assert_eq!(app.wall_scroll, 0, "exactly-cap should not advance");
3234 app.wall_scroll_up();
3235 assert_eq!(app.wall_scroll, 0);
3236 }
3237
3238 #[test]
3239 fn wall_scroll_at_cap_plus_one_advances_then_stops() {
3240 let mut app = App::new();
3245 let agents: Vec<_> = (1..=5)
3246 .map(|i| agent(&format!("p:agent-{i}"), AgentState::Running))
3247 .collect();
3248 app.replace_team(fixture_team(agents));
3249 app.dismiss_splash();
3250 app.toggle_wall_layout();
3251 app.wall_scroll_down();
3252 assert_eq!(app.wall_scroll, 4, "first scroll exposes agent 5");
3253 app.wall_scroll_down();
3254 assert_eq!(app.wall_scroll, 4, "second scroll caps; nothing past");
3255 }
3256
3257 #[test]
3258 fn esc_in_picker_dismisses_overlay_only_keeps_modal_open() {
3259 let mut app = App::new();
3265 app.replace_team(fixture_team_with_channels(
3266 vec![agent("writing:manager", AgentState::Running)],
3267 vec![
3268 channel("writing:all", "writing"),
3269 channel("writing:editorial", "writing"),
3270 ],
3271 ));
3272 app.dismiss_splash();
3273 dispatch(&mut app, key(KeyCode::Char('!')));
3274 assert!(app.compose_picker_open);
3275 assert_eq!(app.stage, Stage::ComposeModal);
3276 dispatch(&mut app, key(KeyCode::Esc));
3277 assert!(!app.compose_picker_open, "picker dismissed");
3278 assert_eq!(app.stage, Stage::ComposeModal, "compose modal stays open");
3279 }
3280
3281 #[test]
3282 fn send_routes_broadcast_through_mock_sender_via_picker() {
3283 use crate::compose::test_support::MockMessageSender;
3289 let sender = MockMessageSender::default();
3290 let mailbox = EmptyMailbox;
3291 let mut app = App::new();
3292 app.replace_team(fixture_team_with_channels(
3293 vec![agent("writing:manager", AgentState::Running)],
3294 vec![
3295 channel("writing:all", "writing"),
3296 channel("writing:editorial", "writing"),
3297 channel("writing:critique", "writing"),
3298 ],
3299 ));
3300 app.dismiss_splash();
3301 super::handle_event(
3304 &mut app,
3305 key(KeyCode::Char('!')),
3306 &NoopDecider,
3307 &sender,
3308 &mailbox,
3309 &crate::keysender::test_support::MockKeySender::default(),
3310 );
3311 super::handle_event(
3312 &mut app,
3313 key(KeyCode::Char('j')),
3314 &NoopDecider,
3315 &sender,
3316 &mailbox,
3317 &crate::keysender::test_support::MockKeySender::default(),
3318 );
3319 super::handle_event(
3320 &mut app,
3321 key(KeyCode::Enter),
3322 &NoopDecider,
3323 &sender,
3324 &mailbox,
3325 &crate::keysender::test_support::MockKeySender::default(),
3326 );
3327 for c in "ship docs".chars() {
3328 super::handle_event(
3329 &mut app,
3330 key(KeyCode::Char(c)),
3331 &NoopDecider,
3332 &sender,
3333 &mailbox,
3334 &crate::keysender::test_support::MockKeySender::default(),
3335 );
3336 }
3337 super::handle_event(
3338 &mut app,
3339 key_with(KeyCode::Enter, crossterm::event::KeyModifiers::CONTROL),
3340 &NoopDecider,
3341 &sender,
3342 &mailbox,
3343 &crate::keysender::test_support::MockKeySender::default(),
3344 );
3345 let dm_calls = sender.dm_calls.lock().unwrap().clone();
3346 let bcast_calls = sender.broadcast_calls.lock().unwrap().clone();
3347 assert!(dm_calls.is_empty(), "broadcast must not route via send_dm");
3348 assert_eq!(bcast_calls.len(), 1);
3349 assert_eq!(
3350 bcast_calls[0].0, "writing:editorial",
3351 "channel id from picker selection"
3352 );
3353 assert_eq!(bcast_calls[0].1, "ship docs");
3354 assert_eq!(app.stage, Stage::Triptych, "modal closes on send");
3355 }
3356
3357 #[test]
3358 fn bang_chord_opens_picker_when_channels_available() {
3359 let mut app = App::new();
3360 app.replace_team(fixture_team_with_channels(
3361 vec![agent("writing:manager", AgentState::Running)],
3362 vec![
3363 channel("writing:all", "writing"),
3364 channel("writing:editorial", "writing"),
3365 channel("writing:critique", "writing"),
3366 ],
3367 ));
3368 app.dismiss_splash();
3369 dispatch(&mut app, key(KeyCode::Char('!')));
3370 assert_eq!(app.stage, Stage::ComposeModal);
3371 assert!(app.compose_picker_open);
3372 dispatch(&mut app, key(KeyCode::Char('j')));
3374 assert_eq!(app.compose_picker_index, 1);
3375 dispatch(&mut app, key(KeyCode::Enter));
3377 assert!(!app.compose_picker_open, "picker closes on confirm");
3378 match app.compose_target.as_ref() {
3379 Some(crate::compose::ComposeTarget::Broadcast { channel_id, .. }) => {
3380 assert_eq!(channel_id, "writing:editorial");
3381 }
3382 other => panic!("expected Broadcast target, got {other:?}"),
3383 }
3384 }
3385
3386 #[test]
3387 fn mailbox_first_layout_seeds_channel_selection_on_entry() {
3388 let mut app = App::new();
3389 app.replace_team(fixture_team_with_channels(
3390 vec![agent("writing:manager", AgentState::Running)],
3391 vec![
3392 channel("writing:all", "writing"),
3393 channel("writing:editorial", "writing"),
3394 ],
3395 ));
3396 app.dismiss_splash();
3397 assert!(app.selected_channel.is_none());
3398 app.toggle_mailbox_first_layout();
3399 assert_eq!(app.selected_channel, Some(0));
3400 }
3401
3402 #[test]
3403 fn help_overlay_opens_on_question_mark_closes_on_esc() {
3404 let mut app = App::new();
3405 app.dismiss_splash();
3406 dispatch(&mut app, key(KeyCode::Char('?')));
3407 assert_eq!(app.stage, Stage::HelpOverlay);
3408 dispatch(&mut app, key(KeyCode::Esc));
3409 assert_eq!(app.stage, Stage::Triptych);
3410 }
3411
3412 #[test]
3413 fn tutorial_opens_on_t_advances_and_closes() {
3414 let mut app = App::new();
3415 app.dismiss_splash();
3416 dispatch(&mut app, key(KeyCode::Char('t')));
3417 assert_eq!(app.stage, Stage::Tutorial);
3418 assert_eq!(app.tutorial_step, 0);
3419 dispatch(&mut app, key(KeyCode::Char(' ')));
3421 assert_eq!(app.tutorial_step, 1);
3422 dispatch(&mut app, key(KeyCode::Char('k')));
3424 assert_eq!(app.tutorial_step, 0);
3425 dispatch(&mut app, key(KeyCode::Esc));
3427 assert_eq!(app.stage, Stage::Triptych);
3428 }
3429
3430 #[test]
3431 fn tutorial_walk_back_at_step_zero_is_no_op() {
3432 let mut app = App::new();
3437 app.dismiss_splash();
3438 app.enter_tutorial();
3439 assert_eq!(app.tutorial_step, 0);
3440 dispatch(&mut app, key(KeyCode::Char('k')));
3441 assert_eq!(app.tutorial_step, 0, "step-0 walk-back is no-op");
3442 assert_eq!(app.stage, Stage::Tutorial);
3445 }
3446
3447 #[test]
3448 fn ctrl_pipe_adds_vertical_split_ctrl_minus_adds_horizontal() {
3449 use crossterm::event::KeyModifiers;
3450 let mut app = App::new();
3451 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3452 app.dismiss_splash();
3453 dispatch(
3454 &mut app,
3455 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
3456 );
3457 dispatch(
3458 &mut app,
3459 key_with(KeyCode::Char('-'), KeyModifiers::CONTROL),
3460 );
3461 assert_eq!(app.detail_splits.len(), 2);
3462 assert_eq!(app.detail_splits[0].1, SplitOrientation::Vertical);
3463 assert_eq!(app.detail_splits[1].1, SplitOrientation::Horizontal);
3464 }
3465
3466 #[test]
3467 fn ctrl_w_q_chord_prefix_closes_focused_split() {
3468 use crossterm::event::KeyModifiers;
3469 let mut app = App::new();
3470 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3471 app.dismiss_splash();
3472 dispatch(
3475 &mut app,
3476 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
3477 );
3478 dispatch(
3479 &mut app,
3480 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
3481 );
3482 dispatch(
3483 &mut app,
3484 key_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
3485 );
3486 assert_eq!(app.pending_chord, Some(KeyCode::Char('w')));
3487 dispatch(&mut app, key(KeyCode::Char('q')));
3490 assert_eq!(app.detail_splits.len(), 1);
3491 assert_eq!(app.stage, Stage::Triptych, "must not enter quit confirm");
3492 assert_eq!(app.pending_chord, None, "chord cleared");
3493 }
3494
3495 #[test]
3496 fn ctrl_w_o_chord_keeps_only_focused_split() {
3497 use crossterm::event::KeyModifiers;
3498 let mut app = App::new();
3499 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3500 app.dismiss_splash();
3501 for _ in 0..3 {
3502 dispatch(
3503 &mut app,
3504 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
3505 );
3506 }
3507 app.selected_split = 1;
3509 let kept_id = app.detail_splits[1].0.clone();
3510 dispatch(
3511 &mut app,
3512 key_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
3513 );
3514 dispatch(&mut app, key(KeyCode::Char('o')));
3515 assert_eq!(app.detail_splits.len(), 1);
3516 assert_eq!(app.detail_splits[0].0, kept_id);
3517 assert_eq!(app.selected_split, 0);
3518 }
3519
3520 #[test]
3521 fn add_detail_split_saturates_at_four_with_explicit_4_and_5_calls() {
3522 let mut app = App::new();
3527 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3528 for _ in 0..4 {
3529 app.add_detail_split();
3530 }
3531 assert_eq!(app.detail_splits.len(), 4);
3532 let snapshot_len = app.detail_splits.len();
3533 app.add_detail_split();
3534 assert_eq!(app.detail_splits.len(), snapshot_len, "5th call rejected");
3535 }
3536
3537 #[test]
3538 fn replace_approvals_clamps_selection_in_range() {
3539 let mut app = App::new();
3540 app.replace_approvals(vec![ap(1), ap(2), ap(3)]);
3541 app.selected_approval = 2;
3542 app.replace_approvals(vec![ap(1), ap(2)]);
3544 assert_eq!(app.selected_approval, 1, "clamps to last index");
3545 }
3546
3547 #[test]
3548 fn arrow_keys_navigate_only_when_roster_focused() {
3549 let mut app = App::new();
3550 app.replace_team(fixture_team(vec![
3551 agent("p:a", AgentState::Running),
3552 agent("p:b", AgentState::Running),
3553 ]));
3554 app.dismiss_splash();
3555 app.selected_agent = Some(0);
3557 dispatch(&mut app, key(KeyCode::Down));
3558 assert_eq!(app.selected_agent, Some(1));
3559 app.cycle_focus();
3561 dispatch(&mut app, key(KeyCode::Down));
3562 assert_eq!(
3563 app.selected_agent,
3564 Some(1),
3565 "non-roster focus ignores arrows"
3566 );
3567 }
3568
3569 fn stream_keys_fixture() -> App {
3575 let mut app = App::new();
3576 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3577 app.dismiss_splash();
3578 app.cycle_focus(); assert_eq!(app.focused_pane, Pane::Detail);
3580 assert_eq!(app.selected_agent, Some(0));
3581 app
3582 }
3583
3584 fn stream_dispatch(
3585 app: &mut App,
3586 ev: Event,
3587 key_sender: &crate::keysender::test_support::MockKeySender,
3588 ) {
3589 super::handle_event(
3590 app,
3591 ev,
3592 &NoopDecider,
3593 &NoopSender,
3594 &EmptyMailbox,
3595 key_sender,
3596 );
3597 }
3598
3599 #[test]
3600 fn ctrl_e_enters_stream_keys_when_detail_focused() {
3601 use crate::keysender::test_support::MockKeySender;
3602 use crossterm::event::KeyModifiers;
3603 let mut app = stream_keys_fixture();
3604 let ks = MockKeySender::default();
3605 stream_dispatch(
3606 &mut app,
3607 key_with(KeyCode::Char('e'), KeyModifiers::CONTROL),
3608 &ks,
3609 );
3610 assert_eq!(app.stage, Stage::StreamKeys);
3611 assert!(
3612 ks.calls.lock().unwrap().is_empty(),
3613 "the activation chord itself never forwards a keystroke"
3614 );
3615 }
3616
3617 #[test]
3618 fn ctrl_e_no_op_when_detail_not_focused() {
3619 use crate::keysender::test_support::MockKeySender;
3624 use crossterm::event::KeyModifiers;
3625 let mut app = App::new();
3626 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3627 app.dismiss_splash();
3628 assert_eq!(app.focused_pane, Pane::Roster);
3629 let ks = MockKeySender::default();
3630 stream_dispatch(
3631 &mut app,
3632 key_with(KeyCode::Char('e'), KeyModifiers::CONTROL),
3633 &ks,
3634 );
3635 assert_eq!(app.stage, Stage::Triptych);
3636 }
3637
3638 #[test]
3639 fn ctrl_e_no_op_when_no_agent_selected() {
3640 use crate::keysender::test_support::MockKeySender;
3643 use crossterm::event::KeyModifiers;
3644 let mut app = App::new();
3645 app.dismiss_splash();
3646 app.cycle_focus(); assert_eq!(app.selected_agent, None);
3648 let ks = MockKeySender::default();
3649 stream_dispatch(
3650 &mut app,
3651 key_with(KeyCode::Char('e'), KeyModifiers::CONTROL),
3652 &ks,
3653 );
3654 assert_eq!(app.stage, Stage::Triptych);
3655 }
3656
3657 #[test]
3658 fn esc_forwards_to_pane_in_stream_keys() {
3659 use crate::keysender::test_support::MockKeySender;
3663 use crossterm::event::KeyModifiers;
3664 let mut app = stream_keys_fixture();
3665 app.enter_stream_keys();
3666 assert_eq!(app.stage, Stage::StreamKeys);
3667 let ks = MockKeySender::default();
3668 stream_dispatch(&mut app, key_with(KeyCode::Esc, KeyModifiers::NONE), &ks);
3669 assert_eq!(
3670 app.stage,
3671 Stage::StreamKeys,
3672 "Esc does NOT exit stream-keys"
3673 );
3674 let calls = ks.calls.lock().unwrap();
3675 assert_eq!(calls.len(), 1, "Esc forwards as one keystroke");
3676 assert_eq!(calls[0].0, "t-p-a");
3677 assert_eq!(calls[0].1.args, vec!["Escape".to_string()]);
3678 }
3679
3680 #[test]
3681 fn ctrl_e_exits_stream_keys() {
3682 use crate::keysender::test_support::MockKeySender;
3685 use crossterm::event::KeyModifiers;
3686 let mut app = stream_keys_fixture();
3687 app.enter_stream_keys();
3688 assert_eq!(app.stage, Stage::StreamKeys);
3689 let ks = MockKeySender::default();
3690 stream_dispatch(
3691 &mut app,
3692 key_with(KeyCode::Char('e'), KeyModifiers::CONTROL),
3693 &ks,
3694 );
3695 assert_eq!(app.stage, Stage::Triptych);
3696 assert!(
3697 ks.calls.lock().unwrap().is_empty(),
3698 "Ctrl+E is the exit chord — it must not forward as a keystroke"
3699 );
3700 }
3701
3702 #[test]
3703 fn stream_mode_forwards_printable_chars_to_target_session() {
3704 use crate::keysender::test_support::MockKeySender;
3705 let mut app = stream_keys_fixture();
3706 app.enter_stream_keys();
3707 let ks = MockKeySender::default();
3708 for c in "hi".chars() {
3709 stream_dispatch(&mut app, key(KeyCode::Char(c)), &ks);
3710 }
3711 let calls = ks.calls.lock().unwrap();
3712 assert_eq!(calls.len(), 2, "one tmux send-keys per keystroke");
3713 assert_eq!(calls[0].0, "t-p-a");
3716 assert_eq!(calls[0].1.args, vec!["-l".to_string(), "h".to_string()]);
3717 assert_eq!(calls[1].1.args, vec!["-l".to_string(), "i".to_string()]);
3718 }
3719
3720 #[test]
3721 fn stream_mode_passes_ctrl_c_through_to_agent() {
3722 use crate::keysender::test_support::MockKeySender;
3726 use crossterm::event::KeyModifiers;
3727 let mut app = stream_keys_fixture();
3728 app.enter_stream_keys();
3729 let ks = MockKeySender::default();
3730 stream_dispatch(
3731 &mut app,
3732 key_with(KeyCode::Char('c'), KeyModifiers::CONTROL),
3733 &ks,
3734 );
3735 assert_eq!(app.stage, Stage::StreamKeys, "Ctrl+C does NOT exit");
3736 let calls = ks.calls.lock().unwrap();
3737 assert_eq!(calls.len(), 1);
3738 assert_eq!(calls[0].1.args, vec!["C-c".to_string()]);
3739 }
3740
3741 #[test]
3742 fn stream_mode_forwards_enter_and_arrows() {
3743 use crate::keysender::test_support::MockKeySender;
3744 let mut app = stream_keys_fixture();
3745 app.enter_stream_keys();
3746 let ks = MockKeySender::default();
3747 stream_dispatch(&mut app, key(KeyCode::Enter), &ks);
3748 stream_dispatch(&mut app, key(KeyCode::Up), &ks);
3749 let calls = ks.calls.lock().unwrap();
3750 assert_eq!(calls[0].1.args, vec!["Enter".to_string()]);
3751 assert_eq!(calls[1].1.args, vec!["Up".to_string()]);
3752 }
3753
3754 #[test]
3755 fn stream_target_session_uses_focused_split_when_present() {
3756 let mut app = App::new();
3761 app.replace_team(fixture_team(vec![
3762 agent("p:a", AgentState::Running),
3763 agent("p:b", AgentState::Running),
3764 ]));
3765 app.dismiss_splash();
3766 app.cycle_focus(); app.selected_agent = Some(0);
3768 app.detail_splits
3770 .push(("p:b".into(), crate::app::SplitOrientation::Vertical));
3771 app.selected_split = 1; let target = app.stream_target_session();
3773 assert_eq!(
3774 target.as_deref(),
3775 Some("t-p-b"),
3776 "selected split's agent drives the target"
3777 );
3778 }
3779
3780 #[test]
3781 fn stream_mode_drops_back_when_target_session_disappears() {
3782 use crate::keysender::test_support::MockKeySender;
3787 let mut app = stream_keys_fixture();
3788 app.enter_stream_keys();
3789 app.selected_agent = None;
3791 app.team.agents.clear();
3792 let ks = MockKeySender::default();
3793 stream_dispatch(&mut app, key(KeyCode::Char('a')), &ks);
3794 assert_eq!(app.stage, Stage::Triptych);
3795 assert!(ks.calls.lock().unwrap().is_empty());
3796 }
3797
3798 #[test]
3801 fn recapture_focused_pane_sets_buffer_and_advances_clock() {
3802 use crate::pane::test_support::MockPaneSource;
3807 let mut app = App::new();
3808 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3809 app.dismiss_splash();
3810 assert_eq!(app.selected_agent, Some(0));
3811 let mock = MockPaneSource {
3812 lines: vec!["hello".into(), "world".into()],
3813 asked: std::sync::Mutex::new(Vec::new()),
3814 };
3815 let before = Instant::now() - PANE_REFRESH_INTERVAL;
3817 app.last_pane_refresh = before;
3818
3819 super::recapture_focused_pane(&mut app, &mock);
3820
3821 assert_eq!(app.detail_buffer, vec!["hello", "world"]);
3822 assert_eq!(mock.asked.lock().unwrap().clone(), vec!["t-p-a"]);
3824 assert!(
3825 app.last_pane_refresh > before,
3826 "re-capture advances the fast-cadence clock"
3827 );
3828 }
3829
3830 #[test]
3831 fn recapture_focused_pane_no_op_when_no_agent_focused() {
3832 use crate::pane::test_support::MockPaneSource;
3835 let mut app = App::new();
3836 app.dismiss_splash();
3837 assert_eq!(app.selected_agent, None);
3838 let mock = MockPaneSource {
3839 lines: vec!["unused".into()],
3840 asked: std::sync::Mutex::new(Vec::new()),
3841 };
3842
3843 super::recapture_focused_pane(&mut app, &mock);
3844
3845 assert!(
3846 mock.asked.lock().unwrap().is_empty(),
3847 "no focused agent → no capture call"
3848 );
3849 assert!(
3850 app.detail_buffer.is_empty(),
3851 "detail buffer untouched with no agent"
3852 );
3853 }
3854
3855 #[test]
3856 fn recapture_focused_pane_no_op_when_selection_cleared() {
3857 use crate::pane::test_support::MockPaneSource;
3860 let mut app = App::new();
3861 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3862 app.dismiss_splash();
3863 app.selected_agent = None;
3864 let mock = MockPaneSource {
3865 lines: vec!["unused".into()],
3866 asked: std::sync::Mutex::new(Vec::new()),
3867 };
3868
3869 super::recapture_focused_pane(&mut app, &mock);
3870
3871 assert!(mock.asked.lock().unwrap().is_empty());
3872 assert!(app.detail_buffer.is_empty());
3873 }
3874
3875 fn stream_keys_fixture_two_agents() -> App {
3881 let mut app = App::new();
3882 app.replace_team(fixture_team(vec![
3883 agent("p:a", AgentState::Running),
3884 agent("p:b", AgentState::Running),
3885 ]));
3886 app.dismiss_splash();
3887 app.cycle_focus(); assert_eq!(app.focused_pane, Pane::Detail);
3889 assert_eq!(app.selected_agent, Some(0));
3890 app.enter_stream_keys();
3891 assert_eq!(app.stage, Stage::StreamKeys);
3892 app
3893 }
3894
3895 #[test]
3896 fn ctrl_shift_down_moves_selection_to_next_agent_no_split() {
3897 use crate::keysender::test_support::MockKeySender;
3901 let mut app = stream_keys_fixture_two_agents();
3902 let ks = MockKeySender::default();
3903 stream_dispatch(
3904 &mut app,
3905 key_with(KeyCode::Down, KeyModifiers::CONTROL | KeyModifiers::SHIFT),
3906 &ks,
3907 );
3908 assert_eq!(app.selected_agent, Some(1), "switched to next agent");
3909 assert_eq!(app.stage, Stage::StreamKeys, "stays in stream-keys");
3910 assert!(
3911 ks.calls.lock().unwrap().is_empty(),
3912 "the switch chord never forwards a keystroke"
3913 );
3914 }
3915
3916 #[test]
3917 fn ctrl_shift_up_moves_selection_to_prev_agent_no_split() {
3918 use crate::keysender::test_support::MockKeySender;
3921 let mut app = stream_keys_fixture_two_agents();
3922 let ks = MockKeySender::default();
3923 stream_dispatch(
3924 &mut app,
3925 key_with(KeyCode::Up, KeyModifiers::CONTROL | KeyModifiers::SHIFT),
3926 &ks,
3927 );
3928 assert_eq!(
3929 app.selected_agent,
3930 Some(1),
3931 "Up from agent 0 wraps to the last agent"
3932 );
3933 assert_eq!(app.stage, Stage::StreamKeys);
3934 assert!(ks.calls.lock().unwrap().is_empty());
3935 }
3936
3937 #[test]
3938 fn ctrl_shift_switch_no_op_when_split_focused() {
3939 use crate::keysender::test_support::MockKeySender;
3946 for code in [KeyCode::Up, KeyCode::Down] {
3947 let mut app = stream_keys_fixture_two_agents();
3948 app.detail_splits
3951 .push(("p:b".into(), SplitOrientation::Vertical));
3952 app.selected_split = 1;
3953 let ks = MockKeySender::default();
3954 stream_dispatch(
3955 &mut app,
3956 key_with(code, KeyModifiers::CONTROL | KeyModifiers::SHIFT),
3957 &ks,
3958 );
3959 assert_eq!(
3960 app.selected_agent,
3961 Some(0),
3962 "split focused → selection must not move ({code:?})"
3963 );
3964 assert_eq!(app.stage, Stage::StreamKeys);
3965 assert!(
3966 ks.calls.lock().unwrap().is_empty(),
3967 "split-focused switch chord is consumed, not forwarded ({code:?})"
3968 );
3969 }
3970 }
3971
3972 #[test]
3973 fn ctrl_shift_switch_single_agent_is_no_op() {
3974 use crate::keysender::test_support::MockKeySender;
3978 let mut app = stream_keys_fixture(); app.enter_stream_keys();
3980 assert_eq!(app.selected_agent, Some(0));
3981 let ks = MockKeySender::default();
3982 stream_dispatch(
3983 &mut app,
3984 key_with(KeyCode::Down, KeyModifiers::CONTROL | KeyModifiers::SHIFT),
3985 &ks,
3986 );
3987 assert_eq!(app.selected_agent, Some(0), "single agent → stays at 0");
3988 assert_eq!(app.stage, Stage::StreamKeys);
3989 assert!(ks.calls.lock().unwrap().is_empty());
3990 }
3991
3992 fn pane_sync_fixture() -> App {
3995 let mut app = App::new();
3996 app.team = fixture_team(vec![
3997 agent("hello:mgr", AgentState::Running),
3998 agent("hello:dev", AgentState::Running),
3999 ]);
4000 app.selected_agent = Some(0);
4001 app.stage = Stage::Triptych;
4002 app.layout = MainLayout::Triptych;
4003 app
4004 }
4005
4006 #[test]
4007 fn sync_fires_resize_on_first_frame() {
4008 let mut app = pane_sync_fixture();
4009 let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
4010 sync_focused_pane_size_to(
4011 &mut app,
4012 ratatui::layout::Rect::new(0, 0, 120, 40),
4013 &resizer,
4014 );
4015 let calls = resizer.calls.lock().unwrap();
4016 assert_eq!(calls.len(), 1);
4019 assert_eq!(calls[0].0, "t-hello-mgr");
4020 assert_eq!(calls[0].1, 90); assert_eq!(calls[0].2, 22); }
4023
4024 #[test]
4025 fn sync_skips_when_size_unchanged() {
4026 let mut app = pane_sync_fixture();
4027 let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
4028 sync_focused_pane_size_to(
4030 &mut app,
4031 ratatui::layout::Rect::new(0, 0, 120, 40),
4032 &resizer,
4033 );
4034 sync_focused_pane_size_to(
4035 &mut app,
4036 ratatui::layout::Rect::new(0, 0, 120, 40),
4037 &resizer,
4038 );
4039 assert_eq!(resizer.calls.lock().unwrap().len(), 1);
4040 }
4041
4042 #[test]
4043 fn sync_fires_again_when_terminal_resizes() {
4044 let mut app = pane_sync_fixture();
4045 let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
4046 sync_focused_pane_size_to(
4047 &mut app,
4048 ratatui::layout::Rect::new(0, 0, 120, 40),
4049 &resizer,
4050 );
4051 sync_focused_pane_size_to(
4053 &mut app,
4054 ratatui::layout::Rect::new(0, 0, 200, 60),
4055 &resizer,
4056 );
4057 let calls = resizer.calls.lock().unwrap();
4058 assert_eq!(calls.len(), 2);
4059 assert_eq!(calls[0].1, 90); assert_eq!(calls[0].2, 22); assert_eq!(calls[1].1, 170); assert_eq!(calls[1].2, 34);
4064 }
4065
4066 #[test]
4067 fn sync_fires_on_focus_switch_to_unsynced_session() {
4068 let mut app = pane_sync_fixture();
4069 let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
4070 sync_focused_pane_size_to(
4071 &mut app,
4072 ratatui::layout::Rect::new(0, 0, 120, 40),
4073 &resizer,
4074 );
4075 app.selected_agent = Some(1);
4077 sync_focused_pane_size_to(
4078 &mut app,
4079 ratatui::layout::Rect::new(0, 0, 120, 40),
4080 &resizer,
4081 );
4082 let calls = resizer.calls.lock().unwrap();
4083 assert_eq!(calls.len(), 2);
4084 assert_eq!(calls[0].0, "t-hello-mgr");
4085 assert_eq!(calls[1].0, "t-hello-dev");
4086 }
4087
4088 #[test]
4089 fn sync_is_noop_when_no_agent_focused() {
4090 let mut app = pane_sync_fixture();
4091 app.selected_agent = None;
4092 let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
4093 sync_focused_pane_size_to(
4094 &mut app,
4095 ratatui::layout::Rect::new(0, 0, 120, 40),
4096 &resizer,
4097 );
4098 assert!(resizer.calls.lock().unwrap().is_empty());
4099 }
4100
4101 #[test]
4102 fn sync_is_noop_when_layout_is_not_triptych() {
4103 let mut app = pane_sync_fixture();
4104 app.layout = MainLayout::Wall;
4105 let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
4106 sync_focused_pane_size_to(
4107 &mut app,
4108 ratatui::layout::Rect::new(0, 0, 120, 40),
4109 &resizer,
4110 );
4111 assert!(resizer.calls.lock().unwrap().is_empty());
4114 }
4115
4116 #[test]
4117 fn sync_is_noop_on_degenerate_terminal_area() {
4118 let mut app = pane_sync_fixture();
4119 let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
4120 sync_focused_pane_size_to(&mut app, ratatui::layout::Rect::new(0, 0, 28, 40), &resizer);
4122 assert!(resizer.calls.lock().unwrap().is_empty());
4123 }
4124
4125 #[test]
4126 fn sync_accounts_for_approvals_stripe_when_present() {
4127 let mut app = pane_sync_fixture();
4128 app.pending_approvals = vec![crate::approvals::Approval {
4130 id: 1,
4131 project_id: "hello".into(),
4132 agent_id: "hello:dev".into(),
4133 action: "test".into(),
4134 summary: "test approval".into(),
4135 payload_json: String::new(),
4136 }];
4137 assert!(app.has_pending_approvals());
4138 let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
4139 sync_focused_pane_size_to(
4140 &mut app,
4141 ratatui::layout::Rect::new(0, 0, 120, 40),
4142 &resizer,
4143 );
4144 let calls = resizer.calls.lock().unwrap();
4145 assert_eq!(calls.len(), 1);
4148 assert_eq!(calls[0].2, 21);
4149 }
4150
4151 fn app_with_mailbox_focused() -> App {
4157 let mut app = App::new();
4158 app.dismiss_splash();
4159 app.cycle_focus();
4161 app.cycle_focus();
4162 assert_eq!(app.focused_pane, Pane::Mailbox);
4163 app
4164 }
4165
4166 #[test]
4167 fn f_opens_filter_input_when_mailbox_focused() {
4168 let mut app = app_with_mailbox_focused();
4169 assert!(app.mailbox_input_mode.is_none());
4170 dispatch(&mut app, key(KeyCode::Char('f')));
4171 assert_eq!(app.mailbox_input_mode, Some(MailboxInputKind::Filter));
4172 }
4173
4174 #[test]
4175 fn slash_opens_search_input_when_mailbox_focused() {
4176 let mut app = app_with_mailbox_focused();
4177 dispatch(&mut app, key(KeyCode::Char('/')));
4178 assert_eq!(app.mailbox_input_mode, Some(MailboxInputKind::Search));
4179 }
4180
4181 #[test]
4182 fn f_does_not_open_filter_when_roster_focused() {
4183 let mut app = App::new();
4187 app.dismiss_splash();
4188 assert_eq!(app.focused_pane, Pane::Roster);
4189 dispatch(&mut app, key(KeyCode::Char('f')));
4190 assert!(app.mailbox_input_mode.is_none());
4191 }
4192
4193 #[test]
4194 fn typing_into_filter_input_mutates_active_tab_buffer() {
4195 let mut app = app_with_mailbox_focused();
4196 dispatch(&mut app, key(KeyCode::Char('f')));
4197 dispatch(&mut app, key(KeyCode::Char('a')));
4198 dispatch(&mut app, key(KeyCode::Char('d')));
4199 dispatch(&mut app, key(KeyCode::Char('a')));
4200 assert_eq!(app.mailbox.filter_text(app.mailbox_tab), "ada");
4201 assert_eq!(app.mailbox.filter_text(MailboxTab::Sent), "");
4203 }
4204
4205 #[test]
4206 fn backspace_pops_input_buffer() {
4207 let mut app = app_with_mailbox_focused();
4208 dispatch(&mut app, key(KeyCode::Char('/')));
4209 for c in "abc".chars() {
4210 dispatch(&mut app, key(KeyCode::Char(c)));
4211 }
4212 assert_eq!(app.mailbox.search_text(app.mailbox_tab), "abc");
4213 dispatch(&mut app, key(KeyCode::Backspace));
4214 assert_eq!(app.mailbox.search_text(app.mailbox_tab), "ab");
4215 }
4216
4217 #[test]
4218 fn enter_confirms_keeps_typed_text() {
4219 let mut app = app_with_mailbox_focused();
4220 dispatch(&mut app, key(KeyCode::Char('f')));
4221 for c in "kian".chars() {
4222 dispatch(&mut app, key(KeyCode::Char(c)));
4223 }
4224 dispatch(&mut app, key(KeyCode::Enter));
4225 assert!(
4226 app.mailbox_input_mode.is_none(),
4227 "input must close on Enter"
4228 );
4229 assert_eq!(
4230 app.mailbox.filter_text(app.mailbox_tab),
4231 "kian",
4232 "Enter must keep the typed text (confirm-keep semantics)"
4233 );
4234 }
4235
4236 #[test]
4237 fn esc_cancels_reverts_to_snapshot() {
4238 let mut app = app_with_mailbox_focused();
4239 app.mailbox
4241 .set_input(app.mailbox_tab, MailboxInputKind::Filter, "previous".into());
4242 dispatch(&mut app, key(KeyCode::Char('f')));
4243 dispatch(&mut app, key(KeyCode::Backspace));
4245 dispatch(&mut app, key(KeyCode::Backspace));
4246 dispatch(&mut app, key(KeyCode::Char('x')));
4247 assert_eq!(app.mailbox.filter_text(app.mailbox_tab), "previox");
4248 dispatch(&mut app, key(KeyCode::Esc));
4250 assert!(app.mailbox_input_mode.is_none());
4251 assert_eq!(
4252 app.mailbox.filter_text(app.mailbox_tab),
4253 "previous",
4254 "Esc must revert the active buffer to the pre-open snapshot"
4255 );
4256 }
4257
4258 #[test]
4259 fn open_input_swallows_pr1_cursor_keys() {
4260 let mut app = app_with_mailbox_focused();
4264 app.mailbox.extend(
4266 app.mailbox_tab,
4267 (1..=10)
4268 .map(|i| crate::mailbox::MessageRow {
4269 id: i,
4270 sender: "p:a".into(),
4271 recipient: "p:dev".into(),
4272 text: "x".into(),
4273 sent_at: 0.0,
4274 })
4275 .collect(),
4276 );
4277 let seated = app.mailbox.cursor(app.mailbox_tab).selected_idx;
4278 assert_eq!(seated, 9, "extend seats cursor at tail (PR-1 contract)");
4279 dispatch(&mut app, key(KeyCode::Char('f')));
4281 dispatch(&mut app, key(KeyCode::Up));
4282 dispatch(&mut app, key(KeyCode::PageUp));
4283 dispatch(&mut app, key(KeyCode::Home));
4284 assert_eq!(app.mailbox.cursor(app.mailbox_tab).selected_idx, 9);
4289 }
4290
4291 #[test]
4292 fn ctrl_modifier_char_does_not_inject_into_input() {
4293 let mut app = app_with_mailbox_focused();
4299 dispatch(&mut app, key(KeyCode::Char('f'))); dispatch(
4301 &mut app,
4302 key_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
4303 );
4304 dispatch(
4305 &mut app,
4306 key_with(KeyCode::Char('c'), KeyModifiers::CONTROL),
4307 );
4308 dispatch(&mut app, key_with(KeyCode::Char('a'), KeyModifiers::ALT));
4309 assert_eq!(
4310 app.mailbox.filter_text(app.mailbox_tab),
4311 "",
4312 "modifier+Char combos must not leak into the filter buffer"
4313 );
4314 dispatch(&mut app, key(KeyCode::Char('w')));
4317 assert_eq!(app.mailbox.filter_text(app.mailbox_tab), "w");
4318 dispatch(&mut app, key_with(KeyCode::Char('X'), KeyModifiers::SHIFT));
4321 assert_eq!(app.mailbox.filter_text(app.mailbox_tab), "wX");
4322 }
4323
4324 #[test]
4325 fn open_input_swallows_q_quit() {
4326 let mut app = app_with_mailbox_focused();
4331 dispatch(&mut app, key(KeyCode::Char('f')));
4332 dispatch(&mut app, key(KeyCode::Char('q')));
4333 assert_eq!(
4334 app.stage,
4335 Stage::Triptych,
4336 "q must NOT trigger quit while input is open"
4337 );
4338 assert_eq!(
4339 app.mailbox.filter_text(app.mailbox_tab),
4340 "q",
4341 "q must land in the filter buffer"
4342 );
4343 }
4344
4345 fn seed_inbox_rows(app: &mut App, n: i64) {
4349 let rows: Vec<MessageRow> = (1..=n)
4350 .map(|i| MessageRow {
4351 id: i,
4352 sender: "p:dev".into(),
4353 recipient: "p:mgr".into(),
4354 text: format!("body #{i}"),
4355 sent_at: 1_700_000_000.0 + i as f64,
4356 })
4357 .collect();
4358 app.mailbox.extend(MailboxTab::Inbox, rows);
4359 }
4360
4361 #[test]
4362 fn enter_on_mailbox_opens_detail_modal_with_snapshot() {
4363 let mut app = app_with_mailbox_focused();
4364 seed_inbox_rows(&mut app, 5);
4365 dispatch(&mut app, key(KeyCode::Enter));
4367 assert_eq!(app.stage, Stage::MailboxDetailModal);
4368 let snap = app.mailbox_detail_modal.as_ref().expect("modal open");
4369 assert_eq!(snap.id, 5);
4370 assert_eq!(snap.text, "body #5");
4371 assert_eq!(app.mailbox_detail_scroll, 0, "scroll resets on open");
4372 }
4373
4374 #[test]
4375 fn enter_on_empty_visible_indices_is_noop() {
4376 let mut app = app_with_mailbox_focused();
4378 seed_inbox_rows(&mut app, 3);
4379 app.mailbox.set_input(
4380 MailboxTab::Inbox,
4381 MailboxInputKind::Filter,
4382 "no-such-sender".into(),
4383 );
4384 assert!(app.mailbox.visible_indices(MailboxTab::Inbox).is_empty());
4385 dispatch(&mut app, key(KeyCode::Enter));
4386 assert_eq!(app.stage, Stage::Triptych);
4387 assert!(app.mailbox_detail_modal.is_none());
4388 }
4389
4390 #[test]
4391 fn snapshot_stable_across_underlying_drain() {
4392 let mut app = app_with_mailbox_focused();
4396 seed_inbox_rows(&mut app, 5);
4397 app.mailbox.cursor_home(MailboxTab::Inbox);
4398 app.mailbox.move_cursor_down(MailboxTab::Inbox);
4399 app.mailbox.move_cursor_down(MailboxTab::Inbox); dispatch(&mut app, key(KeyCode::Enter));
4401 let snap_id = app.mailbox_detail_modal.as_ref().expect("open").id;
4402 assert_eq!(snap_id, 3);
4403 let more: Vec<MessageRow> = (6..=600)
4406 .map(|i| MessageRow {
4407 id: i,
4408 sender: "p:dev".into(),
4409 recipient: "p:mgr".into(),
4410 text: format!("body #{i}"),
4411 sent_at: 1_700_000_000.0 + i as f64,
4412 })
4413 .collect();
4414 app.mailbox.extend(MailboxTab::Inbox, more);
4415 let still_there = app
4417 .mailbox
4418 .rows(MailboxTab::Inbox)
4419 .iter()
4420 .any(|r| r.id == 3);
4421 assert!(!still_there, "row id 3 must have been drained");
4422 let snap = app.mailbox_detail_modal.as_ref().expect("still open");
4425 assert_eq!(snap.id, 3, "snapshot id must survive underlying drain");
4426 assert_eq!(snap.text, "body #3");
4427 }
4428
4429 #[test]
4430 fn esc_closes_detail_modal() {
4431 let mut app = app_with_mailbox_focused();
4432 seed_inbox_rows(&mut app, 3);
4433 dispatch(&mut app, key(KeyCode::Enter));
4434 assert_eq!(app.stage, Stage::MailboxDetailModal);
4435 dispatch(&mut app, key(KeyCode::Esc));
4436 assert_eq!(app.stage, Stage::Triptych);
4437 assert!(app.mailbox_detail_modal.is_none());
4438 }
4439
4440 #[test]
4441 fn q_closes_detail_modal() {
4442 let mut app = app_with_mailbox_focused();
4443 seed_inbox_rows(&mut app, 3);
4444 dispatch(&mut app, key(KeyCode::Enter));
4445 dispatch(&mut app, key(KeyCode::Char('q')));
4446 assert_eq!(app.stage, Stage::Triptych);
4447 assert!(app.mailbox_detail_modal.is_none());
4448 }
4449
4450 #[test]
4451 fn j_and_k_scroll_body_in_modal() {
4452 let mut app = app_with_mailbox_focused();
4453 seed_inbox_rows(&mut app, 3);
4454 dispatch(&mut app, key(KeyCode::Enter));
4455 assert_eq!(app.mailbox_detail_scroll, 0);
4456 dispatch(&mut app, key(KeyCode::Char('j')));
4457 dispatch(&mut app, key(KeyCode::Char('j')));
4458 dispatch(&mut app, key(KeyCode::Down));
4459 assert_eq!(app.mailbox_detail_scroll, 3);
4460 dispatch(&mut app, key(KeyCode::Char('k')));
4461 dispatch(&mut app, key(KeyCode::Up));
4462 assert_eq!(app.mailbox_detail_scroll, 1);
4463 for _ in 0..10 {
4465 dispatch(&mut app, key(KeyCode::Char('k')));
4466 }
4467 assert_eq!(app.mailbox_detail_scroll, 0);
4468 }
4469
4470 #[test]
4471 fn unrelated_keys_swallowed_in_modal() {
4472 let mut app = app_with_mailbox_focused();
4475 seed_inbox_rows(&mut app, 3);
4476 dispatch(&mut app, key(KeyCode::Enter));
4477 assert_eq!(app.stage, Stage::MailboxDetailModal);
4478 let focused_before = app.focused_pane;
4479 dispatch(&mut app, key(KeyCode::Char('f')));
4480 dispatch(&mut app, key(KeyCode::Char('/')));
4481 dispatch(&mut app, key(KeyCode::Tab));
4482 assert_eq!(app.stage, Stage::MailboxDetailModal, "stage stays");
4483 assert!(app.mailbox_input_mode.is_none(), "filter/search not opened");
4484 assert_eq!(
4485 app.focused_pane, focused_before,
4486 "Tab must not cycle panes underneath an open modal"
4487 );
4488 }
4489}