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};
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::{BrokerMailboxSource, MailboxBuffers, MailboxSource, MailboxTab};
30use crate::pane::{PaneSource, TmuxPaneSource};
31use crate::splash;
32use crate::status_bar;
33use crate::statusline;
34use crate::theme::{detect_capabilities, Capabilities};
35use crate::triptych::{self, MainLayout, Pane};
36use crate::tutorial;
37use crate::watch::Watch;
38
39const SPLASH_AUTO_DISMISS: Duration = Duration::from_secs(3);
40const POLL_INTERVAL: Duration = Duration::from_millis(50);
41const REFRESH_INTERVAL: Duration = Duration::from_secs(1);
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum Stage {
47 Splash,
48 Triptych,
49 QuitConfirm,
50 ApprovalsModal,
55 ComposeModal,
60 HelpOverlay,
63 Tutorial,
68 StreamKeys,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum SplitOrientation {
83 Vertical,
84 Horizontal,
85}
86
87pub struct App {
88 pub stage: Stage,
89 pub previous_stage: Stage,
91 pub focused_pane: Pane,
92 pub team: TeamSnapshot,
93 pub selected_agent: Option<usize>,
97 pub detail_buffer: Vec<String>,
101 pub version: &'static str,
102 pub capabilities: Capabilities,
103 pub splash_started: Instant,
104 pub last_refresh: Instant,
107 pub running: bool,
108 pub tutorial_completed: bool,
112 pub mailbox_tab: MailboxTab,
120 pub mailbox: MailboxBuffers,
124 pub pending_approvals: Vec<Approval>,
127 pub selected_approval: usize,
131 pub approval_error: Option<String>,
135 pub compose_target: Option<ComposeTarget>,
139 pub compose_editor: Editor,
143 pub compose_error: Option<String>,
147 pub layout: MainLayout,
150 pub wall_scroll: usize,
154 pub selected_channel: Option<usize>,
158 pub detail_splits: Vec<(String, SplitOrientation)>,
164 pub selected_split: usize,
165 pub pending_chord: Option<KeyCode>,
171 pub tutorial_pending_for_team: bool,
175 pub spinner_frame: usize,
178 pub tutorial_step: usize,
181 pub compose_picker_open: bool,
186 pub compose_picker_index: usize,
188 pub compose_attach_input_open: bool,
195 pub compose_attach_buffer: String,
198 pub last_synced_pane_sizes: std::collections::HashMap<String, (u16, u16)>,
206 pub sysinfo: sysinfo::System,
214 pub rate_limit_indicator_enabled: bool,
222}
223
224const MAX_DETAIL_LINES: usize = 2000;
225
226impl App {
227 pub fn new() -> Self {
233 Self {
234 stage: Stage::Splash,
235 previous_stage: Stage::Splash,
236 focused_pane: Pane::Roster,
237 team: TeamSnapshot::empty(std::path::PathBuf::new()),
238 selected_agent: None,
239 detail_buffer: Vec::new(),
240 version: env!("CARGO_PKG_VERSION"),
241 capabilities: detect_capabilities(),
242 splash_started: Instant::now(),
243 last_refresh: Instant::now() - REFRESH_INTERVAL,
244 running: true,
245 tutorial_completed: tutorial::is_completed(),
246 mailbox_tab: MailboxTab::Inbox,
247 mailbox: MailboxBuffers::default(),
248 pending_approvals: Vec::new(),
249 selected_approval: 0,
250 approval_error: None,
251 compose_target: None,
252 compose_editor: Editor::default(),
253 compose_error: None,
254 layout: MainLayout::Triptych,
255 wall_scroll: 0,
256 selected_channel: None,
257 detail_splits: Vec::new(),
258 selected_split: 0,
259 compose_picker_open: false,
260 compose_picker_index: 0,
261 compose_attach_input_open: false,
262 compose_attach_buffer: String::new(),
263 pending_chord: None,
264 tutorial_pending_for_team: false,
265 spinner_frame: 0,
266 tutorial_step: 0,
267 last_synced_pane_sizes: std::collections::HashMap::new(),
268 sysinfo: sysinfo::System::new(),
274 rate_limit_indicator_enabled: std::env::var_os("TEAMCTL_UI_RATE_LIMIT_INDICATOR")
282 .is_some(),
283 }
284 }
285
286 pub fn enter_help_overlay(&mut self) {
289 self.previous_stage = self.stage;
290 self.stage = Stage::HelpOverlay;
291 }
292 pub fn close_help_overlay(&mut self) {
293 self.stage = self.previous_stage;
294 }
295 pub fn enter_tutorial(&mut self) {
296 self.previous_stage = self.stage;
297 self.stage = Stage::Tutorial;
298 self.tutorial_step = 0;
299 }
300 pub fn close_tutorial(&mut self) {
301 self.stage = self.previous_stage;
302 self.tutorial_pending_for_team = false;
303 if !self.team.root.as_os_str().is_empty() {
304 let _ = crate::onboarding::mark_completed(&self.team.root);
305 }
306 }
307 pub fn tutorial_advance(&mut self) {
308 let len = crate::onboarding::STEPS.len();
309 if len == 0 {
310 self.close_tutorial();
311 return;
312 }
313 if self.tutorial_step + 1 >= len {
314 self.close_tutorial();
315 } else {
316 self.tutorial_step += 1;
317 }
318 }
319 pub fn tutorial_back(&mut self) {
320 self.tutorial_step = self.tutorial_step.saturating_sub(1);
321 }
322
323 pub fn toggle_wall_layout(&mut self) {
324 self.layout = self.layout.toggle_wall();
325 }
326 pub fn toggle_mailbox_first_layout(&mut self) {
327 self.layout = self.layout.toggle_mailbox_first();
328 if matches!(self.layout, MainLayout::MailboxFirst) && self.selected_channel.is_none() {
331 self.selected_channel = if self.team.channels.is_empty() {
332 None
333 } else {
334 Some(0)
335 };
336 }
337 }
338 pub fn wall_scroll_up(&mut self) {
339 self.wall_scroll = self
340 .wall_scroll
341 .saturating_sub(crate::layouts::WALL_TILE_CAP);
342 }
343 pub fn wall_scroll_down(&mut self) {
344 let next = self.wall_scroll + crate::layouts::WALL_TILE_CAP;
345 if next < self.team.agents.len() {
346 self.wall_scroll = next;
347 }
348 }
349 pub fn select_next_channel(&mut self) {
350 if self.team.channels.is_empty() {
351 return;
352 }
353 self.selected_channel = Some(match self.selected_channel {
354 None => 0,
355 Some(i) => (i + 1) % self.team.channels.len(),
356 });
357 }
358 pub fn select_prev_channel(&mut self) {
359 if self.team.channels.is_empty() {
360 return;
361 }
362 self.selected_channel = Some(match self.selected_channel {
363 None | Some(0) => self.team.channels.len() - 1,
364 Some(i) => i - 1,
365 });
366 }
367
368 pub fn add_detail_split_vertical(&mut self) {
372 self.add_detail_split_with_orientation(SplitOrientation::Vertical);
373 }
374 pub fn add_detail_split_horizontal(&mut self) {
376 self.add_detail_split_with_orientation(SplitOrientation::Horizontal);
377 }
378 fn add_detail_split_with_orientation(&mut self, orientation: SplitOrientation) {
379 let Some(id) = self.selected_agent_id() else {
380 return;
381 };
382 if self.detail_splits.len() >= 4 {
383 return;
384 }
385 self.detail_splits.push((id, orientation));
386 self.selected_split = self.detail_splits.len() - 1;
387 }
388 pub fn add_detail_split(&mut self) {
393 self.add_detail_split_vertical();
394 }
395 pub fn close_focused_split(&mut self) {
396 if self.detail_splits.is_empty() {
397 return;
398 }
399 let i = self.selected_split.min(self.detail_splits.len() - 1);
400 self.detail_splits.remove(i);
401 self.selected_split = i.saturating_sub(1);
402 }
403 pub fn cycle_split_next(&mut self) {
404 if self.detail_splits.is_empty() {
405 return;
406 }
407 self.selected_split = (self.selected_split + 1) % self.detail_splits.len();
408 }
409 pub fn cycle_split_prev(&mut self) {
410 if self.detail_splits.is_empty() {
411 return;
412 }
413 self.selected_split = if self.selected_split == 0 {
414 self.detail_splits.len() - 1
415 } else {
416 self.selected_split - 1
417 };
418 }
419
420 pub fn enter_compose_broadcast_with_picker(&mut self) {
425 if self.team.channels.is_empty() {
426 self.enter_compose_broadcast();
430 return;
431 }
432 let project_id = self
433 .team
434 .channels
435 .first()
436 .map(|c| c.project_id.clone())
437 .unwrap_or_default();
438 self.previous_stage = self.stage;
439 self.stage = Stage::ComposeModal;
440 self.compose_target = Some(ComposeTarget::Broadcast {
441 channel_id: format!("{project_id}:all"),
442 project_id,
443 });
444 self.compose_editor = Editor::default();
445 self.compose_error = None;
446 self.compose_picker_open = true;
447 self.compose_picker_index = 0;
448 }
449 pub fn picker_next(&mut self) {
450 if self.team.channels.is_empty() {
451 return;
452 }
453 self.compose_picker_index = (self.compose_picker_index + 1) % self.team.channels.len();
454 }
455 pub fn picker_prev(&mut self) {
456 if self.team.channels.is_empty() {
457 return;
458 }
459 self.compose_picker_index = if self.compose_picker_index == 0 {
460 self.team.channels.len() - 1
461 } else {
462 self.compose_picker_index - 1
463 };
464 }
465 pub fn picker_confirm(&mut self) {
466 if let Some(ch) = self.team.channels.get(self.compose_picker_index) {
467 self.compose_target = Some(ComposeTarget::Broadcast {
468 channel_id: ch.id.clone(),
469 project_id: ch.project_id.clone(),
470 });
471 }
472 self.compose_picker_open = false;
473 }
474
475 pub fn open_compose_attach_input(&mut self) {
478 self.compose_attach_input_open = true;
479 self.compose_attach_buffer.clear();
480 }
481
482 pub fn confirm_compose_attach_input(&mut self) {
488 let path = self.compose_attach_buffer.trim().to_string();
489 if !path.is_empty() {
490 let marker = format!("📎 attachment: {path}");
491 if let Some(last) = self.compose_editor.lines.last_mut() {
496 if !last.is_empty() {
497 self.compose_editor.lines.push(marker);
498 } else {
499 *last = marker;
500 }
501 } else {
502 self.compose_editor.lines.push(marker);
503 }
504 self.compose_editor.cursor_row = self.compose_editor.lines.len() - 1;
507 self.compose_editor.cursor_col = self
508 .compose_editor
509 .lines
510 .last()
511 .map(|l| l.len())
512 .unwrap_or(0);
513 }
514 self.close_compose_attach_input();
515 }
516
517 pub fn close_compose_attach_input(&mut self) {
518 self.compose_attach_input_open = false;
519 self.compose_attach_buffer.clear();
520 }
521
522 pub fn cycle_mailbox_tab(&mut self) {
523 self.mailbox_tab = self.mailbox_tab.next();
524 }
525
526 pub fn cycle_mailbox_tab_back(&mut self) {
527 self.mailbox_tab = self.mailbox_tab.prev();
528 }
529
530 pub fn cycle_focus_back(&mut self) {
531 self.focused_pane = self.focused_pane.prev();
532 }
533
534 pub fn has_pending_approvals(&self) -> bool {
535 !self.pending_approvals.is_empty()
536 }
537
538 pub fn enter_approvals_modal(&mut self) {
539 if self.pending_approvals.is_empty() {
540 return;
541 }
542 self.previous_stage = self.stage;
543 self.stage = Stage::ApprovalsModal;
544 self.selected_approval = 0;
545 self.approval_error = None;
546 }
547
548 pub fn close_approvals_modal(&mut self) {
549 self.stage = self.previous_stage;
550 self.approval_error = None;
551 }
552
553 pub fn cycle_approval_next(&mut self) {
554 if self.pending_approvals.is_empty() {
555 return;
556 }
557 self.selected_approval = (self.selected_approval + 1) % self.pending_approvals.len();
558 }
559
560 pub fn cycle_approval_prev(&mut self) {
561 if self.pending_approvals.is_empty() {
562 return;
563 }
564 self.selected_approval = if self.selected_approval == 0 {
565 self.pending_approvals.len() - 1
566 } else {
567 self.selected_approval - 1
568 };
569 }
570
571 pub fn focused_approval(&self) -> Option<&Approval> {
572 self.pending_approvals.get(self.selected_approval)
573 }
574
575 pub fn replace_approvals(&mut self, approvals: Vec<Approval>) {
581 self.pending_approvals = approvals;
582 if self.pending_approvals.is_empty() {
583 if matches!(self.stage, Stage::ApprovalsModal) {
584 self.close_approvals_modal();
585 }
586 self.selected_approval = 0;
587 } else if self.selected_approval >= self.pending_approvals.len() {
588 self.selected_approval = self.pending_approvals.len() - 1;
589 }
590 }
591
592 pub fn apply_decision<D: ApprovalDecider>(&mut self, decider: &D, kind: Decision, note: &str) {
599 let Some(approval) = self.focused_approval().cloned() else {
600 return;
601 };
602 match decider.decide(&self.team.root, approval.id, kind, note) {
603 Ok(()) => {
604 self.pending_approvals.retain(|a| a.id != approval.id);
605 self.approval_error = None;
606 if self.pending_approvals.is_empty() {
607 self.close_approvals_modal();
608 } else if self.selected_approval >= self.pending_approvals.len() {
609 self.selected_approval = self.pending_approvals.len() - 1;
610 }
611 }
612 Err(err) => {
613 self.approval_error = Some(err.to_string());
614 }
615 }
616 }
617
618 pub fn enter_compose_dm_for_focused(&mut self) {
621 let Some(info) = self
622 .selected_agent
623 .and_then(|i| self.team.agents.get(i))
624 .cloned()
625 else {
626 return;
627 };
628 self.previous_stage = self.stage;
629 self.stage = Stage::ComposeModal;
630 self.compose_target = Some(ComposeTarget::Dm {
631 agent_id: info.id.clone(),
632 project_id: info.project.clone(),
633 });
634 self.compose_editor = Editor::default();
635 self.compose_error = None;
636 }
637
638 pub fn enter_compose_broadcast(&mut self) {
646 let project_id = self
647 .selected_agent
648 .and_then(|i| self.team.agents.get(i))
649 .map(|a| a.project.clone())
650 .or_else(|| self.team.agents.first().map(|a| a.project.clone()));
651 let Some(project_id) = project_id else {
652 return;
653 };
654 let channel_id = format!("{project_id}:all");
655 self.previous_stage = self.stage;
656 self.stage = Stage::ComposeModal;
657 self.compose_target = Some(ComposeTarget::Broadcast {
658 channel_id,
659 project_id,
660 });
661 self.compose_editor = Editor::default();
662 self.compose_error = None;
663 }
664
665 pub fn close_compose_modal(&mut self) {
666 self.stage = self.previous_stage;
667 self.compose_target = None;
668 self.compose_editor = Editor::default();
669 self.compose_error = None;
670 self.compose_attach_input_open = false;
673 self.compose_attach_buffer.clear();
674 }
675
676 pub fn apply_send<S: MessageSender, M: MailboxSource>(
682 &mut self,
683 sender: &S,
684 mailbox_source: &M,
685 ) {
686 let Some(target) = self.compose_target.clone() else {
687 return;
688 };
689 let body = self.compose_editor.body();
690 if body.is_empty() {
691 self.compose_error = Some("body is empty".into());
692 return;
693 }
694 let result = match &target {
695 ComposeTarget::Dm { agent_id, .. } => sender.send_dm(&self.team.root, agent_id, &body),
696 ComposeTarget::Broadcast { channel_id, .. } => {
697 sender.broadcast(&self.team.root, channel_id, &body)
698 }
699 };
700 match result {
701 Ok(()) => {
702 self.close_compose_modal();
703 refresh_mailbox(self, mailbox_source);
706 }
707 Err(err) => {
708 self.compose_error = Some(err.to_string());
709 }
710 }
711 }
712
713 pub fn dismiss_splash(&mut self) {
714 if matches!(self.stage, Stage::Splash) {
715 self.stage = Stage::Triptych;
716 self.previous_stage = Stage::Triptych;
717 }
718 }
719
720 pub fn cycle_focus(&mut self) {
721 self.focused_pane = self.focused_pane.next();
722 }
723
724 pub fn select_prev(&mut self) {
730 if self.team.agents.is_empty() {
731 self.selected_agent = None;
732 return;
733 }
734 let prior = self.selected_agent_id();
735 self.selected_agent = Some(match self.selected_agent {
736 None | Some(0) => self.team.agents.len() - 1,
737 Some(i) => i - 1,
738 });
739 if prior != self.selected_agent_id() {
740 self.mailbox.reset();
741 }
742 }
743
744 pub fn select_next(&mut self) {
747 if self.team.agents.is_empty() {
748 self.selected_agent = None;
749 return;
750 }
751 let prior = self.selected_agent_id();
752 self.selected_agent = Some(match self.selected_agent {
753 None => 0,
754 Some(i) => (i + 1) % self.team.agents.len(),
755 });
756 if prior != self.selected_agent_id() {
757 self.mailbox.reset();
758 }
759 }
760
761 pub fn selected_agent_id(&self) -> Option<String> {
763 self.selected_agent
764 .and_then(|i| self.team.agents.get(i))
765 .map(|a| a.id.clone())
766 }
767
768 pub fn enter_quit_confirm(&mut self) {
769 self.previous_stage = self.stage;
770 self.stage = Stage::QuitConfirm;
771 }
772
773 pub fn cancel_quit(&mut self) {
774 self.stage = self.previous_stage;
775 }
776
777 pub fn confirm_quit(&mut self) {
778 self.running = false;
779 }
780
781 pub fn replace_team(&mut self, team: TeamSnapshot) {
788 let prior_id = self.selected_agent_id();
789 self.team = team;
790 self.selected_agent = match (prior_id.clone(), self.team.agents.is_empty()) {
791 (_, true) => None,
792 (Some(id), false) => self.team.agents.iter().position(|a| a.id == id).or(Some(0)),
793 (None, false) => Some(0),
794 };
795 if prior_id != self.selected_agent_id() {
796 self.mailbox.reset();
797 }
798 }
799
800 pub fn focused_session(&self) -> Option<&str> {
803 self.selected_agent
804 .and_then(|i| self.team.agents.get(i))
805 .map(|a| a.tmux_session.as_str())
806 }
807
808 pub fn stream_target_session(&self) -> Option<String> {
815 if self.detail_splits.is_empty() || self.selected_split == 0 {
816 return self.focused_session().map(|s| s.to_string());
817 }
818 let split_idx = self.selected_split - 1;
819 let agent_id = self.detail_splits.get(split_idx).map(|(id, _)| id)?;
820 self.team
821 .agents
822 .iter()
823 .find(|a| &a.id == agent_id)
824 .map(|a| a.tmux_session.clone())
825 }
826
827 pub fn enter_stream_keys(&mut self) {
832 if self.stream_target_session().is_none() {
833 return;
834 }
835 self.previous_stage = self.stage;
836 self.stage = Stage::StreamKeys;
837 }
838
839 pub fn exit_stream_keys(&mut self) {
843 self.stage = self.previous_stage;
844 }
845
846 pub fn set_detail_buffer(&mut self, lines: Vec<String>) {
848 let len = lines.len();
849 let start = len.saturating_sub(MAX_DETAIL_LINES);
850 self.detail_buffer = lines[start..].to_vec();
851 }
852}
853
854impl Default for App {
855 fn default() -> Self {
856 Self::new()
857 }
858}
859
860pub fn refresh<P: PaneSource, M: MailboxSource, A: ApprovalSource>(
865 app: &mut App,
866 pane_source: &P,
867 mailbox_source: &M,
868 approval_source: &A,
869) {
870 if let Ok(Some(snapshot)) = TeamSnapshot::discover_and_load() {
871 app.replace_team(snapshot);
872 }
873 if let Some(session) = app.focused_session().map(|s| s.to_string()) {
874 if let Ok(lines) = pane_source.capture(&session) {
875 app.set_detail_buffer(lines);
876 }
877 } else {
878 app.detail_buffer.clear();
879 }
880 refresh_mailbox(app, mailbox_source);
881 refresh_approvals(app, approval_source);
882 app.last_refresh = Instant::now();
883}
884
885pub fn refresh_approvals<A: ApprovalSource>(app: &mut App, approval_source: &A) {
891 let approvals = approval_source.pending().unwrap_or_default();
892 app.replace_approvals(approvals);
893}
894
895pub fn refresh_mailbox<M: MailboxSource>(app: &mut App, mailbox_source: &M) {
900 let Some(agent_id) = app.selected_agent_id() else {
901 return;
904 };
905 let project_id = app
906 .selected_agent
907 .and_then(|i| app.team.agents.get(i))
908 .map(|a| a.project.clone())
909 .unwrap_or_default();
910 if let Ok(batch) = mailbox_source.inbox(&agent_id, app.mailbox.inbox_after) {
911 app.mailbox.extend(MailboxTab::Inbox, batch);
912 }
913 if let Ok(batch) = mailbox_source.sent(&agent_id, app.mailbox.sent_after) {
914 app.mailbox.extend(MailboxTab::Sent, batch);
915 }
916 if let Ok(batch) = mailbox_source.channel_feed(&agent_id, app.mailbox.channel_after) {
917 app.mailbox.extend(MailboxTab::Channel, batch);
918 }
919 if let Ok(batch) = mailbox_source.wire(&project_id, app.mailbox.wire_after) {
920 app.mailbox.extend(MailboxTab::Wire, batch);
921 }
922}
923
924pub fn run<B: Backend>(terminal: &mut Terminal<B>) -> Result<()> {
925 let mut app = App::new();
926 let pane_source = TmuxPaneSource;
927 let decider = CliApprovalDecider;
928 let sender = CliMessageSender;
929 let key_sender = TmuxKeySender;
930 let pane_resizer = crate::pane_resize::TmuxPaneResizer;
931 refresh_with_default_sources(&mut app, &pane_source);
934 let mut watch = Watch::try_new(&app.team.root.join("state"));
935 while app.running {
936 terminal.draw(|f| draw(f, &app))?;
937 let term_sz = terminal.size()?;
945 let term_area = ratatui::layout::Rect::new(0, 0, term_sz.width, term_sz.height);
946 sync_focused_pane_size_to(&mut app, term_area, &pane_resizer);
947 if event::poll(POLL_INTERVAL)? {
948 let db_path = app.team.root.join("state/mailbox.db");
952 let mailbox_source = BrokerMailboxSource::new(db_path);
953 handle_event(
954 &mut app,
955 event::read()?,
956 &decider,
957 &sender,
958 &mailbox_source,
959 &key_sender,
960 );
961 }
962 if matches!(app.stage, Stage::Splash) && app.splash_started.elapsed() >= SPLASH_AUTO_DISMISS
963 {
964 app.dismiss_splash();
965 }
966 let dirty = watch.take_dirty();
973 if dirty || app.last_refresh.elapsed() >= REFRESH_INTERVAL {
974 let prior_root = app.team.root.clone();
975 refresh_with_default_sources(&mut app, &pane_source);
976 if app.team.root != prior_root {
979 watch = Watch::try_new(&app.team.root.join("state"));
980 }
981 }
982 }
983 Ok(())
984}
985
986pub fn sync_focused_pane_size_to<R: crate::pane_resize::PaneResizer>(
1001 app: &mut App,
1002 total_area: ratatui::layout::Rect,
1003 resizer: &R,
1004) {
1005 if !matches!(app.layout, MainLayout::Triptych) {
1006 return;
1007 }
1008 let Some(detail) =
1009 crate::pane_resize::triptych_detail_area(total_area, app.has_pending_approvals())
1010 else {
1011 return;
1012 };
1013 let Some(session) = app.focused_session().map(|s| s.to_string()) else {
1014 return;
1015 };
1016 let target = (detail.width, detail.height);
1017 if !crate::pane_resize::should_sync(&app.last_synced_pane_sizes, &session, target) {
1018 return;
1019 }
1020 resizer.resize(&session, target.0, target.1);
1021 app.last_synced_pane_sizes.insert(session, target);
1022}
1023
1024fn refresh_with_default_sources<P: PaneSource>(app: &mut App, pane_source: &P) {
1029 if let Ok(Some(snapshot)) = TeamSnapshot::discover_and_load() {
1030 app.replace_team(snapshot);
1031 }
1032 let db_path = app.team.root.join("state/mailbox.db");
1033 let mailbox_source = BrokerMailboxSource::new(db_path.clone());
1034 let approval_source = BrokerApprovalSource::new(db_path);
1035 if let Some(session) = app.focused_session().map(|s| s.to_string()) {
1036 if let Ok(lines) = pane_source.capture(&session) {
1037 app.set_detail_buffer(lines);
1038 }
1039 } else {
1040 app.detail_buffer.clear();
1041 }
1042 refresh_mailbox(app, &mailbox_source);
1043 refresh_approvals(app, &approval_source);
1044 app.sysinfo.refresh_cpu_usage();
1050 app.sysinfo.refresh_memory();
1051 app.last_refresh = Instant::now();
1052}
1053
1054pub fn draw(f: &mut Frame<'_>, app: &App) {
1055 let area = f.area();
1056 match app.stage {
1057 Stage::Splash => splash::draw(f, app),
1058 Stage::Triptych => draw_main(f, area, app),
1059 Stage::StreamKeys => draw_main(f, area, app),
1064 Stage::QuitConfirm => {
1065 draw_main(f, area, app);
1066 draw_quit_confirm(f, area);
1067 }
1068 Stage::ApprovalsModal => {
1069 draw_main(f, area, app);
1070 draw_approvals_modal(f, area, app);
1071 }
1072 Stage::ComposeModal => {
1073 draw_main(f, area, app);
1074 draw_compose_modal(f, area, app);
1075 }
1076 Stage::HelpOverlay => {
1077 draw_main(f, area, app);
1078 let buf = f.buffer_mut();
1079 render_help_overlay(area, buf, app);
1080 }
1081 Stage::Tutorial => {
1082 draw_main(f, area, app);
1083 let buf = f.buffer_mut();
1084 render_tutorial(area, buf, app);
1085 }
1086 }
1087}
1088
1089fn render_help_overlay(area: Rect, buf: &mut Buffer, app: &App) {
1090 let popup_w = 70u16.min(area.width.saturating_sub(4));
1091 let popup_h = 24u16.min(area.height.saturating_sub(2));
1092 let popup = centered_rect(popup_w, popup_h, area);
1093 Clear.render(popup, buf);
1094 let block = Block::default()
1095 .title("help · ? to close")
1096 .borders(Borders::ALL)
1097 .border_style(Style::default().fg(app.capabilities.accent()));
1098 let inner = block.inner(popup);
1099 block.render(popup, buf);
1100 let muted = Style::default().fg(app.capabilities.muted());
1101 let bold = Style::default().add_modifier(Modifier::BOLD);
1102 let mut lines: Vec<ratatui::text::Line<'_>> = Vec::new();
1103 for group in crate::help::ALL_GROUPS {
1104 lines.push(ratatui::text::Line::styled(group.title, bold));
1105 for b in group.bindings {
1106 lines.push(ratatui::text::Line::raw(format!(
1107 " {:<22} {}",
1108 b.chord, b.description
1109 )));
1110 }
1111 lines.push(ratatui::text::Line::styled("", muted));
1112 }
1113 Paragraph::new(lines).render(inner, buf);
1114}
1115
1116fn render_tutorial(area: Rect, buf: &mut Buffer, app: &App) {
1117 let popup_w = 64u16.min(area.width.saturating_sub(4));
1118 let popup_h = 14u16.min(area.height.saturating_sub(2));
1119 let popup = centered_rect(popup_w, popup_h, area);
1120 Clear.render(popup, buf);
1121 let total = crate::onboarding::STEPS.len();
1122 let i = app.tutorial_step.min(total.saturating_sub(1));
1123 let step = &crate::onboarding::STEPS[i];
1124 let block = Block::default()
1125 .title(format!("tutorial · {}/{total}", i + 1))
1126 .borders(Borders::ALL)
1127 .border_style(Style::default().fg(app.capabilities.accent()));
1128 let inner = block.inner(popup);
1129 block.render(popup, buf);
1130 let muted = Style::default().fg(app.capabilities.muted());
1131 let lines = vec![
1132 ratatui::text::Line::styled(step.heading, Style::default().add_modifier(Modifier::BOLD)),
1133 ratatui::text::Line::raw(""),
1134 ratatui::text::Line::raw(step.body),
1135 ratatui::text::Line::raw(""),
1136 ratatui::text::Line::styled("any key next · k / ↑ / p back · Esc skip", muted),
1137 ];
1138 Paragraph::new(lines)
1144 .wrap(ratatui::widgets::Wrap { trim: true })
1145 .render(inner, buf);
1146}
1147
1148fn draw_main(f: &mut Frame<'_>, area: Rect, app: &App) {
1149 let chunks = Layout::default()
1154 .direction(Direction::Vertical)
1155 .constraints([
1156 Constraint::Min(3),
1157 Constraint::Length(1), Constraint::Length(1), ])
1160 .split(area);
1161 let buf = f.buffer_mut();
1162 match app.layout {
1163 crate::triptych::MainLayout::Triptych => {
1164 triptych::Triptych { app }.render(chunks[0], buf);
1165 }
1166 crate::triptych::MainLayout::Wall => {
1167 layouts::Wall { app }.render(chunks[0], buf);
1168 }
1169 crate::triptych::MainLayout::MailboxFirst => {
1170 layouts::MailboxFirst { app }.render(chunks[0], buf);
1171 }
1172 }
1173 statusline::Statusline { app }.render(chunks[1], buf);
1174 status_bar::StatusBar { app }.render(chunks[2], buf);
1175}
1176
1177fn draw_approvals_modal(f: &mut Frame<'_>, area: Rect, app: &App) {
1178 let buf = f.buffer_mut();
1179 render_approvals_modal(area, buf, app);
1180}
1181
1182fn draw_compose_modal(f: &mut Frame<'_>, area: Rect, app: &App) {
1183 let buf = f.buffer_mut();
1184 render_compose_modal(area, buf, app);
1185}
1186
1187fn render_compose_picker_body(inner: Rect, buf: &mut Buffer, app: &App) {
1188 let muted = Style::default().fg(app.capabilities.muted());
1189 let chunks = Layout::default()
1190 .direction(Direction::Vertical)
1191 .constraints([
1192 Constraint::Min(1),
1193 Constraint::Length(1),
1194 Constraint::Length(1),
1195 ])
1196 .split(inner);
1197 let lines: Vec<ratatui::text::Line<'_>> = if app.team.channels.is_empty() {
1198 vec![ratatui::text::Line::styled(
1199 "(no channels declared in team-compose)",
1200 muted,
1201 )]
1202 } else {
1203 app.team
1204 .channels
1205 .iter()
1206 .enumerate()
1207 .map(|(i, ch)| {
1208 let label = format!(" #{} ({})", ch.name, ch.project_id);
1209 let style = if i == app.compose_picker_index {
1210 Style::default()
1211 .fg(app.capabilities.accent())
1212 .add_modifier(Modifier::REVERSED)
1213 } else {
1214 Style::default()
1215 };
1216 ratatui::text::Line::styled(label, style)
1217 })
1218 .collect()
1219 };
1220 Paragraph::new(lines).render(chunks[0], buf);
1221 Paragraph::new("pick a channel to broadcast to")
1222 .style(muted)
1223 .render(chunks[1], buf);
1224 Paragraph::new("Enter pick · j/k navigate · Esc cancel")
1225 .style(muted)
1226 .render(chunks[2], buf);
1227}
1228
1229fn render_compose_modal(area: Rect, buf: &mut Buffer, app: &App) {
1230 let popup_w = 80u16.min(area.width.saturating_sub(4));
1231 let popup_h = 16u16.min(area.height.saturating_sub(2));
1232 let popup = centered_rect(popup_w, popup_h, area);
1233 Clear.render(popup, buf);
1234 let title = app
1235 .compose_target
1236 .as_ref()
1237 .map(|t| t.title(&app.team))
1238 .unwrap_or_else(|| "→ ?".into());
1239 let block = Block::default()
1240 .title(title)
1241 .borders(Borders::ALL)
1242 .border_style(Style::default().fg(app.capabilities.accent()));
1243 let inner = block.inner(popup);
1244 block.render(popup, buf);
1245
1246 if inner.height < 3 {
1247 return;
1248 }
1249 if app.compose_picker_open {
1253 render_compose_picker_body(inner, buf, app);
1254 return;
1255 }
1256 if app.compose_attach_input_open {
1257 render_compose_attach_input(inner, buf, app);
1258 return;
1259 }
1260 let chunks = Layout::default()
1263 .direction(Direction::Vertical)
1264 .constraints([
1265 Constraint::Min(1), Constraint::Length(1), Constraint::Length(1), ])
1269 .split(inner);
1270
1271 let muted = Style::default().fg(app.capabilities.muted());
1276 let body_lines: Vec<ratatui::text::Line<'_>> = app
1277 .compose_editor
1278 .lines
1279 .iter()
1280 .enumerate()
1281 .map(|(row, line)| {
1282 if row == app.compose_editor.cursor_row
1283 && app.compose_editor.mode == crate::compose::VimMode::Insert
1284 {
1285 let col = app.compose_editor.cursor_col.min(line.len());
1286 let (head, tail) = line.split_at(col);
1287 ratatui::text::Line::from(vec![
1288 ratatui::text::Span::raw(head.to_string()),
1289 ratatui::text::Span::styled(
1290 "▏",
1291 Style::default().fg(app.capabilities.accent()),
1292 ),
1293 ratatui::text::Span::raw(tail.to_string()),
1294 ])
1295 } else {
1296 ratatui::text::Line::raw(line.clone())
1297 }
1298 })
1299 .collect();
1300 Paragraph::new(body_lines).render(chunks[0], buf);
1301
1302 let error_line = match (&app.compose_error, app.compose_editor.mode) {
1303 (Some(e), _) => format!("error: {e}"),
1304 (None, crate::compose::VimMode::Ex) => format!(":{}", app.compose_editor.ex_buffer),
1305 (None, crate::compose::VimMode::Normal) => "-- NORMAL --".into(),
1306 (None, crate::compose::VimMode::Insert) => "-- INSERT --".into(),
1307 };
1308 let style = if app.compose_error.is_some() {
1309 Style::default().fg(app.capabilities.accent())
1310 } else {
1311 muted
1312 };
1313 Paragraph::new(error_line)
1314 .style(style)
1315 .render(chunks[1], buf);
1316
1317 Paragraph::new("Alt+Enter send · Esc Esc cancel · Tab attach")
1318 .style(muted)
1319 .render(chunks[2], buf);
1320}
1321
1322fn render_compose_attach_input(inner: Rect, buf: &mut Buffer, app: &App) {
1327 let muted = Style::default().fg(app.capabilities.muted());
1328 let chunks = Layout::default()
1329 .direction(Direction::Vertical)
1330 .constraints([
1331 Constraint::Min(1),
1332 Constraint::Length(1),
1333 Constraint::Length(1),
1334 ])
1335 .split(inner);
1336 let line = ratatui::text::Line::from(vec![
1337 ratatui::text::Span::raw(format!("path: {}", app.compose_attach_buffer)),
1338 ratatui::text::Span::styled("▏", Style::default().fg(app.capabilities.accent())),
1339 ]);
1340 Paragraph::new(line).render(chunks[0], buf);
1341 Paragraph::new("type or paste an absolute path; the agent reads it via the broker")
1342 .style(muted)
1343 .render(chunks[1], buf);
1344 Paragraph::new("Enter confirm · Esc cancel")
1345 .style(muted)
1346 .render(chunks[2], buf);
1347}
1348
1349fn render_approvals_modal(area: Rect, buf: &mut Buffer, app: &App) {
1350 let popup_w = 80u16.min(area.width.saturating_sub(4));
1351 let popup_h = 18u16.min(area.height.saturating_sub(2));
1352 let popup = centered_rect(popup_w, popup_h, area);
1353 Clear.render(popup, buf);
1354 let n = app.pending_approvals.len();
1355 let i = app.selected_approval.min(n.saturating_sub(1));
1356 let title = format!("approvals · {}/{n}", i + 1);
1357 let block = Block::default()
1358 .title(title)
1359 .borders(Borders::ALL)
1360 .border_style(Style::default().fg(app.capabilities.accent()));
1361 let inner = block.inner(popup);
1362 block.render(popup, buf);
1363
1364 let muted = Style::default().fg(app.capabilities.muted());
1365 let bold = Style::default().add_modifier(Modifier::BOLD);
1366
1367 let Some(a) = app.focused_approval() else {
1368 Paragraph::new("(no pending approvals)")
1369 .style(muted)
1370 .alignment(Alignment::Center)
1371 .render(inner, buf);
1372 return;
1373 };
1374
1375 let mut lines: Vec<ratatui::text::Line<'_>> = vec![
1376 ratatui::text::Line::styled(format!("#{} {}", a.id, a.action), bold),
1377 ratatui::text::Line::styled(
1378 format!("from: {}", crate::data::agent_label(&app.team, &a.agent_id)),
1379 muted,
1380 ),
1381 ratatui::text::Line::raw(""),
1382 ratatui::text::Line::raw(a.summary.clone()),
1383 ];
1384 if !a.payload_json.is_empty() && a.payload_json != "{}" {
1385 lines.push(ratatui::text::Line::raw(""));
1386 lines.push(ratatui::text::Line::styled("payload:", muted));
1387 for chunk in a.payload_json.lines().take(4) {
1388 lines.push(ratatui::text::Line::raw(chunk.to_string()));
1389 }
1390 }
1391 if let Some(err) = &app.approval_error {
1392 lines.push(ratatui::text::Line::raw(""));
1393 lines.push(ratatui::text::Line::styled(
1394 format!("error: {err}"),
1395 Style::default().fg(app.capabilities.accent()),
1396 ));
1397 }
1398 lines.push(ratatui::text::Line::raw(""));
1399 lines.push(ratatui::text::Line::styled(
1400 "[y] approve · [Shift-N] deny · [j/k] cycle · [Esc] close",
1401 muted,
1402 ));
1403 Paragraph::new(lines).render(inner, buf);
1404}
1405
1406fn draw_quit_confirm(f: &mut Frame<'_>, area: Rect) {
1407 let popup_w = 36u16.min(area.width.saturating_sub(2));
1408 let popup_h = 5u16.min(area.height.saturating_sub(2));
1409 let popup = centered_rect(popup_w, popup_h, area);
1410 let buf = f.buffer_mut();
1411 Clear.render(popup, buf);
1412 Paragraph::new("Quit teamctl-ui? [y / n]")
1413 .alignment(Alignment::Center)
1414 .block(Block::default().borders(Borders::ALL).title("confirm"))
1415 .render(popup, buf);
1416}
1417
1418fn centered_rect(w: u16, h: u16, area: Rect) -> Rect {
1419 let x = area.x + area.width.saturating_sub(w) / 2;
1420 let y = area.y + area.height.saturating_sub(h) / 2;
1421 Rect {
1422 x,
1423 y,
1424 width: w,
1425 height: h,
1426 }
1427}
1428
1429pub fn handle_event<D: ApprovalDecider, S: MessageSender, M: MailboxSource, K: KeySender>(
1430 app: &mut App,
1431 ev: Event,
1432 decider: &D,
1433 sender: &S,
1434 mailbox_source: &M,
1435 key_sender: &K,
1436) {
1437 use crossterm::event::KeyModifiers;
1438 match ev {
1439 Event::Key(k) if k.kind == KeyEventKind::Press => match app.stage {
1440 Stage::Splash => app.dismiss_splash(),
1441 Stage::Triptych => match k.code {
1442 KeyCode::Char('q') if app.pending_chord == Some(KeyCode::Char('w')) => {
1447 app.pending_chord = None;
1448 app.close_focused_split();
1449 }
1450 KeyCode::Char('o') if app.pending_chord == Some(KeyCode::Char('w')) => {
1451 app.pending_chord = None;
1452 if !app.detail_splits.is_empty() {
1453 let keep = app.selected_split.min(app.detail_splits.len() - 1);
1454 let kept = app.detail_splits.remove(keep);
1455 app.detail_splits.clear();
1456 app.detail_splits.push(kept);
1457 app.selected_split = 0;
1458 }
1459 }
1460 KeyCode::Char('q') if k.modifiers.is_empty() => app.enter_quit_confirm(),
1461 KeyCode::Char('a') => app.enter_approvals_modal(),
1465 KeyCode::Char('@') => app.enter_compose_dm_for_focused(),
1470 KeyCode::Char('!') => app.enter_compose_broadcast_with_picker(),
1471 KeyCode::Char('w') | KeyCode::Char('W')
1481 if k.modifiers.contains(KeyModifiers::CONTROL)
1482 && !app.detail_splits.is_empty() =>
1483 {
1484 app.pending_chord = Some(KeyCode::Char('w'))
1485 }
1486 KeyCode::Char('w') | KeyCode::Char('W')
1491 if k.modifiers.contains(KeyModifiers::CONTROL) =>
1492 {
1493 app.toggle_wall_layout()
1494 }
1495 KeyCode::Char('m') | KeyCode::Char('M')
1496 if k.modifiers.contains(KeyModifiers::CONTROL) =>
1497 {
1498 app.toggle_mailbox_first_layout()
1499 }
1500 KeyCode::Char('|') if k.modifiers.contains(KeyModifiers::CONTROL) => {
1504 app.add_detail_split_vertical()
1505 }
1506 KeyCode::Char('-') if k.modifiers.contains(KeyModifiers::CONTROL) => {
1507 app.add_detail_split_horizontal()
1508 }
1509 KeyCode::Char('h')
1514 | KeyCode::Char('H')
1515 | KeyCode::Char('k')
1516 | KeyCode::Char('K')
1517 if k.modifiers.contains(KeyModifiers::CONTROL) =>
1518 {
1519 app.cycle_split_prev()
1520 }
1521 KeyCode::Char('l')
1522 | KeyCode::Char('L')
1523 | KeyCode::Char('j')
1524 | KeyCode::Char('J')
1525 if k.modifiers.contains(KeyModifiers::CONTROL) =>
1526 {
1527 app.cycle_split_next()
1528 }
1529 KeyCode::Char('q') | KeyCode::Char('Q')
1534 if k.modifiers.contains(KeyModifiers::CONTROL) =>
1535 {
1536 app.close_focused_split()
1537 }
1538 KeyCode::Char('e') | KeyCode::Char('E')
1546 if k.modifiers.contains(KeyModifiers::CONTROL)
1547 && app.focused_pane == Pane::Detail =>
1548 {
1549 app.enter_stream_keys()
1550 }
1551 KeyCode::Char('?')
1559 if k.modifiers.is_empty() || k.modifiers == KeyModifiers::SHIFT =>
1560 {
1561 app.enter_help_overlay()
1562 }
1563 KeyCode::Char('t') if k.modifiers.is_empty() => app.enter_tutorial(),
1564 KeyCode::BackTab => app.cycle_focus_back(),
1568 KeyCode::Tab if k.modifiers.contains(KeyModifiers::SHIFT) => app.cycle_focus_back(),
1569 KeyCode::Tab => app.cycle_focus(),
1577 KeyCode::Right if app.focused_pane == Pane::Mailbox => app.cycle_mailbox_tab(),
1584 KeyCode::Left if app.focused_pane == Pane::Mailbox => app.cycle_mailbox_tab_back(),
1585 KeyCode::Up | KeyCode::Char('k') if matches!(app.layout, MainLayout::Wall) => {
1590 app.wall_scroll_up()
1591 }
1592 KeyCode::Down | KeyCode::Char('j') if matches!(app.layout, MainLayout::Wall) => {
1593 app.wall_scroll_down()
1594 }
1595 KeyCode::Up | KeyCode::Char('k')
1598 if matches!(app.layout, MainLayout::MailboxFirst) =>
1599 {
1600 app.select_prev_channel()
1601 }
1602 KeyCode::Down | KeyCode::Char('j')
1603 if matches!(app.layout, MainLayout::MailboxFirst) =>
1604 {
1605 app.select_next_channel()
1606 }
1607 KeyCode::Up | KeyCode::Char('k') if app.focused_pane == Pane::Roster => {
1611 app.select_prev()
1612 }
1613 KeyCode::Down | KeyCode::Char('j') if app.focused_pane == Pane::Roster => {
1614 app.select_next()
1615 }
1616 _ => {}
1617 },
1618 Stage::QuitConfirm => match k.code {
1619 KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => app.confirm_quit(),
1620 KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => app.cancel_quit(),
1621 _ => {}
1622 },
1623 Stage::ApprovalsModal => match k.code {
1624 KeyCode::Char('y') | KeyCode::Char('Y') => {
1633 app.apply_decision(decider, Decision::Approve, "")
1634 }
1635 KeyCode::Char('N') => app.apply_decision(decider, Decision::Deny, ""),
1636 KeyCode::Char('j') | KeyCode::Down => app.cycle_approval_next(),
1637 KeyCode::Char('k') | KeyCode::Up => app.cycle_approval_prev(),
1638 KeyCode::Esc | KeyCode::Char('q') => app.close_approvals_modal(),
1639 _ => {}
1640 },
1641 Stage::ComposeModal => {
1642 if app.compose_picker_open {
1646 match k.code {
1647 KeyCode::Down | KeyCode::Char('j') => app.picker_next(),
1648 KeyCode::Up | KeyCode::Char('k') => app.picker_prev(),
1649 KeyCode::Enter => app.picker_confirm(),
1650 KeyCode::Esc => {
1659 app.compose_picker_open = false;
1660 app.compose_picker_index = 0;
1661 }
1662 _ => {}
1663 }
1664 } else if app.compose_attach_input_open {
1665 match k.code {
1672 KeyCode::Char(c) => app.compose_attach_buffer.push(c),
1673 KeyCode::Backspace => {
1674 app.compose_attach_buffer.pop();
1675 }
1676 KeyCode::Enter => app.confirm_compose_attach_input(),
1677 KeyCode::Esc => app.close_compose_attach_input(),
1678 _ => {}
1679 }
1680 } else if k.code == KeyCode::Tab {
1681 app.open_compose_attach_input();
1686 } else {
1687 match app.compose_editor.apply_key(k) {
1690 EditorAction::Continue => {}
1691 EditorAction::Send => app.apply_send(sender, mailbox_source),
1692 EditorAction::Cancel => app.close_compose_modal(),
1693 }
1694 }
1695 }
1696 Stage::HelpOverlay => match k.code {
1697 KeyCode::Esc | KeyCode::Char('?') | KeyCode::Char('q') => app.close_help_overlay(),
1698 _ => {}
1699 },
1700 Stage::Tutorial => match k.code {
1701 KeyCode::Esc => app.close_tutorial(),
1702 KeyCode::Char('k') | KeyCode::Up | KeyCode::Char('p') => app.tutorial_back(),
1703 _ => app.tutorial_advance(),
1704 },
1705 Stage::StreamKeys => {
1713 if matches!(k.code, KeyCode::Esc) {
1714 app.exit_stream_keys();
1715 } else if let Some(session) = app.stream_target_session() {
1716 if let Some(encoded) = encode_key(k) {
1717 let _ = key_sender.send(&session, &encoded);
1722 }
1723 } else {
1724 app.exit_stream_keys();
1729 }
1730 }
1731 },
1732 Event::Resize(_, _) => {
1733 }
1735 Event::Mouse(m) if matches!(app.stage, Stage::Triptych) => {
1746 use crossterm::event::MouseEventKind;
1747 let direction = match m.kind {
1748 MouseEventKind::ScrollUp => Some(ScrollDirection::Up),
1749 MouseEventKind::ScrollDown => Some(ScrollDirection::Down),
1750 _ => None,
1751 };
1752 if let Some(dir) = direction {
1753 match app.focused_pane {
1754 Pane::Detail => {
1755 if let Some(session) = app.focused_session().map(|s| s.to_string()) {
1756 let _ = key_sender.scroll(&session, dir);
1761 }
1762 }
1763 Pane::Roster => match dir {
1764 ScrollDirection::Up => app.select_prev(),
1765 ScrollDirection::Down => app.select_next(),
1766 },
1767 Pane::Mailbox => {
1768 }
1771 }
1772 }
1773 }
1774 _ => {}
1775 }
1776}
1777
1778pub fn render_to_buffer(app: &App, width: u16, height: u16) -> Buffer {
1782 let area = Rect::new(0, 0, width, height);
1783 let mut buf = Buffer::empty(area);
1784 match app.stage {
1785 Stage::Splash => splash::Splash { app }.render(area, &mut buf),
1786 Stage::Triptych => render_main(app, area, &mut buf),
1787 Stage::StreamKeys => render_main(app, area, &mut buf),
1788 Stage::QuitConfirm => {
1789 render_main(app, area, &mut buf);
1790 render_quit_confirm(area, &mut buf);
1791 }
1792 Stage::ApprovalsModal => {
1793 render_main(app, area, &mut buf);
1794 render_approvals_modal(area, &mut buf, app);
1795 }
1796 Stage::ComposeModal => {
1797 render_main(app, area, &mut buf);
1798 render_compose_modal(area, &mut buf, app);
1799 }
1800 Stage::HelpOverlay => {
1801 render_main(app, area, &mut buf);
1802 render_help_overlay(area, &mut buf, app);
1803 }
1804 Stage::Tutorial => {
1805 render_main(app, area, &mut buf);
1806 render_tutorial(area, &mut buf, app);
1807 }
1808 }
1809 buf
1810}
1811
1812fn render_main(app: &App, area: Rect, buf: &mut Buffer) {
1813 let chunks = Layout::default()
1816 .direction(Direction::Vertical)
1817 .constraints([
1818 Constraint::Min(3),
1819 Constraint::Length(1), Constraint::Length(1), ])
1822 .split(area);
1823 match app.layout {
1824 crate::triptych::MainLayout::Triptych => {
1825 triptych::Triptych { app }.render(chunks[0], buf);
1826 }
1827 crate::triptych::MainLayout::Wall => {
1828 layouts::Wall { app }.render(chunks[0], buf);
1829 }
1830 crate::triptych::MainLayout::MailboxFirst => {
1831 layouts::MailboxFirst { app }.render(chunks[0], buf);
1832 }
1833 }
1834 statusline::Statusline { app }.render(chunks[1], buf);
1835 status_bar::StatusBar { app }.render(chunks[2], buf);
1836}
1837
1838fn render_quit_confirm(area: Rect, buf: &mut Buffer) {
1839 let popup_w = 36u16.min(area.width.saturating_sub(2));
1840 let popup_h = 5u16.min(area.height.saturating_sub(2));
1841 let popup = centered_rect(popup_w, popup_h, area);
1842 Clear.render(popup, buf);
1843 Paragraph::new("Quit teamctl-ui? [y / n]")
1844 .alignment(Alignment::Center)
1845 .block(Block::default().borders(Borders::ALL).title("confirm"))
1846 .render(popup, buf);
1847}
1848
1849#[cfg(test)]
1850mod tests {
1851 use super::*;
1852 use crate::data::AgentInfo;
1853 use crossterm::event::{KeyEvent, KeyEventState, KeyModifiers};
1854 use team_core::supervisor::AgentState;
1855
1856 fn key(code: KeyCode) -> Event {
1857 Event::Key(KeyEvent {
1858 code,
1859 modifiers: KeyModifiers::NONE,
1860 kind: KeyEventKind::Press,
1861 state: KeyEventState::NONE,
1862 })
1863 }
1864
1865 fn key_with(code: KeyCode, modifiers: KeyModifiers) -> Event {
1866 Event::Key(KeyEvent {
1867 code,
1868 modifiers,
1869 kind: KeyEventKind::Press,
1870 state: KeyEventState::NONE,
1871 })
1872 }
1873
1874 struct NoopDecider;
1876 impl crate::approvals::ApprovalDecider for NoopDecider {
1877 fn decide(
1878 &self,
1879 _root: &std::path::Path,
1880 _id: i64,
1881 _kind: crate::approvals::Decision,
1882 _note: &str,
1883 ) -> anyhow::Result<()> {
1884 Ok(())
1885 }
1886 }
1887
1888 struct NoopSender;
1890 impl crate::compose::MessageSender for NoopSender {
1891 fn send_dm(
1892 &self,
1893 _root: &std::path::Path,
1894 _agent: &str,
1895 _body: &str,
1896 ) -> anyhow::Result<()> {
1897 Ok(())
1898 }
1899 fn broadcast(
1900 &self,
1901 _root: &std::path::Path,
1902 _channel: &str,
1903 _body: &str,
1904 ) -> anyhow::Result<()> {
1905 Ok(())
1906 }
1907 }
1908
1909 struct EmptyMailbox;
1912 impl crate::mailbox::MailboxSource for EmptyMailbox {
1913 fn inbox(&self, _id: &str, _after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
1914 Ok(Vec::new())
1915 }
1916 fn sent(&self, _id: &str, _after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
1917 Ok(Vec::new())
1918 }
1919 fn channel_feed(
1920 &self,
1921 _id: &str,
1922 _after: i64,
1923 ) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
1924 Ok(Vec::new())
1925 }
1926 fn wire(&self, _id: &str, _after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
1927 Ok(Vec::new())
1928 }
1929 }
1930
1931 fn dispatch(app: &mut App, ev: Event) {
1934 super::handle_event(
1935 app,
1936 ev,
1937 &NoopDecider,
1938 &NoopSender,
1939 &EmptyMailbox,
1940 &crate::keysender::test_support::MockKeySender::default(),
1941 );
1942 }
1943
1944 fn agent(id: &str, state: AgentState) -> AgentInfo {
1945 AgentInfo {
1946 id: id.into(),
1947 agent: id
1948 .split_once(':')
1949 .map(|(_, a)| a.to_string())
1950 .unwrap_or_default(),
1951 project: id
1952 .split_once(':')
1953 .map(|(p, _)| p.to_string())
1954 .unwrap_or_default(),
1955 tmux_session: format!("t-{}", id.replace(':', "-")),
1956 state,
1957 unread_mail: 0,
1958 pending_approvals: 0,
1959 is_manager: false,
1960 display_name: None,
1961 rate_limit_resets_at: None,
1962 }
1963 }
1964
1965 pub fn fixture_team(agents: Vec<AgentInfo>) -> TeamSnapshot {
1966 TeamSnapshot {
1967 root: std::path::PathBuf::from("/fixture"),
1968 team_name: "fixture".into(),
1969 agents,
1970 channels: Vec::new(),
1971 }
1972 }
1973
1974 #[test]
1975 fn splash_dismissed_by_any_key() {
1976 let mut app = App::new();
1977 assert_eq!(app.stage, Stage::Splash);
1978 dispatch(&mut app, key(KeyCode::Char(' ')));
1979 assert_eq!(app.stage, Stage::Triptych);
1980 }
1981
1982 #[test]
1983 fn tab_cycles_panes_uniformly_and_wraps_through_mailbox() {
1984 let mut app = App::new();
1991 app.dismiss_splash();
1992 assert_eq!(app.focused_pane, Pane::Roster);
1993 dispatch(&mut app, key(KeyCode::Tab));
1994 assert_eq!(app.focused_pane, Pane::Detail);
1995 dispatch(&mut app, key(KeyCode::Tab));
1996 assert_eq!(app.focused_pane, Pane::Mailbox);
1997 assert_eq!(
1998 app.mailbox_tab,
1999 MailboxTab::Inbox,
2000 "Tab into mailbox does NOT touch the active mailbox tab"
2001 );
2002 dispatch(&mut app, key(KeyCode::Tab));
2003 assert_eq!(
2004 app.focused_pane,
2005 Pane::Roster,
2006 "Tab from mailbox wraps to roster, not into mailbox subtabs"
2007 );
2008 assert_eq!(
2009 app.mailbox_tab,
2010 MailboxTab::Inbox,
2011 "mailbox tab still untouched"
2012 );
2013 }
2014
2015 #[test]
2016 fn arrow_keys_walk_mailbox_tabs_when_mailbox_focused() {
2017 let mut app = App::new();
2022 app.dismiss_splash();
2023 dispatch(&mut app, key(KeyCode::Tab));
2025 dispatch(&mut app, key(KeyCode::Tab));
2026 assert_eq!(app.focused_pane, Pane::Mailbox);
2027 assert_eq!(app.mailbox_tab, MailboxTab::Inbox);
2028
2029 dispatch(&mut app, key(KeyCode::Right));
2030 assert_eq!(app.mailbox_tab, MailboxTab::Sent);
2031 dispatch(&mut app, key(KeyCode::Right));
2032 assert_eq!(app.mailbox_tab, MailboxTab::Channel);
2033 dispatch(&mut app, key(KeyCode::Right));
2034 assert_eq!(app.mailbox_tab, MailboxTab::Wire);
2035 dispatch(&mut app, key(KeyCode::Right));
2036 assert_eq!(app.mailbox_tab, MailboxTab::Inbox, "→ wraps");
2037
2038 dispatch(&mut app, key(KeyCode::Left));
2039 assert_eq!(app.mailbox_tab, MailboxTab::Wire, "← walks back");
2040 }
2041
2042 #[test]
2043 fn arrow_keys_no_op_when_mailbox_not_focused() {
2044 let mut app = App::new();
2047 app.dismiss_splash();
2048 assert_eq!(app.focused_pane, Pane::Roster);
2049 let initial = app.mailbox_tab;
2050 dispatch(&mut app, key(KeyCode::Right));
2051 dispatch(&mut app, key(KeyCode::Left));
2052 assert_eq!(
2053 app.mailbox_tab, initial,
2054 "←/→ from non-mailbox panes must not flip the active tab"
2055 );
2056 }
2057
2058 #[test]
2059 fn brackets_no_longer_cycle_mailbox_tabs() {
2060 let mut app = App::new();
2065 app.dismiss_splash();
2066 dispatch(&mut app, key(KeyCode::Tab));
2067 dispatch(&mut app, key(KeyCode::Tab));
2068 assert_eq!(app.focused_pane, Pane::Mailbox);
2069 let initial = app.mailbox_tab;
2070
2071 dispatch(&mut app, key(KeyCode::Char(']')));
2072 dispatch(&mut app, key(KeyCode::Char('[')));
2073 assert_eq!(
2074 app.mailbox_tab, initial,
2075 "`[` / `]` must no longer cycle mailbox tabs (T-124 hard-swap)",
2076 );
2077 }
2078
2079 #[test]
2080 fn q_opens_confirm_then_n_cancels() {
2081 let mut app = App::new();
2082 app.dismiss_splash();
2083 dispatch(&mut app, key(KeyCode::Char('q')));
2084 assert_eq!(app.stage, Stage::QuitConfirm);
2085 dispatch(&mut app, key(KeyCode::Char('n')));
2086 assert_eq!(app.stage, Stage::Triptych);
2087 assert!(app.running, "n must not exit");
2088 }
2089
2090 #[test]
2091 fn q_then_y_exits() {
2092 let mut app = App::new();
2093 app.dismiss_splash();
2094 dispatch(&mut app, key(KeyCode::Char('q')));
2095 dispatch(&mut app, key(KeyCode::Char('y')));
2096 assert!(!app.running);
2097 }
2098
2099 #[test]
2100 fn esc_cancels_quit_confirm() {
2101 let mut app = App::new();
2102 app.dismiss_splash();
2103 app.enter_quit_confirm();
2104 dispatch(&mut app, key(KeyCode::Esc));
2105 assert_eq!(app.stage, Stage::Triptych);
2106 }
2107
2108 #[test]
2109 fn render_does_not_panic_at_minimal_size() {
2110 let app = App::new();
2111 let _ = render_to_buffer(&app, 20, 8);
2112 }
2113
2114 #[test]
2115 fn render_does_not_panic_at_huge_size() {
2116 let app = App::new();
2117 let _ = render_to_buffer(&app, 240, 80);
2118 }
2119
2120 #[test]
2121 fn select_next_wraps_through_team() {
2122 let mut app = App::new();
2123 app.replace_team(fixture_team(vec![
2124 agent("p:a", AgentState::Running),
2125 agent("p:b", AgentState::Running),
2126 agent("p:c", AgentState::Running),
2127 ]));
2128 assert_eq!(app.selected_agent, Some(0));
2129 app.select_next();
2130 assert_eq!(app.selected_agent, Some(1));
2131 app.select_next();
2132 assert_eq!(app.selected_agent, Some(2));
2133 app.select_next();
2134 assert_eq!(app.selected_agent, Some(0)); }
2136
2137 #[test]
2138 fn select_prev_wraps_at_top() {
2139 let mut app = App::new();
2140 app.replace_team(fixture_team(vec![
2141 agent("p:a", AgentState::Running),
2142 agent("p:b", AgentState::Running),
2143 ]));
2144 app.selected_agent = Some(0);
2145 app.select_prev();
2146 assert_eq!(app.selected_agent, Some(1));
2147 }
2148
2149 #[test]
2150 fn select_no_op_on_empty_team() {
2151 let mut app = App::new();
2152 app.select_next();
2153 assert_eq!(app.selected_agent, None);
2154 app.select_prev();
2155 assert_eq!(app.selected_agent, None);
2156 }
2157
2158 #[test]
2159 fn replace_team_preserves_selection_when_agent_still_present() {
2160 let mut app = App::new();
2161 app.replace_team(fixture_team(vec![
2162 agent("p:a", AgentState::Running),
2163 agent("p:b", AgentState::Running),
2164 ]));
2165 app.selected_agent = Some(1);
2166 app.replace_team(fixture_team(vec![
2167 agent("p:a", AgentState::Running),
2168 agent("p:b", AgentState::Stopped), ]));
2170 assert_eq!(app.selected_agent, Some(1), "selection follows the id");
2171 }
2172
2173 #[test]
2174 fn replace_team_resets_selection_when_agent_disappears() {
2175 let mut app = App::new();
2176 app.replace_team(fixture_team(vec![
2177 agent("p:a", AgentState::Running),
2178 agent("p:gone", AgentState::Running),
2179 ]));
2180 app.selected_agent = Some(1);
2181 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
2182 assert_eq!(app.selected_agent, Some(0), "falls back to first agent");
2183 }
2184
2185 #[test]
2186 fn switching_agent_resets_mailbox_buffers() {
2187 let mut app = App::new();
2191 app.replace_team(fixture_team(vec![
2192 agent("p:a", AgentState::Running),
2193 agent("p:b", AgentState::Running),
2194 ]));
2195 app.mailbox.extend(
2196 crate::mailbox::MailboxTab::Inbox,
2197 vec![crate::mailbox::MessageRow {
2198 id: 7,
2199 sender: "p:b".into(),
2200 recipient: "p:a".into(),
2201 text: "hi".into(),
2202 sent_at: 0.0,
2203 }],
2204 );
2205 assert_eq!(app.mailbox.inbox.len(), 1);
2206 assert_eq!(app.mailbox.inbox_after, 7);
2207 app.select_next();
2209 assert_eq!(app.selected_agent_id().as_deref(), Some("p:b"));
2210 assert!(app.mailbox.inbox.is_empty());
2211 assert_eq!(app.mailbox.inbox_after, 0);
2212 }
2213
2214 struct TripleFilterMock {
2219 inbox: Vec<crate::mailbox::MessageRow>,
2220 sent: Vec<crate::mailbox::MessageRow>,
2221 channel: Vec<crate::mailbox::MessageRow>,
2222 wire: Vec<crate::mailbox::MessageRow>,
2223 calls: std::sync::Mutex<Vec<(&'static str, String, i64)>>,
2224 }
2225 impl crate::mailbox::MailboxSource for TripleFilterMock {
2226 fn inbox(&self, id: &str, after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
2227 self.calls.lock().unwrap().push(("inbox", id.into(), after));
2228 Ok(self.inbox.clone())
2229 }
2230 fn sent(&self, id: &str, after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
2231 self.calls.lock().unwrap().push(("sent", id.into(), after));
2232 Ok(self.sent.clone())
2233 }
2234 fn channel_feed(
2235 &self,
2236 id: &str,
2237 after: i64,
2238 ) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
2239 self.calls
2240 .lock()
2241 .unwrap()
2242 .push(("channel", id.into(), after));
2243 Ok(self.channel.clone())
2244 }
2245 fn wire(&self, id: &str, after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
2246 self.calls.lock().unwrap().push(("wire", id.into(), after));
2247 Ok(self.wire.clone())
2248 }
2249 }
2250
2251 #[test]
2252 fn refresh_mailbox_fans_out_to_four_filters() {
2253 use crate::mailbox::MessageRow;
2254 let mut app = App::new();
2255 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
2256 let mock = TripleFilterMock {
2257 inbox: vec![MessageRow {
2258 id: 1,
2259 sender: "p:b".into(),
2260 recipient: "p:a".into(),
2261 text: "dm".into(),
2262 sent_at: 0.0,
2263 }],
2264 sent: vec![MessageRow {
2265 id: 4,
2266 sender: "p:a".into(),
2267 recipient: "p:b".into(),
2268 text: "outgoing dm".into(),
2269 sent_at: 0.0,
2270 }],
2271 channel: vec![MessageRow {
2272 id: 2,
2273 sender: "p:b".into(),
2274 recipient: "channel:p:editorial".into(),
2275 text: "ch".into(),
2276 sent_at: 0.0,
2277 }],
2278 wire: vec![MessageRow {
2279 id: 3,
2280 sender: "p:b".into(),
2281 recipient: "channel:p:all".into(),
2282 text: "wire".into(),
2283 sent_at: 0.0,
2284 }],
2285 calls: std::sync::Mutex::new(Vec::new()),
2286 };
2287 super::refresh_mailbox(&mut app, &mock);
2288 assert_eq!(app.mailbox.inbox.len(), 1);
2289 assert_eq!(app.mailbox.sent.len(), 1);
2290 assert_eq!(app.mailbox.channel.len(), 1);
2291 assert_eq!(app.mailbox.wire.len(), 1);
2292 let calls = mock.calls.lock().unwrap();
2293 assert!(calls.contains(&("inbox", "p:a".into(), 0)));
2296 assert!(calls.contains(&("sent", "p:a".into(), 0)));
2297 assert!(calls.contains(&("channel", "p:a".into(), 0)));
2298 assert!(calls.contains(&("wire", "p".into(), 0)));
2299 }
2300
2301 fn ap(id: i64) -> crate::approvals::Approval {
2302 crate::approvals::Approval {
2303 id,
2304 project_id: "p".into(),
2305 agent_id: "p:m".into(),
2306 action: "publish".into(),
2307 summary: format!("approval #{id}"),
2308 payload_json: String::new(),
2309 }
2310 }
2311
2312 #[test]
2313 fn has_pending_approvals_tracks_replace_calls() {
2314 let mut app = App::new();
2315 assert!(!app.has_pending_approvals());
2316 app.replace_approvals(vec![ap(1), ap(2)]);
2317 assert!(app.has_pending_approvals());
2318 app.replace_approvals(vec![]);
2319 assert!(!app.has_pending_approvals());
2320 }
2321
2322 #[test]
2323 fn enter_approvals_modal_no_op_when_queue_empty() {
2324 let mut app = App::new();
2325 app.dismiss_splash();
2326 app.enter_approvals_modal();
2327 assert_eq!(app.stage, Stage::Triptych, "no pending → no modal");
2328 }
2329
2330 #[test]
2331 fn a_chord_opens_modal_when_pending() {
2332 let mut app = App::new();
2333 app.dismiss_splash();
2334 app.replace_approvals(vec![ap(1), ap(2)]);
2335 dispatch(&mut app, key(KeyCode::Char('a')));
2336 assert_eq!(app.stage, Stage::ApprovalsModal);
2337 assert_eq!(app.selected_approval, 0);
2338 }
2339
2340 #[test]
2341 fn modal_cycle_jk_walks_approvals() {
2342 let mut app = App::new();
2343 app.dismiss_splash();
2344 app.replace_approvals(vec![ap(1), ap(2), ap(3)]);
2345 app.enter_approvals_modal();
2346 dispatch(&mut app, key(KeyCode::Char('j')));
2347 assert_eq!(app.selected_approval, 1);
2348 dispatch(&mut app, key(KeyCode::Char('j')));
2349 assert_eq!(app.selected_approval, 2);
2350 dispatch(&mut app, key(KeyCode::Char('j')));
2351 assert_eq!(app.selected_approval, 0, "wraps");
2352 dispatch(&mut app, key(KeyCode::Char('k')));
2353 assert_eq!(app.selected_approval, 2, "k wraps too");
2354 }
2355
2356 #[test]
2357 fn capital_y_routes_approve_through_decider() {
2358 use crate::approvals::test_support::MockApprovalDecider;
2359 let dec = MockApprovalDecider::default();
2360 let mut app = App::new();
2361 app.dismiss_splash();
2362 app.replace_approvals(vec![ap(7), ap(8)]);
2363 app.enter_approvals_modal();
2364 super::handle_event(
2365 &mut app,
2366 key(KeyCode::Char('Y')),
2367 &dec,
2368 &NoopSender,
2369 &EmptyMailbox,
2370 &crate::keysender::test_support::MockKeySender::default(),
2371 );
2372 let calls = dec.calls.lock().unwrap().clone();
2373 assert_eq!(calls.len(), 1);
2374 assert_eq!(calls[0].0, 7);
2375 assert_eq!(calls[0].1, crate::approvals::Decision::Approve);
2376 assert_eq!(app.pending_approvals.len(), 1);
2378 assert_eq!(app.pending_approvals[0].id, 8);
2379 }
2380
2381 #[test]
2382 fn capital_n_routes_deny_through_decider() {
2383 use crate::approvals::test_support::MockApprovalDecider;
2384 let dec = MockApprovalDecider::default();
2385 let mut app = App::new();
2386 app.dismiss_splash();
2387 app.replace_approvals(vec![ap(7)]);
2388 app.enter_approvals_modal();
2389 super::handle_event(
2390 &mut app,
2391 key(KeyCode::Char('N')),
2392 &dec,
2393 &NoopSender,
2394 &EmptyMailbox,
2395 &crate::keysender::test_support::MockKeySender::default(),
2396 );
2397 let calls = dec.calls.lock().unwrap().clone();
2398 assert_eq!(calls.len(), 1);
2399 assert_eq!(calls[0].1, crate::approvals::Decision::Deny);
2400 assert_eq!(app.stage, Stage::Triptych);
2402 }
2403
2404 #[test]
2405 fn esc_closes_approvals_modal() {
2406 let mut app = App::new();
2407 app.dismiss_splash();
2408 app.replace_approvals(vec![ap(1)]);
2409 app.enter_approvals_modal();
2410 dispatch(&mut app, key(KeyCode::Esc));
2411 assert_eq!(app.stage, Stage::Triptych);
2412 }
2413
2414 #[test]
2415 fn lowercase_y_routes_approve_through_decider() {
2416 use crate::approvals::test_support::MockApprovalDecider;
2420 let dec = MockApprovalDecider::default();
2421 let mut app = App::new();
2422 app.dismiss_splash();
2423 app.replace_approvals(vec![ap(7)]);
2424 app.enter_approvals_modal();
2425 super::handle_event(
2426 &mut app,
2427 key(KeyCode::Char('y')),
2428 &dec,
2429 &NoopSender,
2430 &EmptyMailbox,
2431 &crate::keysender::test_support::MockKeySender::default(),
2432 );
2433 let calls = dec.calls.lock().unwrap().clone();
2434 assert_eq!(calls.len(), 1);
2435 assert_eq!(calls[0].1, crate::approvals::Decision::Approve);
2436 }
2437
2438 #[test]
2439 fn lowercase_n_does_not_deny() {
2440 use crate::approvals::test_support::MockApprovalDecider;
2445 let dec = MockApprovalDecider::default();
2446 let mut app = App::new();
2447 app.dismiss_splash();
2448 app.replace_approvals(vec![ap(7)]);
2449 app.enter_approvals_modal();
2450 super::handle_event(
2451 &mut app,
2452 key(KeyCode::Char('n')),
2453 &dec,
2454 &NoopSender,
2455 &EmptyMailbox,
2456 &crate::keysender::test_support::MockKeySender::default(),
2457 );
2458 assert!(
2459 dec.calls.lock().unwrap().is_empty(),
2460 "lowercase n must not route through the decider"
2461 );
2462 assert_eq!(
2463 app.stage,
2464 Stage::ApprovalsModal,
2465 "stale lowercase n leaves the modal open"
2466 );
2467 }
2468
2469 #[test]
2470 fn shift_tab_cycles_panes_backward() {
2471 use crossterm::event::KeyModifiers;
2472 let mut app = App::new();
2473 app.dismiss_splash();
2474 assert_eq!(app.focused_pane, Pane::Roster);
2475 dispatch(&mut app, key(KeyCode::BackTab));
2478 assert_eq!(app.focused_pane, Pane::Mailbox);
2479 dispatch(&mut app, key_with(KeyCode::Tab, KeyModifiers::SHIFT));
2481 assert_eq!(app.focused_pane, Pane::Detail);
2482 }
2483
2484 #[test]
2485 fn at_chord_opens_compose_dm_to_focused_agent() {
2486 let mut app = App::new();
2487 app.replace_team(fixture_team(vec![
2488 agent("writing:manager", AgentState::Running),
2489 agent("writing:dev1", AgentState::Running),
2490 ]));
2491 app.dismiss_splash();
2492 app.select_next();
2493 dispatch(&mut app, key(KeyCode::Char('@')));
2494 assert_eq!(app.stage, Stage::ComposeModal);
2495 match app.compose_target.as_ref() {
2496 Some(crate::compose::ComposeTarget::Dm { agent_id, .. }) => {
2497 assert_eq!(agent_id, "writing:dev1");
2498 }
2499 other => panic!("expected DM target, got {other:?}"),
2500 }
2501 }
2502
2503 #[test]
2504 fn bang_chord_opens_compose_broadcast_to_all_channel() {
2505 let mut app = App::new();
2506 app.replace_team(fixture_team(vec![agent(
2507 "writing:manager",
2508 AgentState::Running,
2509 )]));
2510 app.dismiss_splash();
2511 dispatch(&mut app, key(KeyCode::Char('!')));
2512 assert_eq!(app.stage, Stage::ComposeModal);
2513 match app.compose_target.as_ref() {
2514 Some(crate::compose::ComposeTarget::Broadcast { channel_id, .. }) => {
2515 assert_eq!(channel_id, "writing:all");
2516 }
2517 other => panic!("expected Broadcast target, got {other:?}"),
2518 }
2519 }
2520
2521 #[test]
2522 fn send_routes_dm_through_mock_sender() {
2523 use crate::compose::test_support::MockMessageSender;
2524 let sender = MockMessageSender::default();
2525 let mailbox = EmptyMailbox;
2526 let mut app = App::new();
2527 app.replace_team(fixture_team(vec![agent(
2528 "writing:dev1",
2529 AgentState::Running,
2530 )]));
2531 app.dismiss_splash();
2532 app.enter_compose_dm_for_focused();
2533 for c in "ship it".chars() {
2534 super::handle_event(
2535 &mut app,
2536 key(KeyCode::Char(c)),
2537 &NoopDecider,
2538 &sender,
2539 &mailbox,
2540 &crate::keysender::test_support::MockKeySender::default(),
2541 );
2542 }
2543 super::handle_event(
2544 &mut app,
2545 key_with(KeyCode::Enter, crossterm::event::KeyModifiers::CONTROL),
2546 &NoopDecider,
2547 &sender,
2548 &mailbox,
2549 &crate::keysender::test_support::MockKeySender::default(),
2550 );
2551 let calls = sender.dm_calls.lock().unwrap().clone();
2552 assert_eq!(calls.len(), 1);
2553 assert_eq!(calls[0].0, "writing:dev1");
2554 assert_eq!(calls[0].1, "ship it");
2555 assert_eq!(app.stage, Stage::Triptych, "modal closes on send");
2556 }
2557
2558 #[test]
2559 fn esc_esc_cancels_compose_without_send() {
2560 use crate::compose::test_support::MockMessageSender;
2561 let sender = MockMessageSender::default();
2562 let mailbox = EmptyMailbox;
2563 let mut app = App::new();
2564 app.replace_team(fixture_team(vec![agent(
2565 "writing:dev1",
2566 AgentState::Running,
2567 )]));
2568 app.dismiss_splash();
2569 app.enter_compose_dm_for_focused();
2570 for c in "draft".chars() {
2571 super::handle_event(
2572 &mut app,
2573 key(KeyCode::Char(c)),
2574 &NoopDecider,
2575 &sender,
2576 &mailbox,
2577 &crate::keysender::test_support::MockKeySender::default(),
2578 );
2579 }
2580 super::handle_event(
2581 &mut app,
2582 key(KeyCode::Esc),
2583 &NoopDecider,
2584 &sender,
2585 &mailbox,
2586 &crate::keysender::test_support::MockKeySender::default(),
2587 );
2588 super::handle_event(
2589 &mut app,
2590 key(KeyCode::Esc),
2591 &NoopDecider,
2592 &sender,
2593 &mailbox,
2594 &crate::keysender::test_support::MockKeySender::default(),
2595 );
2596 assert_eq!(app.stage, Stage::Triptych);
2597 assert!(sender.dm_calls.lock().unwrap().is_empty());
2598 }
2599
2600 #[test]
2601 fn send_failure_surfaces_error_inline_keeps_modal_open() {
2602 use crate::compose::test_support::MockMessageSender;
2603 let sender = MockMessageSender::default();
2604 *sender.fail_next.lock().unwrap() = Some("rate limit".into());
2605 let mailbox = EmptyMailbox;
2606 let mut app = App::new();
2607 app.replace_team(fixture_team(vec![agent(
2608 "writing:dev1",
2609 AgentState::Running,
2610 )]));
2611 app.dismiss_splash();
2612 app.enter_compose_dm_for_focused();
2613 super::handle_event(
2614 &mut app,
2615 key(KeyCode::Char('x')),
2616 &NoopDecider,
2617 &sender,
2618 &mailbox,
2619 &crate::keysender::test_support::MockKeySender::default(),
2620 );
2621 super::handle_event(
2622 &mut app,
2623 key_with(KeyCode::Enter, crossterm::event::KeyModifiers::CONTROL),
2624 &NoopDecider,
2625 &sender,
2626 &mailbox,
2627 &crate::keysender::test_support::MockKeySender::default(),
2628 );
2629 assert_eq!(app.stage, Stage::ComposeModal, "modal stays open on err");
2630 assert!(app
2631 .compose_error
2632 .as_deref()
2633 .unwrap_or_default()
2634 .contains("rate limit"));
2635 }
2636
2637 fn channel(id: &str, project: &str) -> crate::data::ChannelInfo {
2638 crate::data::ChannelInfo {
2639 id: id.into(),
2640 name: id
2641 .rsplit_once(':')
2642 .map(|(_, n)| n.to_string())
2643 .unwrap_or_default(),
2644 project_id: project.into(),
2645 }
2646 }
2647
2648 fn fixture_team_with_channels(
2649 agents: Vec<AgentInfo>,
2650 channels: Vec<crate::data::ChannelInfo>,
2651 ) -> TeamSnapshot {
2652 TeamSnapshot {
2653 root: std::path::PathBuf::from("/fixture"),
2654 team_name: "fixture".into(),
2655 agents,
2656 channels,
2657 }
2658 }
2659
2660 #[test]
2661 fn ctrl_w_toggles_wall_layout() {
2662 use crossterm::event::KeyModifiers;
2663 let mut app = App::new();
2664 app.dismiss_splash();
2665 assert_eq!(app.layout, MainLayout::Triptych);
2666 dispatch(
2667 &mut app,
2668 key_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
2669 );
2670 assert_eq!(app.layout, MainLayout::Wall);
2671 dispatch(
2672 &mut app,
2673 key_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
2674 );
2675 assert_eq!(app.layout, MainLayout::Triptych);
2676 }
2677
2678 #[test]
2679 fn ctrl_m_toggles_mailbox_first_layout() {
2680 use crossterm::event::KeyModifiers;
2681 let mut app = App::new();
2682 app.dismiss_splash();
2683 dispatch(
2684 &mut app,
2685 key_with(KeyCode::Char('m'), KeyModifiers::CONTROL),
2686 );
2687 assert_eq!(app.layout, MainLayout::MailboxFirst);
2688 dispatch(
2689 &mut app,
2690 key_with(KeyCode::Char('m'), KeyModifiers::CONTROL),
2691 );
2692 assert_eq!(app.layout, MainLayout::Triptych);
2693 }
2694
2695 #[test]
2696 fn wall_scroll_pages_through_overflow_agents() {
2697 let mut app = App::new();
2698 let mut agents: Vec<_> = (1..=10)
2699 .map(|i| agent(&format!("p:agent-{i:02}"), AgentState::Running))
2700 .collect();
2701 for a in agents.iter_mut() {
2703 a.is_manager = false;
2704 }
2705 app.replace_team(fixture_team(agents));
2706 app.dismiss_splash();
2707 app.toggle_wall_layout();
2708 assert_eq!(app.wall_scroll, 0);
2709 app.wall_scroll_down();
2710 assert_eq!(app.wall_scroll, 4);
2711 app.wall_scroll_down();
2712 assert_eq!(app.wall_scroll, 8);
2713 app.wall_scroll_down();
2715 assert_eq!(app.wall_scroll, 8, "scroll capped at last full window");
2716 app.wall_scroll_up();
2717 assert_eq!(app.wall_scroll, 4);
2718 }
2719
2720 #[test]
2721 fn ctrl_pipe_adds_detail_split_capped_at_four() {
2722 use crossterm::event::KeyModifiers;
2723 let mut app = App::new();
2724 app.replace_team(fixture_team(vec![
2725 agent("p:a", AgentState::Running),
2726 agent("p:b", AgentState::Running),
2727 ]));
2728 app.dismiss_splash();
2729 for _ in 0..6 {
2730 dispatch(
2731 &mut app,
2732 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
2733 );
2734 }
2735 assert_eq!(app.detail_splits.len(), 4, "split count capped at 4");
2736 }
2737
2738 #[test]
2739 fn ctrl_q_closes_focused_split() {
2740 use crossterm::event::KeyModifiers;
2741 let mut app = App::new();
2742 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
2743 app.dismiss_splash();
2744 dispatch(
2745 &mut app,
2746 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
2747 );
2748 dispatch(
2749 &mut app,
2750 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
2751 );
2752 assert_eq!(app.detail_splits.len(), 2);
2753 dispatch(
2754 &mut app,
2755 key_with(KeyCode::Char('Q'), KeyModifiers::CONTROL),
2756 );
2757 assert_eq!(app.detail_splits.len(), 1);
2758 }
2759
2760 #[test]
2761 fn ctrl_hjkl_cycles_splits() {
2762 use crossterm::event::KeyModifiers;
2763 let mut app = App::new();
2764 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
2765 app.dismiss_splash();
2766 for _ in 0..3 {
2767 dispatch(
2768 &mut app,
2769 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
2770 );
2771 }
2772 assert_eq!(app.selected_split, 2);
2773 dispatch(
2774 &mut app,
2775 key_with(KeyCode::Char('l'), KeyModifiers::CONTROL),
2776 );
2777 assert_eq!(app.selected_split, 0, "wraps");
2778 dispatch(
2779 &mut app,
2780 key_with(KeyCode::Char('h'), KeyModifiers::CONTROL),
2781 );
2782 assert_eq!(app.selected_split, 2);
2783 }
2784
2785 #[test]
2786 fn wall_scroll_at_exactly_cap_agents_does_not_scroll() {
2787 let mut app = App::new();
2792 let agents: Vec<_> = (1..=4)
2793 .map(|i| agent(&format!("p:agent-{i}"), AgentState::Running))
2794 .collect();
2795 app.replace_team(fixture_team(agents));
2796 app.dismiss_splash();
2797 app.toggle_wall_layout();
2798 assert_eq!(app.wall_scroll, 0);
2799 app.wall_scroll_down();
2800 assert_eq!(app.wall_scroll, 0, "exactly-cap should not advance");
2801 app.wall_scroll_up();
2802 assert_eq!(app.wall_scroll, 0);
2803 }
2804
2805 #[test]
2806 fn wall_scroll_at_cap_plus_one_advances_then_stops() {
2807 let mut app = App::new();
2812 let agents: Vec<_> = (1..=5)
2813 .map(|i| agent(&format!("p:agent-{i}"), AgentState::Running))
2814 .collect();
2815 app.replace_team(fixture_team(agents));
2816 app.dismiss_splash();
2817 app.toggle_wall_layout();
2818 app.wall_scroll_down();
2819 assert_eq!(app.wall_scroll, 4, "first scroll exposes agent 5");
2820 app.wall_scroll_down();
2821 assert_eq!(app.wall_scroll, 4, "second scroll caps; nothing past");
2822 }
2823
2824 #[test]
2825 fn esc_in_picker_dismisses_overlay_only_keeps_modal_open() {
2826 let mut app = App::new();
2832 app.replace_team(fixture_team_with_channels(
2833 vec![agent("writing:manager", AgentState::Running)],
2834 vec![
2835 channel("writing:all", "writing"),
2836 channel("writing:editorial", "writing"),
2837 ],
2838 ));
2839 app.dismiss_splash();
2840 dispatch(&mut app, key(KeyCode::Char('!')));
2841 assert!(app.compose_picker_open);
2842 assert_eq!(app.stage, Stage::ComposeModal);
2843 dispatch(&mut app, key(KeyCode::Esc));
2844 assert!(!app.compose_picker_open, "picker dismissed");
2845 assert_eq!(app.stage, Stage::ComposeModal, "compose modal stays open");
2846 }
2847
2848 #[test]
2849 fn send_routes_broadcast_through_mock_sender_via_picker() {
2850 use crate::compose::test_support::MockMessageSender;
2856 let sender = MockMessageSender::default();
2857 let mailbox = EmptyMailbox;
2858 let mut app = App::new();
2859 app.replace_team(fixture_team_with_channels(
2860 vec![agent("writing:manager", AgentState::Running)],
2861 vec![
2862 channel("writing:all", "writing"),
2863 channel("writing:editorial", "writing"),
2864 channel("writing:critique", "writing"),
2865 ],
2866 ));
2867 app.dismiss_splash();
2868 super::handle_event(
2871 &mut app,
2872 key(KeyCode::Char('!')),
2873 &NoopDecider,
2874 &sender,
2875 &mailbox,
2876 &crate::keysender::test_support::MockKeySender::default(),
2877 );
2878 super::handle_event(
2879 &mut app,
2880 key(KeyCode::Char('j')),
2881 &NoopDecider,
2882 &sender,
2883 &mailbox,
2884 &crate::keysender::test_support::MockKeySender::default(),
2885 );
2886 super::handle_event(
2887 &mut app,
2888 key(KeyCode::Enter),
2889 &NoopDecider,
2890 &sender,
2891 &mailbox,
2892 &crate::keysender::test_support::MockKeySender::default(),
2893 );
2894 for c in "ship docs".chars() {
2895 super::handle_event(
2896 &mut app,
2897 key(KeyCode::Char(c)),
2898 &NoopDecider,
2899 &sender,
2900 &mailbox,
2901 &crate::keysender::test_support::MockKeySender::default(),
2902 );
2903 }
2904 super::handle_event(
2905 &mut app,
2906 key_with(KeyCode::Enter, crossterm::event::KeyModifiers::CONTROL),
2907 &NoopDecider,
2908 &sender,
2909 &mailbox,
2910 &crate::keysender::test_support::MockKeySender::default(),
2911 );
2912 let dm_calls = sender.dm_calls.lock().unwrap().clone();
2913 let bcast_calls = sender.broadcast_calls.lock().unwrap().clone();
2914 assert!(dm_calls.is_empty(), "broadcast must not route via send_dm");
2915 assert_eq!(bcast_calls.len(), 1);
2916 assert_eq!(
2917 bcast_calls[0].0, "writing:editorial",
2918 "channel id from picker selection"
2919 );
2920 assert_eq!(bcast_calls[0].1, "ship docs");
2921 assert_eq!(app.stage, Stage::Triptych, "modal closes on send");
2922 }
2923
2924 #[test]
2925 fn bang_chord_opens_picker_when_channels_available() {
2926 let mut app = App::new();
2927 app.replace_team(fixture_team_with_channels(
2928 vec![agent("writing:manager", AgentState::Running)],
2929 vec![
2930 channel("writing:all", "writing"),
2931 channel("writing:editorial", "writing"),
2932 channel("writing:critique", "writing"),
2933 ],
2934 ));
2935 app.dismiss_splash();
2936 dispatch(&mut app, key(KeyCode::Char('!')));
2937 assert_eq!(app.stage, Stage::ComposeModal);
2938 assert!(app.compose_picker_open);
2939 dispatch(&mut app, key(KeyCode::Char('j')));
2941 assert_eq!(app.compose_picker_index, 1);
2942 dispatch(&mut app, key(KeyCode::Enter));
2944 assert!(!app.compose_picker_open, "picker closes on confirm");
2945 match app.compose_target.as_ref() {
2946 Some(crate::compose::ComposeTarget::Broadcast { channel_id, .. }) => {
2947 assert_eq!(channel_id, "writing:editorial");
2948 }
2949 other => panic!("expected Broadcast target, got {other:?}"),
2950 }
2951 }
2952
2953 #[test]
2954 fn mailbox_first_layout_seeds_channel_selection_on_entry() {
2955 let mut app = App::new();
2956 app.replace_team(fixture_team_with_channels(
2957 vec![agent("writing:manager", AgentState::Running)],
2958 vec![
2959 channel("writing:all", "writing"),
2960 channel("writing:editorial", "writing"),
2961 ],
2962 ));
2963 app.dismiss_splash();
2964 assert!(app.selected_channel.is_none());
2965 app.toggle_mailbox_first_layout();
2966 assert_eq!(app.selected_channel, Some(0));
2967 }
2968
2969 #[test]
2970 fn help_overlay_opens_on_question_mark_closes_on_esc() {
2971 let mut app = App::new();
2972 app.dismiss_splash();
2973 dispatch(&mut app, key(KeyCode::Char('?')));
2974 assert_eq!(app.stage, Stage::HelpOverlay);
2975 dispatch(&mut app, key(KeyCode::Esc));
2976 assert_eq!(app.stage, Stage::Triptych);
2977 }
2978
2979 #[test]
2980 fn tutorial_opens_on_t_advances_and_closes() {
2981 let mut app = App::new();
2982 app.dismiss_splash();
2983 dispatch(&mut app, key(KeyCode::Char('t')));
2984 assert_eq!(app.stage, Stage::Tutorial);
2985 assert_eq!(app.tutorial_step, 0);
2986 dispatch(&mut app, key(KeyCode::Char(' ')));
2988 assert_eq!(app.tutorial_step, 1);
2989 dispatch(&mut app, key(KeyCode::Char('k')));
2991 assert_eq!(app.tutorial_step, 0);
2992 dispatch(&mut app, key(KeyCode::Esc));
2994 assert_eq!(app.stage, Stage::Triptych);
2995 }
2996
2997 #[test]
2998 fn tutorial_walk_back_at_step_zero_is_no_op() {
2999 let mut app = App::new();
3004 app.dismiss_splash();
3005 app.enter_tutorial();
3006 assert_eq!(app.tutorial_step, 0);
3007 dispatch(&mut app, key(KeyCode::Char('k')));
3008 assert_eq!(app.tutorial_step, 0, "step-0 walk-back is no-op");
3009 assert_eq!(app.stage, Stage::Tutorial);
3012 }
3013
3014 #[test]
3015 fn ctrl_pipe_adds_vertical_split_ctrl_minus_adds_horizontal() {
3016 use crossterm::event::KeyModifiers;
3017 let mut app = App::new();
3018 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3019 app.dismiss_splash();
3020 dispatch(
3021 &mut app,
3022 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
3023 );
3024 dispatch(
3025 &mut app,
3026 key_with(KeyCode::Char('-'), KeyModifiers::CONTROL),
3027 );
3028 assert_eq!(app.detail_splits.len(), 2);
3029 assert_eq!(app.detail_splits[0].1, SplitOrientation::Vertical);
3030 assert_eq!(app.detail_splits[1].1, SplitOrientation::Horizontal);
3031 }
3032
3033 #[test]
3034 fn ctrl_w_q_chord_prefix_closes_focused_split() {
3035 use crossterm::event::KeyModifiers;
3036 let mut app = App::new();
3037 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3038 app.dismiss_splash();
3039 dispatch(
3042 &mut app,
3043 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
3044 );
3045 dispatch(
3046 &mut app,
3047 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
3048 );
3049 dispatch(
3050 &mut app,
3051 key_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
3052 );
3053 assert_eq!(app.pending_chord, Some(KeyCode::Char('w')));
3054 dispatch(&mut app, key(KeyCode::Char('q')));
3057 assert_eq!(app.detail_splits.len(), 1);
3058 assert_eq!(app.stage, Stage::Triptych, "must not enter quit confirm");
3059 assert_eq!(app.pending_chord, None, "chord cleared");
3060 }
3061
3062 #[test]
3063 fn ctrl_w_o_chord_keeps_only_focused_split() {
3064 use crossterm::event::KeyModifiers;
3065 let mut app = App::new();
3066 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3067 app.dismiss_splash();
3068 for _ in 0..3 {
3069 dispatch(
3070 &mut app,
3071 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
3072 );
3073 }
3074 app.selected_split = 1;
3076 let kept_id = app.detail_splits[1].0.clone();
3077 dispatch(
3078 &mut app,
3079 key_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
3080 );
3081 dispatch(&mut app, key(KeyCode::Char('o')));
3082 assert_eq!(app.detail_splits.len(), 1);
3083 assert_eq!(app.detail_splits[0].0, kept_id);
3084 assert_eq!(app.selected_split, 0);
3085 }
3086
3087 #[test]
3088 fn add_detail_split_saturates_at_four_with_explicit_4_and_5_calls() {
3089 let mut app = App::new();
3094 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3095 for _ in 0..4 {
3096 app.add_detail_split();
3097 }
3098 assert_eq!(app.detail_splits.len(), 4);
3099 let snapshot_len = app.detail_splits.len();
3100 app.add_detail_split();
3101 assert_eq!(app.detail_splits.len(), snapshot_len, "5th call rejected");
3102 }
3103
3104 #[test]
3105 fn replace_approvals_clamps_selection_in_range() {
3106 let mut app = App::new();
3107 app.replace_approvals(vec![ap(1), ap(2), ap(3)]);
3108 app.selected_approval = 2;
3109 app.replace_approvals(vec![ap(1), ap(2)]);
3111 assert_eq!(app.selected_approval, 1, "clamps to last index");
3112 }
3113
3114 #[test]
3115 fn arrow_keys_navigate_only_when_roster_focused() {
3116 let mut app = App::new();
3117 app.replace_team(fixture_team(vec![
3118 agent("p:a", AgentState::Running),
3119 agent("p:b", AgentState::Running),
3120 ]));
3121 app.dismiss_splash();
3122 app.selected_agent = Some(0);
3124 dispatch(&mut app, key(KeyCode::Down));
3125 assert_eq!(app.selected_agent, Some(1));
3126 app.cycle_focus();
3128 dispatch(&mut app, key(KeyCode::Down));
3129 assert_eq!(
3130 app.selected_agent,
3131 Some(1),
3132 "non-roster focus ignores arrows"
3133 );
3134 }
3135
3136 fn stream_keys_fixture() -> App {
3142 let mut app = App::new();
3143 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3144 app.dismiss_splash();
3145 app.cycle_focus(); assert_eq!(app.focused_pane, Pane::Detail);
3147 assert_eq!(app.selected_agent, Some(0));
3148 app
3149 }
3150
3151 fn stream_dispatch(
3152 app: &mut App,
3153 ev: Event,
3154 key_sender: &crate::keysender::test_support::MockKeySender,
3155 ) {
3156 super::handle_event(
3157 app,
3158 ev,
3159 &NoopDecider,
3160 &NoopSender,
3161 &EmptyMailbox,
3162 key_sender,
3163 );
3164 }
3165
3166 #[test]
3167 fn ctrl_e_enters_stream_keys_when_detail_focused() {
3168 use crate::keysender::test_support::MockKeySender;
3169 use crossterm::event::KeyModifiers;
3170 let mut app = stream_keys_fixture();
3171 let ks = MockKeySender::default();
3172 stream_dispatch(
3173 &mut app,
3174 key_with(KeyCode::Char('e'), KeyModifiers::CONTROL),
3175 &ks,
3176 );
3177 assert_eq!(app.stage, Stage::StreamKeys);
3178 assert!(
3179 ks.calls.lock().unwrap().is_empty(),
3180 "the activation chord itself never forwards a keystroke"
3181 );
3182 }
3183
3184 #[test]
3185 fn ctrl_e_no_op_when_detail_not_focused() {
3186 use crate::keysender::test_support::MockKeySender;
3191 use crossterm::event::KeyModifiers;
3192 let mut app = App::new();
3193 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3194 app.dismiss_splash();
3195 assert_eq!(app.focused_pane, Pane::Roster);
3196 let ks = MockKeySender::default();
3197 stream_dispatch(
3198 &mut app,
3199 key_with(KeyCode::Char('e'), KeyModifiers::CONTROL),
3200 &ks,
3201 );
3202 assert_eq!(app.stage, Stage::Triptych);
3203 }
3204
3205 #[test]
3206 fn ctrl_e_no_op_when_no_agent_selected() {
3207 use crate::keysender::test_support::MockKeySender;
3210 use crossterm::event::KeyModifiers;
3211 let mut app = App::new();
3212 app.dismiss_splash();
3213 app.cycle_focus(); assert_eq!(app.selected_agent, None);
3215 let ks = MockKeySender::default();
3216 stream_dispatch(
3217 &mut app,
3218 key_with(KeyCode::Char('e'), KeyModifiers::CONTROL),
3219 &ks,
3220 );
3221 assert_eq!(app.stage, Stage::Triptych);
3222 }
3223
3224 #[test]
3225 fn esc_exits_stream_keys() {
3226 use crate::keysender::test_support::MockKeySender;
3227 let mut app = stream_keys_fixture();
3228 app.enter_stream_keys();
3229 assert_eq!(app.stage, Stage::StreamKeys);
3230 let ks = MockKeySender::default();
3231 stream_dispatch(&mut app, key(KeyCode::Esc), &ks);
3232 assert_eq!(app.stage, Stage::Triptych);
3233 assert!(
3234 ks.calls.lock().unwrap().is_empty(),
3235 "Esc is the exit chord — it must not forward as a keystroke"
3236 );
3237 }
3238
3239 #[test]
3240 fn stream_mode_forwards_printable_chars_to_target_session() {
3241 use crate::keysender::test_support::MockKeySender;
3242 let mut app = stream_keys_fixture();
3243 app.enter_stream_keys();
3244 let ks = MockKeySender::default();
3245 for c in "hi".chars() {
3246 stream_dispatch(&mut app, key(KeyCode::Char(c)), &ks);
3247 }
3248 let calls = ks.calls.lock().unwrap();
3249 assert_eq!(calls.len(), 2, "one tmux send-keys per keystroke");
3250 assert_eq!(calls[0].0, "t-p-a");
3253 assert_eq!(calls[0].1.args, vec!["-l".to_string(), "h".to_string()]);
3254 assert_eq!(calls[1].1.args, vec!["-l".to_string(), "i".to_string()]);
3255 }
3256
3257 #[test]
3258 fn stream_mode_passes_ctrl_c_through_to_agent() {
3259 use crate::keysender::test_support::MockKeySender;
3263 use crossterm::event::KeyModifiers;
3264 let mut app = stream_keys_fixture();
3265 app.enter_stream_keys();
3266 let ks = MockKeySender::default();
3267 stream_dispatch(
3268 &mut app,
3269 key_with(KeyCode::Char('c'), KeyModifiers::CONTROL),
3270 &ks,
3271 );
3272 assert_eq!(app.stage, Stage::StreamKeys, "Ctrl+C does NOT exit");
3273 let calls = ks.calls.lock().unwrap();
3274 assert_eq!(calls.len(), 1);
3275 assert_eq!(calls[0].1.args, vec!["C-c".to_string()]);
3276 }
3277
3278 #[test]
3279 fn stream_mode_forwards_enter_and_arrows() {
3280 use crate::keysender::test_support::MockKeySender;
3281 let mut app = stream_keys_fixture();
3282 app.enter_stream_keys();
3283 let ks = MockKeySender::default();
3284 stream_dispatch(&mut app, key(KeyCode::Enter), &ks);
3285 stream_dispatch(&mut app, key(KeyCode::Up), &ks);
3286 let calls = ks.calls.lock().unwrap();
3287 assert_eq!(calls[0].1.args, vec!["Enter".to_string()]);
3288 assert_eq!(calls[1].1.args, vec!["Up".to_string()]);
3289 }
3290
3291 #[test]
3292 fn stream_target_session_uses_focused_split_when_present() {
3293 let mut app = App::new();
3298 app.replace_team(fixture_team(vec![
3299 agent("p:a", AgentState::Running),
3300 agent("p:b", AgentState::Running),
3301 ]));
3302 app.dismiss_splash();
3303 app.cycle_focus(); app.selected_agent = Some(0);
3305 app.detail_splits
3307 .push(("p:b".into(), crate::app::SplitOrientation::Vertical));
3308 app.selected_split = 1; let target = app.stream_target_session();
3310 assert_eq!(
3311 target.as_deref(),
3312 Some("t-p-b"),
3313 "selected split's agent drives the target"
3314 );
3315 }
3316
3317 #[test]
3318 fn stream_mode_drops_back_when_target_session_disappears() {
3319 use crate::keysender::test_support::MockKeySender;
3324 let mut app = stream_keys_fixture();
3325 app.enter_stream_keys();
3326 app.selected_agent = None;
3328 app.team.agents.clear();
3329 let ks = MockKeySender::default();
3330 stream_dispatch(&mut app, key(KeyCode::Char('a')), &ks);
3331 assert_eq!(app.stage, Stage::Triptych);
3332 assert!(ks.calls.lock().unwrap().is_empty());
3333 }
3334
3335 fn pane_sync_fixture() -> App {
3338 let mut app = App::new();
3339 app.team = fixture_team(vec![
3340 agent("hello:mgr", AgentState::Running),
3341 agent("hello:dev", AgentState::Running),
3342 ]);
3343 app.selected_agent = Some(0);
3344 app.stage = Stage::Triptych;
3345 app.layout = MainLayout::Triptych;
3346 app
3347 }
3348
3349 #[test]
3350 fn sync_fires_resize_on_first_frame() {
3351 let mut app = pane_sync_fixture();
3352 let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
3353 sync_focused_pane_size_to(
3354 &mut app,
3355 ratatui::layout::Rect::new(0, 0, 120, 40),
3356 &resizer,
3357 );
3358 let calls = resizer.calls.lock().unwrap();
3359 assert_eq!(calls.len(), 1);
3362 assert_eq!(calls[0].0, "t-hello-mgr");
3363 assert_eq!(calls[0].1, 92); assert_eq!(calls[0].2, 24); }
3366
3367 #[test]
3368 fn sync_skips_when_size_unchanged() {
3369 let mut app = pane_sync_fixture();
3370 let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
3371 sync_focused_pane_size_to(
3373 &mut app,
3374 ratatui::layout::Rect::new(0, 0, 120, 40),
3375 &resizer,
3376 );
3377 sync_focused_pane_size_to(
3378 &mut app,
3379 ratatui::layout::Rect::new(0, 0, 120, 40),
3380 &resizer,
3381 );
3382 assert_eq!(resizer.calls.lock().unwrap().len(), 1);
3383 }
3384
3385 #[test]
3386 fn sync_fires_again_when_terminal_resizes() {
3387 let mut app = pane_sync_fixture();
3388 let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
3389 sync_focused_pane_size_to(
3390 &mut app,
3391 ratatui::layout::Rect::new(0, 0, 120, 40),
3392 &resizer,
3393 );
3394 sync_focused_pane_size_to(
3396 &mut app,
3397 ratatui::layout::Rect::new(0, 0, 200, 60),
3398 &resizer,
3399 );
3400 let calls = resizer.calls.lock().unwrap();
3401 assert_eq!(calls.len(), 2);
3402 assert_eq!(calls[0].1, 92);
3403 assert_eq!(calls[0].2, 24);
3404 assert_eq!(calls[1].1, 172); assert_eq!(calls[1].2, 36);
3407 }
3408
3409 #[test]
3410 fn sync_fires_on_focus_switch_to_unsynced_session() {
3411 let mut app = pane_sync_fixture();
3412 let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
3413 sync_focused_pane_size_to(
3414 &mut app,
3415 ratatui::layout::Rect::new(0, 0, 120, 40),
3416 &resizer,
3417 );
3418 app.selected_agent = Some(1);
3420 sync_focused_pane_size_to(
3421 &mut app,
3422 ratatui::layout::Rect::new(0, 0, 120, 40),
3423 &resizer,
3424 );
3425 let calls = resizer.calls.lock().unwrap();
3426 assert_eq!(calls.len(), 2);
3427 assert_eq!(calls[0].0, "t-hello-mgr");
3428 assert_eq!(calls[1].0, "t-hello-dev");
3429 }
3430
3431 #[test]
3432 fn sync_is_noop_when_no_agent_focused() {
3433 let mut app = pane_sync_fixture();
3434 app.selected_agent = None;
3435 let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
3436 sync_focused_pane_size_to(
3437 &mut app,
3438 ratatui::layout::Rect::new(0, 0, 120, 40),
3439 &resizer,
3440 );
3441 assert!(resizer.calls.lock().unwrap().is_empty());
3442 }
3443
3444 #[test]
3445 fn sync_is_noop_when_layout_is_not_triptych() {
3446 let mut app = pane_sync_fixture();
3447 app.layout = MainLayout::Wall;
3448 let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
3449 sync_focused_pane_size_to(
3450 &mut app,
3451 ratatui::layout::Rect::new(0, 0, 120, 40),
3452 &resizer,
3453 );
3454 assert!(resizer.calls.lock().unwrap().is_empty());
3457 }
3458
3459 #[test]
3460 fn sync_is_noop_on_degenerate_terminal_area() {
3461 let mut app = pane_sync_fixture();
3462 let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
3463 sync_focused_pane_size_to(&mut app, ratatui::layout::Rect::new(0, 0, 28, 40), &resizer);
3465 assert!(resizer.calls.lock().unwrap().is_empty());
3466 }
3467
3468 #[test]
3469 fn sync_accounts_for_approvals_stripe_when_present() {
3470 let mut app = pane_sync_fixture();
3471 app.pending_approvals = vec![crate::approvals::Approval {
3473 id: 1,
3474 project_id: "hello".into(),
3475 agent_id: "hello:dev".into(),
3476 action: "test".into(),
3477 summary: "test approval".into(),
3478 payload_json: String::new(),
3479 }];
3480 assert!(app.has_pending_approvals());
3481 let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
3482 sync_focused_pane_size_to(
3483 &mut app,
3484 ratatui::layout::Rect::new(0, 0, 120, 40),
3485 &resizer,
3486 );
3487 let calls = resizer.calls.lock().unwrap();
3488 assert_eq!(calls.len(), 1);
3490 assert_eq!(calls[0].2, 23);
3491 }
3492}