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 reports_to: None,
1963 }
1964 }
1965
1966 pub fn fixture_team(agents: Vec<AgentInfo>) -> TeamSnapshot {
1967 TeamSnapshot {
1968 root: std::path::PathBuf::from("/fixture"),
1969 team_name: "fixture".into(),
1970 agents,
1971 channels: Vec::new(),
1972 }
1973 }
1974
1975 #[test]
1976 fn splash_dismissed_by_any_key() {
1977 let mut app = App::new();
1978 assert_eq!(app.stage, Stage::Splash);
1979 dispatch(&mut app, key(KeyCode::Char(' ')));
1980 assert_eq!(app.stage, Stage::Triptych);
1981 }
1982
1983 #[test]
1984 fn tab_cycles_panes_uniformly_and_wraps_through_mailbox() {
1985 let mut app = App::new();
1992 app.dismiss_splash();
1993 assert_eq!(app.focused_pane, Pane::Roster);
1994 dispatch(&mut app, key(KeyCode::Tab));
1995 assert_eq!(app.focused_pane, Pane::Detail);
1996 dispatch(&mut app, key(KeyCode::Tab));
1997 assert_eq!(app.focused_pane, Pane::Mailbox);
1998 assert_eq!(
1999 app.mailbox_tab,
2000 MailboxTab::Inbox,
2001 "Tab into mailbox does NOT touch the active mailbox tab"
2002 );
2003 dispatch(&mut app, key(KeyCode::Tab));
2004 assert_eq!(
2005 app.focused_pane,
2006 Pane::Roster,
2007 "Tab from mailbox wraps to roster, not into mailbox subtabs"
2008 );
2009 assert_eq!(
2010 app.mailbox_tab,
2011 MailboxTab::Inbox,
2012 "mailbox tab still untouched"
2013 );
2014 }
2015
2016 #[test]
2017 fn arrow_keys_walk_mailbox_tabs_when_mailbox_focused() {
2018 let mut app = App::new();
2023 app.dismiss_splash();
2024 dispatch(&mut app, key(KeyCode::Tab));
2026 dispatch(&mut app, key(KeyCode::Tab));
2027 assert_eq!(app.focused_pane, Pane::Mailbox);
2028 assert_eq!(app.mailbox_tab, MailboxTab::Inbox);
2029
2030 dispatch(&mut app, key(KeyCode::Right));
2031 assert_eq!(app.mailbox_tab, MailboxTab::Sent);
2032 dispatch(&mut app, key(KeyCode::Right));
2033 assert_eq!(app.mailbox_tab, MailboxTab::Channel);
2034 dispatch(&mut app, key(KeyCode::Right));
2035 assert_eq!(app.mailbox_tab, MailboxTab::Wire);
2036 dispatch(&mut app, key(KeyCode::Right));
2037 assert_eq!(app.mailbox_tab, MailboxTab::Inbox, "→ wraps");
2038
2039 dispatch(&mut app, key(KeyCode::Left));
2040 assert_eq!(app.mailbox_tab, MailboxTab::Wire, "← walks back");
2041 }
2042
2043 #[test]
2044 fn arrow_keys_no_op_when_mailbox_not_focused() {
2045 let mut app = App::new();
2048 app.dismiss_splash();
2049 assert_eq!(app.focused_pane, Pane::Roster);
2050 let initial = app.mailbox_tab;
2051 dispatch(&mut app, key(KeyCode::Right));
2052 dispatch(&mut app, key(KeyCode::Left));
2053 assert_eq!(
2054 app.mailbox_tab, initial,
2055 "←/→ from non-mailbox panes must not flip the active tab"
2056 );
2057 }
2058
2059 #[test]
2060 fn brackets_no_longer_cycle_mailbox_tabs() {
2061 let mut app = App::new();
2066 app.dismiss_splash();
2067 dispatch(&mut app, key(KeyCode::Tab));
2068 dispatch(&mut app, key(KeyCode::Tab));
2069 assert_eq!(app.focused_pane, Pane::Mailbox);
2070 let initial = app.mailbox_tab;
2071
2072 dispatch(&mut app, key(KeyCode::Char(']')));
2073 dispatch(&mut app, key(KeyCode::Char('[')));
2074 assert_eq!(
2075 app.mailbox_tab, initial,
2076 "`[` / `]` must no longer cycle mailbox tabs (T-124 hard-swap)",
2077 );
2078 }
2079
2080 #[test]
2081 fn q_opens_confirm_then_n_cancels() {
2082 let mut app = App::new();
2083 app.dismiss_splash();
2084 dispatch(&mut app, key(KeyCode::Char('q')));
2085 assert_eq!(app.stage, Stage::QuitConfirm);
2086 dispatch(&mut app, key(KeyCode::Char('n')));
2087 assert_eq!(app.stage, Stage::Triptych);
2088 assert!(app.running, "n must not exit");
2089 }
2090
2091 #[test]
2092 fn q_then_y_exits() {
2093 let mut app = App::new();
2094 app.dismiss_splash();
2095 dispatch(&mut app, key(KeyCode::Char('q')));
2096 dispatch(&mut app, key(KeyCode::Char('y')));
2097 assert!(!app.running);
2098 }
2099
2100 #[test]
2101 fn esc_cancels_quit_confirm() {
2102 let mut app = App::new();
2103 app.dismiss_splash();
2104 app.enter_quit_confirm();
2105 dispatch(&mut app, key(KeyCode::Esc));
2106 assert_eq!(app.stage, Stage::Triptych);
2107 }
2108
2109 #[test]
2110 fn render_does_not_panic_at_minimal_size() {
2111 let app = App::new();
2112 let _ = render_to_buffer(&app, 20, 8);
2113 }
2114
2115 #[test]
2116 fn render_does_not_panic_at_huge_size() {
2117 let app = App::new();
2118 let _ = render_to_buffer(&app, 240, 80);
2119 }
2120
2121 #[test]
2122 fn select_next_wraps_through_team() {
2123 let mut app = App::new();
2124 app.replace_team(fixture_team(vec![
2125 agent("p:a", AgentState::Running),
2126 agent("p:b", AgentState::Running),
2127 agent("p:c", AgentState::Running),
2128 ]));
2129 assert_eq!(app.selected_agent, Some(0));
2130 app.select_next();
2131 assert_eq!(app.selected_agent, Some(1));
2132 app.select_next();
2133 assert_eq!(app.selected_agent, Some(2));
2134 app.select_next();
2135 assert_eq!(app.selected_agent, Some(0)); }
2137
2138 #[test]
2139 fn select_prev_wraps_at_top() {
2140 let mut app = App::new();
2141 app.replace_team(fixture_team(vec![
2142 agent("p:a", AgentState::Running),
2143 agent("p:b", AgentState::Running),
2144 ]));
2145 app.selected_agent = Some(0);
2146 app.select_prev();
2147 assert_eq!(app.selected_agent, Some(1));
2148 }
2149
2150 #[test]
2151 fn select_no_op_on_empty_team() {
2152 let mut app = App::new();
2153 app.select_next();
2154 assert_eq!(app.selected_agent, None);
2155 app.select_prev();
2156 assert_eq!(app.selected_agent, None);
2157 }
2158
2159 #[test]
2160 fn replace_team_preserves_selection_when_agent_still_present() {
2161 let mut app = App::new();
2162 app.replace_team(fixture_team(vec![
2163 agent("p:a", AgentState::Running),
2164 agent("p:b", AgentState::Running),
2165 ]));
2166 app.selected_agent = Some(1);
2167 app.replace_team(fixture_team(vec![
2168 agent("p:a", AgentState::Running),
2169 agent("p:b", AgentState::Stopped), ]));
2171 assert_eq!(app.selected_agent, Some(1), "selection follows the id");
2172 }
2173
2174 #[test]
2175 fn replace_team_resets_selection_when_agent_disappears() {
2176 let mut app = App::new();
2177 app.replace_team(fixture_team(vec![
2178 agent("p:a", AgentState::Running),
2179 agent("p:gone", AgentState::Running),
2180 ]));
2181 app.selected_agent = Some(1);
2182 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
2183 assert_eq!(app.selected_agent, Some(0), "falls back to first agent");
2184 }
2185
2186 #[test]
2187 fn switching_agent_resets_mailbox_buffers() {
2188 let mut app = App::new();
2192 app.replace_team(fixture_team(vec![
2193 agent("p:a", AgentState::Running),
2194 agent("p:b", AgentState::Running),
2195 ]));
2196 app.mailbox.extend(
2197 crate::mailbox::MailboxTab::Inbox,
2198 vec![crate::mailbox::MessageRow {
2199 id: 7,
2200 sender: "p:b".into(),
2201 recipient: "p:a".into(),
2202 text: "hi".into(),
2203 sent_at: 0.0,
2204 }],
2205 );
2206 assert_eq!(app.mailbox.inbox.len(), 1);
2207 assert_eq!(app.mailbox.inbox_after, 7);
2208 app.select_next();
2210 assert_eq!(app.selected_agent_id().as_deref(), Some("p:b"));
2211 assert!(app.mailbox.inbox.is_empty());
2212 assert_eq!(app.mailbox.inbox_after, 0);
2213 }
2214
2215 struct TripleFilterMock {
2220 inbox: Vec<crate::mailbox::MessageRow>,
2221 sent: Vec<crate::mailbox::MessageRow>,
2222 channel: Vec<crate::mailbox::MessageRow>,
2223 wire: Vec<crate::mailbox::MessageRow>,
2224 calls: std::sync::Mutex<Vec<(&'static str, String, i64)>>,
2225 }
2226 impl crate::mailbox::MailboxSource for TripleFilterMock {
2227 fn inbox(&self, id: &str, after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
2228 self.calls.lock().unwrap().push(("inbox", id.into(), after));
2229 Ok(self.inbox.clone())
2230 }
2231 fn sent(&self, id: &str, after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
2232 self.calls.lock().unwrap().push(("sent", id.into(), after));
2233 Ok(self.sent.clone())
2234 }
2235 fn channel_feed(
2236 &self,
2237 id: &str,
2238 after: i64,
2239 ) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
2240 self.calls
2241 .lock()
2242 .unwrap()
2243 .push(("channel", id.into(), after));
2244 Ok(self.channel.clone())
2245 }
2246 fn wire(&self, id: &str, after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
2247 self.calls.lock().unwrap().push(("wire", id.into(), after));
2248 Ok(self.wire.clone())
2249 }
2250 }
2251
2252 #[test]
2253 fn refresh_mailbox_fans_out_to_four_filters() {
2254 use crate::mailbox::MessageRow;
2255 let mut app = App::new();
2256 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
2257 let mock = TripleFilterMock {
2258 inbox: vec![MessageRow {
2259 id: 1,
2260 sender: "p:b".into(),
2261 recipient: "p:a".into(),
2262 text: "dm".into(),
2263 sent_at: 0.0,
2264 }],
2265 sent: vec![MessageRow {
2266 id: 4,
2267 sender: "p:a".into(),
2268 recipient: "p:b".into(),
2269 text: "outgoing dm".into(),
2270 sent_at: 0.0,
2271 }],
2272 channel: vec![MessageRow {
2273 id: 2,
2274 sender: "p:b".into(),
2275 recipient: "channel:p:editorial".into(),
2276 text: "ch".into(),
2277 sent_at: 0.0,
2278 }],
2279 wire: vec![MessageRow {
2280 id: 3,
2281 sender: "p:b".into(),
2282 recipient: "channel:p:all".into(),
2283 text: "wire".into(),
2284 sent_at: 0.0,
2285 }],
2286 calls: std::sync::Mutex::new(Vec::new()),
2287 };
2288 super::refresh_mailbox(&mut app, &mock);
2289 assert_eq!(app.mailbox.inbox.len(), 1);
2290 assert_eq!(app.mailbox.sent.len(), 1);
2291 assert_eq!(app.mailbox.channel.len(), 1);
2292 assert_eq!(app.mailbox.wire.len(), 1);
2293 let calls = mock.calls.lock().unwrap();
2294 assert!(calls.contains(&("inbox", "p:a".into(), 0)));
2297 assert!(calls.contains(&("sent", "p:a".into(), 0)));
2298 assert!(calls.contains(&("channel", "p:a".into(), 0)));
2299 assert!(calls.contains(&("wire", "p".into(), 0)));
2300 }
2301
2302 fn ap(id: i64) -> crate::approvals::Approval {
2303 crate::approvals::Approval {
2304 id,
2305 project_id: "p".into(),
2306 agent_id: "p:m".into(),
2307 action: "publish".into(),
2308 summary: format!("approval #{id}"),
2309 payload_json: String::new(),
2310 }
2311 }
2312
2313 #[test]
2314 fn has_pending_approvals_tracks_replace_calls() {
2315 let mut app = App::new();
2316 assert!(!app.has_pending_approvals());
2317 app.replace_approvals(vec![ap(1), ap(2)]);
2318 assert!(app.has_pending_approvals());
2319 app.replace_approvals(vec![]);
2320 assert!(!app.has_pending_approvals());
2321 }
2322
2323 #[test]
2324 fn enter_approvals_modal_no_op_when_queue_empty() {
2325 let mut app = App::new();
2326 app.dismiss_splash();
2327 app.enter_approvals_modal();
2328 assert_eq!(app.stage, Stage::Triptych, "no pending → no modal");
2329 }
2330
2331 #[test]
2332 fn a_chord_opens_modal_when_pending() {
2333 let mut app = App::new();
2334 app.dismiss_splash();
2335 app.replace_approvals(vec![ap(1), ap(2)]);
2336 dispatch(&mut app, key(KeyCode::Char('a')));
2337 assert_eq!(app.stage, Stage::ApprovalsModal);
2338 assert_eq!(app.selected_approval, 0);
2339 }
2340
2341 #[test]
2342 fn modal_cycle_jk_walks_approvals() {
2343 let mut app = App::new();
2344 app.dismiss_splash();
2345 app.replace_approvals(vec![ap(1), ap(2), ap(3)]);
2346 app.enter_approvals_modal();
2347 dispatch(&mut app, key(KeyCode::Char('j')));
2348 assert_eq!(app.selected_approval, 1);
2349 dispatch(&mut app, key(KeyCode::Char('j')));
2350 assert_eq!(app.selected_approval, 2);
2351 dispatch(&mut app, key(KeyCode::Char('j')));
2352 assert_eq!(app.selected_approval, 0, "wraps");
2353 dispatch(&mut app, key(KeyCode::Char('k')));
2354 assert_eq!(app.selected_approval, 2, "k wraps too");
2355 }
2356
2357 #[test]
2358 fn capital_y_routes_approve_through_decider() {
2359 use crate::approvals::test_support::MockApprovalDecider;
2360 let dec = MockApprovalDecider::default();
2361 let mut app = App::new();
2362 app.dismiss_splash();
2363 app.replace_approvals(vec![ap(7), ap(8)]);
2364 app.enter_approvals_modal();
2365 super::handle_event(
2366 &mut app,
2367 key(KeyCode::Char('Y')),
2368 &dec,
2369 &NoopSender,
2370 &EmptyMailbox,
2371 &crate::keysender::test_support::MockKeySender::default(),
2372 );
2373 let calls = dec.calls.lock().unwrap().clone();
2374 assert_eq!(calls.len(), 1);
2375 assert_eq!(calls[0].0, 7);
2376 assert_eq!(calls[0].1, crate::approvals::Decision::Approve);
2377 assert_eq!(app.pending_approvals.len(), 1);
2379 assert_eq!(app.pending_approvals[0].id, 8);
2380 }
2381
2382 #[test]
2383 fn capital_n_routes_deny_through_decider() {
2384 use crate::approvals::test_support::MockApprovalDecider;
2385 let dec = MockApprovalDecider::default();
2386 let mut app = App::new();
2387 app.dismiss_splash();
2388 app.replace_approvals(vec![ap(7)]);
2389 app.enter_approvals_modal();
2390 super::handle_event(
2391 &mut app,
2392 key(KeyCode::Char('N')),
2393 &dec,
2394 &NoopSender,
2395 &EmptyMailbox,
2396 &crate::keysender::test_support::MockKeySender::default(),
2397 );
2398 let calls = dec.calls.lock().unwrap().clone();
2399 assert_eq!(calls.len(), 1);
2400 assert_eq!(calls[0].1, crate::approvals::Decision::Deny);
2401 assert_eq!(app.stage, Stage::Triptych);
2403 }
2404
2405 #[test]
2406 fn esc_closes_approvals_modal() {
2407 let mut app = App::new();
2408 app.dismiss_splash();
2409 app.replace_approvals(vec![ap(1)]);
2410 app.enter_approvals_modal();
2411 dispatch(&mut app, key(KeyCode::Esc));
2412 assert_eq!(app.stage, Stage::Triptych);
2413 }
2414
2415 #[test]
2416 fn lowercase_y_routes_approve_through_decider() {
2417 use crate::approvals::test_support::MockApprovalDecider;
2421 let dec = MockApprovalDecider::default();
2422 let mut app = App::new();
2423 app.dismiss_splash();
2424 app.replace_approvals(vec![ap(7)]);
2425 app.enter_approvals_modal();
2426 super::handle_event(
2427 &mut app,
2428 key(KeyCode::Char('y')),
2429 &dec,
2430 &NoopSender,
2431 &EmptyMailbox,
2432 &crate::keysender::test_support::MockKeySender::default(),
2433 );
2434 let calls = dec.calls.lock().unwrap().clone();
2435 assert_eq!(calls.len(), 1);
2436 assert_eq!(calls[0].1, crate::approvals::Decision::Approve);
2437 }
2438
2439 #[test]
2440 fn lowercase_n_does_not_deny() {
2441 use crate::approvals::test_support::MockApprovalDecider;
2446 let dec = MockApprovalDecider::default();
2447 let mut app = App::new();
2448 app.dismiss_splash();
2449 app.replace_approvals(vec![ap(7)]);
2450 app.enter_approvals_modal();
2451 super::handle_event(
2452 &mut app,
2453 key(KeyCode::Char('n')),
2454 &dec,
2455 &NoopSender,
2456 &EmptyMailbox,
2457 &crate::keysender::test_support::MockKeySender::default(),
2458 );
2459 assert!(
2460 dec.calls.lock().unwrap().is_empty(),
2461 "lowercase n must not route through the decider"
2462 );
2463 assert_eq!(
2464 app.stage,
2465 Stage::ApprovalsModal,
2466 "stale lowercase n leaves the modal open"
2467 );
2468 }
2469
2470 #[test]
2471 fn shift_tab_cycles_panes_backward() {
2472 use crossterm::event::KeyModifiers;
2473 let mut app = App::new();
2474 app.dismiss_splash();
2475 assert_eq!(app.focused_pane, Pane::Roster);
2476 dispatch(&mut app, key(KeyCode::BackTab));
2479 assert_eq!(app.focused_pane, Pane::Mailbox);
2480 dispatch(&mut app, key_with(KeyCode::Tab, KeyModifiers::SHIFT));
2482 assert_eq!(app.focused_pane, Pane::Detail);
2483 }
2484
2485 #[test]
2486 fn at_chord_opens_compose_dm_to_focused_agent() {
2487 let mut app = App::new();
2488 app.replace_team(fixture_team(vec![
2489 agent("writing:manager", AgentState::Running),
2490 agent("writing:dev1", AgentState::Running),
2491 ]));
2492 app.dismiss_splash();
2493 app.select_next();
2494 dispatch(&mut app, key(KeyCode::Char('@')));
2495 assert_eq!(app.stage, Stage::ComposeModal);
2496 match app.compose_target.as_ref() {
2497 Some(crate::compose::ComposeTarget::Dm { agent_id, .. }) => {
2498 assert_eq!(agent_id, "writing:dev1");
2499 }
2500 other => panic!("expected DM target, got {other:?}"),
2501 }
2502 }
2503
2504 #[test]
2505 fn bang_chord_opens_compose_broadcast_to_all_channel() {
2506 let mut app = App::new();
2507 app.replace_team(fixture_team(vec![agent(
2508 "writing:manager",
2509 AgentState::Running,
2510 )]));
2511 app.dismiss_splash();
2512 dispatch(&mut app, key(KeyCode::Char('!')));
2513 assert_eq!(app.stage, Stage::ComposeModal);
2514 match app.compose_target.as_ref() {
2515 Some(crate::compose::ComposeTarget::Broadcast { channel_id, .. }) => {
2516 assert_eq!(channel_id, "writing:all");
2517 }
2518 other => panic!("expected Broadcast target, got {other:?}"),
2519 }
2520 }
2521
2522 #[test]
2523 fn send_routes_dm_through_mock_sender() {
2524 use crate::compose::test_support::MockMessageSender;
2525 let sender = MockMessageSender::default();
2526 let mailbox = EmptyMailbox;
2527 let mut app = App::new();
2528 app.replace_team(fixture_team(vec![agent(
2529 "writing:dev1",
2530 AgentState::Running,
2531 )]));
2532 app.dismiss_splash();
2533 app.enter_compose_dm_for_focused();
2534 for c in "ship it".chars() {
2535 super::handle_event(
2536 &mut app,
2537 key(KeyCode::Char(c)),
2538 &NoopDecider,
2539 &sender,
2540 &mailbox,
2541 &crate::keysender::test_support::MockKeySender::default(),
2542 );
2543 }
2544 super::handle_event(
2545 &mut app,
2546 key_with(KeyCode::Enter, crossterm::event::KeyModifiers::CONTROL),
2547 &NoopDecider,
2548 &sender,
2549 &mailbox,
2550 &crate::keysender::test_support::MockKeySender::default(),
2551 );
2552 let calls = sender.dm_calls.lock().unwrap().clone();
2553 assert_eq!(calls.len(), 1);
2554 assert_eq!(calls[0].0, "writing:dev1");
2555 assert_eq!(calls[0].1, "ship it");
2556 assert_eq!(app.stage, Stage::Triptych, "modal closes on send");
2557 }
2558
2559 #[test]
2560 fn esc_esc_cancels_compose_without_send() {
2561 use crate::compose::test_support::MockMessageSender;
2562 let sender = MockMessageSender::default();
2563 let mailbox = EmptyMailbox;
2564 let mut app = App::new();
2565 app.replace_team(fixture_team(vec![agent(
2566 "writing:dev1",
2567 AgentState::Running,
2568 )]));
2569 app.dismiss_splash();
2570 app.enter_compose_dm_for_focused();
2571 for c in "draft".chars() {
2572 super::handle_event(
2573 &mut app,
2574 key(KeyCode::Char(c)),
2575 &NoopDecider,
2576 &sender,
2577 &mailbox,
2578 &crate::keysender::test_support::MockKeySender::default(),
2579 );
2580 }
2581 super::handle_event(
2582 &mut app,
2583 key(KeyCode::Esc),
2584 &NoopDecider,
2585 &sender,
2586 &mailbox,
2587 &crate::keysender::test_support::MockKeySender::default(),
2588 );
2589 super::handle_event(
2590 &mut app,
2591 key(KeyCode::Esc),
2592 &NoopDecider,
2593 &sender,
2594 &mailbox,
2595 &crate::keysender::test_support::MockKeySender::default(),
2596 );
2597 assert_eq!(app.stage, Stage::Triptych);
2598 assert!(sender.dm_calls.lock().unwrap().is_empty());
2599 }
2600
2601 #[test]
2602 fn send_failure_surfaces_error_inline_keeps_modal_open() {
2603 use crate::compose::test_support::MockMessageSender;
2604 let sender = MockMessageSender::default();
2605 *sender.fail_next.lock().unwrap() = Some("rate limit".into());
2606 let mailbox = EmptyMailbox;
2607 let mut app = App::new();
2608 app.replace_team(fixture_team(vec![agent(
2609 "writing:dev1",
2610 AgentState::Running,
2611 )]));
2612 app.dismiss_splash();
2613 app.enter_compose_dm_for_focused();
2614 super::handle_event(
2615 &mut app,
2616 key(KeyCode::Char('x')),
2617 &NoopDecider,
2618 &sender,
2619 &mailbox,
2620 &crate::keysender::test_support::MockKeySender::default(),
2621 );
2622 super::handle_event(
2623 &mut app,
2624 key_with(KeyCode::Enter, crossterm::event::KeyModifiers::CONTROL),
2625 &NoopDecider,
2626 &sender,
2627 &mailbox,
2628 &crate::keysender::test_support::MockKeySender::default(),
2629 );
2630 assert_eq!(app.stage, Stage::ComposeModal, "modal stays open on err");
2631 assert!(app
2632 .compose_error
2633 .as_deref()
2634 .unwrap_or_default()
2635 .contains("rate limit"));
2636 }
2637
2638 fn channel(id: &str, project: &str) -> crate::data::ChannelInfo {
2639 crate::data::ChannelInfo {
2640 id: id.into(),
2641 name: id
2642 .rsplit_once(':')
2643 .map(|(_, n)| n.to_string())
2644 .unwrap_or_default(),
2645 project_id: project.into(),
2646 }
2647 }
2648
2649 fn fixture_team_with_channels(
2650 agents: Vec<AgentInfo>,
2651 channels: Vec<crate::data::ChannelInfo>,
2652 ) -> TeamSnapshot {
2653 TeamSnapshot {
2654 root: std::path::PathBuf::from("/fixture"),
2655 team_name: "fixture".into(),
2656 agents,
2657 channels,
2658 }
2659 }
2660
2661 #[test]
2662 fn ctrl_w_toggles_wall_layout() {
2663 use crossterm::event::KeyModifiers;
2664 let mut app = App::new();
2665 app.dismiss_splash();
2666 assert_eq!(app.layout, MainLayout::Triptych);
2667 dispatch(
2668 &mut app,
2669 key_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
2670 );
2671 assert_eq!(app.layout, MainLayout::Wall);
2672 dispatch(
2673 &mut app,
2674 key_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
2675 );
2676 assert_eq!(app.layout, MainLayout::Triptych);
2677 }
2678
2679 #[test]
2680 fn ctrl_m_toggles_mailbox_first_layout() {
2681 use crossterm::event::KeyModifiers;
2682 let mut app = App::new();
2683 app.dismiss_splash();
2684 dispatch(
2685 &mut app,
2686 key_with(KeyCode::Char('m'), KeyModifiers::CONTROL),
2687 );
2688 assert_eq!(app.layout, MainLayout::MailboxFirst);
2689 dispatch(
2690 &mut app,
2691 key_with(KeyCode::Char('m'), KeyModifiers::CONTROL),
2692 );
2693 assert_eq!(app.layout, MainLayout::Triptych);
2694 }
2695
2696 #[test]
2697 fn wall_scroll_pages_through_overflow_agents() {
2698 let mut app = App::new();
2699 let mut agents: Vec<_> = (1..=10)
2700 .map(|i| agent(&format!("p:agent-{i:02}"), AgentState::Running))
2701 .collect();
2702 for a in agents.iter_mut() {
2704 a.is_manager = false;
2705 }
2706 app.replace_team(fixture_team(agents));
2707 app.dismiss_splash();
2708 app.toggle_wall_layout();
2709 assert_eq!(app.wall_scroll, 0);
2710 app.wall_scroll_down();
2711 assert_eq!(app.wall_scroll, 4);
2712 app.wall_scroll_down();
2713 assert_eq!(app.wall_scroll, 8);
2714 app.wall_scroll_down();
2716 assert_eq!(app.wall_scroll, 8, "scroll capped at last full window");
2717 app.wall_scroll_up();
2718 assert_eq!(app.wall_scroll, 4);
2719 }
2720
2721 #[test]
2722 fn ctrl_pipe_adds_detail_split_capped_at_four() {
2723 use crossterm::event::KeyModifiers;
2724 let mut app = App::new();
2725 app.replace_team(fixture_team(vec![
2726 agent("p:a", AgentState::Running),
2727 agent("p:b", AgentState::Running),
2728 ]));
2729 app.dismiss_splash();
2730 for _ in 0..6 {
2731 dispatch(
2732 &mut app,
2733 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
2734 );
2735 }
2736 assert_eq!(app.detail_splits.len(), 4, "split count capped at 4");
2737 }
2738
2739 #[test]
2740 fn ctrl_q_closes_focused_split() {
2741 use crossterm::event::KeyModifiers;
2742 let mut app = App::new();
2743 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
2744 app.dismiss_splash();
2745 dispatch(
2746 &mut app,
2747 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
2748 );
2749 dispatch(
2750 &mut app,
2751 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
2752 );
2753 assert_eq!(app.detail_splits.len(), 2);
2754 dispatch(
2755 &mut app,
2756 key_with(KeyCode::Char('Q'), KeyModifiers::CONTROL),
2757 );
2758 assert_eq!(app.detail_splits.len(), 1);
2759 }
2760
2761 #[test]
2762 fn ctrl_hjkl_cycles_splits() {
2763 use crossterm::event::KeyModifiers;
2764 let mut app = App::new();
2765 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
2766 app.dismiss_splash();
2767 for _ in 0..3 {
2768 dispatch(
2769 &mut app,
2770 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
2771 );
2772 }
2773 assert_eq!(app.selected_split, 2);
2774 dispatch(
2775 &mut app,
2776 key_with(KeyCode::Char('l'), KeyModifiers::CONTROL),
2777 );
2778 assert_eq!(app.selected_split, 0, "wraps");
2779 dispatch(
2780 &mut app,
2781 key_with(KeyCode::Char('h'), KeyModifiers::CONTROL),
2782 );
2783 assert_eq!(app.selected_split, 2);
2784 }
2785
2786 #[test]
2787 fn wall_scroll_at_exactly_cap_agents_does_not_scroll() {
2788 let mut app = App::new();
2793 let agents: Vec<_> = (1..=4)
2794 .map(|i| agent(&format!("p:agent-{i}"), AgentState::Running))
2795 .collect();
2796 app.replace_team(fixture_team(agents));
2797 app.dismiss_splash();
2798 app.toggle_wall_layout();
2799 assert_eq!(app.wall_scroll, 0);
2800 app.wall_scroll_down();
2801 assert_eq!(app.wall_scroll, 0, "exactly-cap should not advance");
2802 app.wall_scroll_up();
2803 assert_eq!(app.wall_scroll, 0);
2804 }
2805
2806 #[test]
2807 fn wall_scroll_at_cap_plus_one_advances_then_stops() {
2808 let mut app = App::new();
2813 let agents: Vec<_> = (1..=5)
2814 .map(|i| agent(&format!("p:agent-{i}"), AgentState::Running))
2815 .collect();
2816 app.replace_team(fixture_team(agents));
2817 app.dismiss_splash();
2818 app.toggle_wall_layout();
2819 app.wall_scroll_down();
2820 assert_eq!(app.wall_scroll, 4, "first scroll exposes agent 5");
2821 app.wall_scroll_down();
2822 assert_eq!(app.wall_scroll, 4, "second scroll caps; nothing past");
2823 }
2824
2825 #[test]
2826 fn esc_in_picker_dismisses_overlay_only_keeps_modal_open() {
2827 let mut app = App::new();
2833 app.replace_team(fixture_team_with_channels(
2834 vec![agent("writing:manager", AgentState::Running)],
2835 vec![
2836 channel("writing:all", "writing"),
2837 channel("writing:editorial", "writing"),
2838 ],
2839 ));
2840 app.dismiss_splash();
2841 dispatch(&mut app, key(KeyCode::Char('!')));
2842 assert!(app.compose_picker_open);
2843 assert_eq!(app.stage, Stage::ComposeModal);
2844 dispatch(&mut app, key(KeyCode::Esc));
2845 assert!(!app.compose_picker_open, "picker dismissed");
2846 assert_eq!(app.stage, Stage::ComposeModal, "compose modal stays open");
2847 }
2848
2849 #[test]
2850 fn send_routes_broadcast_through_mock_sender_via_picker() {
2851 use crate::compose::test_support::MockMessageSender;
2857 let sender = MockMessageSender::default();
2858 let mailbox = EmptyMailbox;
2859 let mut app = App::new();
2860 app.replace_team(fixture_team_with_channels(
2861 vec![agent("writing:manager", AgentState::Running)],
2862 vec![
2863 channel("writing:all", "writing"),
2864 channel("writing:editorial", "writing"),
2865 channel("writing:critique", "writing"),
2866 ],
2867 ));
2868 app.dismiss_splash();
2869 super::handle_event(
2872 &mut app,
2873 key(KeyCode::Char('!')),
2874 &NoopDecider,
2875 &sender,
2876 &mailbox,
2877 &crate::keysender::test_support::MockKeySender::default(),
2878 );
2879 super::handle_event(
2880 &mut app,
2881 key(KeyCode::Char('j')),
2882 &NoopDecider,
2883 &sender,
2884 &mailbox,
2885 &crate::keysender::test_support::MockKeySender::default(),
2886 );
2887 super::handle_event(
2888 &mut app,
2889 key(KeyCode::Enter),
2890 &NoopDecider,
2891 &sender,
2892 &mailbox,
2893 &crate::keysender::test_support::MockKeySender::default(),
2894 );
2895 for c in "ship docs".chars() {
2896 super::handle_event(
2897 &mut app,
2898 key(KeyCode::Char(c)),
2899 &NoopDecider,
2900 &sender,
2901 &mailbox,
2902 &crate::keysender::test_support::MockKeySender::default(),
2903 );
2904 }
2905 super::handle_event(
2906 &mut app,
2907 key_with(KeyCode::Enter, crossterm::event::KeyModifiers::CONTROL),
2908 &NoopDecider,
2909 &sender,
2910 &mailbox,
2911 &crate::keysender::test_support::MockKeySender::default(),
2912 );
2913 let dm_calls = sender.dm_calls.lock().unwrap().clone();
2914 let bcast_calls = sender.broadcast_calls.lock().unwrap().clone();
2915 assert!(dm_calls.is_empty(), "broadcast must not route via send_dm");
2916 assert_eq!(bcast_calls.len(), 1);
2917 assert_eq!(
2918 bcast_calls[0].0, "writing:editorial",
2919 "channel id from picker selection"
2920 );
2921 assert_eq!(bcast_calls[0].1, "ship docs");
2922 assert_eq!(app.stage, Stage::Triptych, "modal closes on send");
2923 }
2924
2925 #[test]
2926 fn bang_chord_opens_picker_when_channels_available() {
2927 let mut app = App::new();
2928 app.replace_team(fixture_team_with_channels(
2929 vec![agent("writing:manager", AgentState::Running)],
2930 vec![
2931 channel("writing:all", "writing"),
2932 channel("writing:editorial", "writing"),
2933 channel("writing:critique", "writing"),
2934 ],
2935 ));
2936 app.dismiss_splash();
2937 dispatch(&mut app, key(KeyCode::Char('!')));
2938 assert_eq!(app.stage, Stage::ComposeModal);
2939 assert!(app.compose_picker_open);
2940 dispatch(&mut app, key(KeyCode::Char('j')));
2942 assert_eq!(app.compose_picker_index, 1);
2943 dispatch(&mut app, key(KeyCode::Enter));
2945 assert!(!app.compose_picker_open, "picker closes on confirm");
2946 match app.compose_target.as_ref() {
2947 Some(crate::compose::ComposeTarget::Broadcast { channel_id, .. }) => {
2948 assert_eq!(channel_id, "writing:editorial");
2949 }
2950 other => panic!("expected Broadcast target, got {other:?}"),
2951 }
2952 }
2953
2954 #[test]
2955 fn mailbox_first_layout_seeds_channel_selection_on_entry() {
2956 let mut app = App::new();
2957 app.replace_team(fixture_team_with_channels(
2958 vec![agent("writing:manager", AgentState::Running)],
2959 vec![
2960 channel("writing:all", "writing"),
2961 channel("writing:editorial", "writing"),
2962 ],
2963 ));
2964 app.dismiss_splash();
2965 assert!(app.selected_channel.is_none());
2966 app.toggle_mailbox_first_layout();
2967 assert_eq!(app.selected_channel, Some(0));
2968 }
2969
2970 #[test]
2971 fn help_overlay_opens_on_question_mark_closes_on_esc() {
2972 let mut app = App::new();
2973 app.dismiss_splash();
2974 dispatch(&mut app, key(KeyCode::Char('?')));
2975 assert_eq!(app.stage, Stage::HelpOverlay);
2976 dispatch(&mut app, key(KeyCode::Esc));
2977 assert_eq!(app.stage, Stage::Triptych);
2978 }
2979
2980 #[test]
2981 fn tutorial_opens_on_t_advances_and_closes() {
2982 let mut app = App::new();
2983 app.dismiss_splash();
2984 dispatch(&mut app, key(KeyCode::Char('t')));
2985 assert_eq!(app.stage, Stage::Tutorial);
2986 assert_eq!(app.tutorial_step, 0);
2987 dispatch(&mut app, key(KeyCode::Char(' ')));
2989 assert_eq!(app.tutorial_step, 1);
2990 dispatch(&mut app, key(KeyCode::Char('k')));
2992 assert_eq!(app.tutorial_step, 0);
2993 dispatch(&mut app, key(KeyCode::Esc));
2995 assert_eq!(app.stage, Stage::Triptych);
2996 }
2997
2998 #[test]
2999 fn tutorial_walk_back_at_step_zero_is_no_op() {
3000 let mut app = App::new();
3005 app.dismiss_splash();
3006 app.enter_tutorial();
3007 assert_eq!(app.tutorial_step, 0);
3008 dispatch(&mut app, key(KeyCode::Char('k')));
3009 assert_eq!(app.tutorial_step, 0, "step-0 walk-back is no-op");
3010 assert_eq!(app.stage, Stage::Tutorial);
3013 }
3014
3015 #[test]
3016 fn ctrl_pipe_adds_vertical_split_ctrl_minus_adds_horizontal() {
3017 use crossterm::event::KeyModifiers;
3018 let mut app = App::new();
3019 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3020 app.dismiss_splash();
3021 dispatch(
3022 &mut app,
3023 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
3024 );
3025 dispatch(
3026 &mut app,
3027 key_with(KeyCode::Char('-'), KeyModifiers::CONTROL),
3028 );
3029 assert_eq!(app.detail_splits.len(), 2);
3030 assert_eq!(app.detail_splits[0].1, SplitOrientation::Vertical);
3031 assert_eq!(app.detail_splits[1].1, SplitOrientation::Horizontal);
3032 }
3033
3034 #[test]
3035 fn ctrl_w_q_chord_prefix_closes_focused_split() {
3036 use crossterm::event::KeyModifiers;
3037 let mut app = App::new();
3038 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3039 app.dismiss_splash();
3040 dispatch(
3043 &mut app,
3044 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
3045 );
3046 dispatch(
3047 &mut app,
3048 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
3049 );
3050 dispatch(
3051 &mut app,
3052 key_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
3053 );
3054 assert_eq!(app.pending_chord, Some(KeyCode::Char('w')));
3055 dispatch(&mut app, key(KeyCode::Char('q')));
3058 assert_eq!(app.detail_splits.len(), 1);
3059 assert_eq!(app.stage, Stage::Triptych, "must not enter quit confirm");
3060 assert_eq!(app.pending_chord, None, "chord cleared");
3061 }
3062
3063 #[test]
3064 fn ctrl_w_o_chord_keeps_only_focused_split() {
3065 use crossterm::event::KeyModifiers;
3066 let mut app = App::new();
3067 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3068 app.dismiss_splash();
3069 for _ in 0..3 {
3070 dispatch(
3071 &mut app,
3072 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
3073 );
3074 }
3075 app.selected_split = 1;
3077 let kept_id = app.detail_splits[1].0.clone();
3078 dispatch(
3079 &mut app,
3080 key_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
3081 );
3082 dispatch(&mut app, key(KeyCode::Char('o')));
3083 assert_eq!(app.detail_splits.len(), 1);
3084 assert_eq!(app.detail_splits[0].0, kept_id);
3085 assert_eq!(app.selected_split, 0);
3086 }
3087
3088 #[test]
3089 fn add_detail_split_saturates_at_four_with_explicit_4_and_5_calls() {
3090 let mut app = App::new();
3095 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3096 for _ in 0..4 {
3097 app.add_detail_split();
3098 }
3099 assert_eq!(app.detail_splits.len(), 4);
3100 let snapshot_len = app.detail_splits.len();
3101 app.add_detail_split();
3102 assert_eq!(app.detail_splits.len(), snapshot_len, "5th call rejected");
3103 }
3104
3105 #[test]
3106 fn replace_approvals_clamps_selection_in_range() {
3107 let mut app = App::new();
3108 app.replace_approvals(vec![ap(1), ap(2), ap(3)]);
3109 app.selected_approval = 2;
3110 app.replace_approvals(vec![ap(1), ap(2)]);
3112 assert_eq!(app.selected_approval, 1, "clamps to last index");
3113 }
3114
3115 #[test]
3116 fn arrow_keys_navigate_only_when_roster_focused() {
3117 let mut app = App::new();
3118 app.replace_team(fixture_team(vec![
3119 agent("p:a", AgentState::Running),
3120 agent("p:b", AgentState::Running),
3121 ]));
3122 app.dismiss_splash();
3123 app.selected_agent = Some(0);
3125 dispatch(&mut app, key(KeyCode::Down));
3126 assert_eq!(app.selected_agent, Some(1));
3127 app.cycle_focus();
3129 dispatch(&mut app, key(KeyCode::Down));
3130 assert_eq!(
3131 app.selected_agent,
3132 Some(1),
3133 "non-roster focus ignores arrows"
3134 );
3135 }
3136
3137 fn stream_keys_fixture() -> App {
3143 let mut app = App::new();
3144 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3145 app.dismiss_splash();
3146 app.cycle_focus(); assert_eq!(app.focused_pane, Pane::Detail);
3148 assert_eq!(app.selected_agent, Some(0));
3149 app
3150 }
3151
3152 fn stream_dispatch(
3153 app: &mut App,
3154 ev: Event,
3155 key_sender: &crate::keysender::test_support::MockKeySender,
3156 ) {
3157 super::handle_event(
3158 app,
3159 ev,
3160 &NoopDecider,
3161 &NoopSender,
3162 &EmptyMailbox,
3163 key_sender,
3164 );
3165 }
3166
3167 #[test]
3168 fn ctrl_e_enters_stream_keys_when_detail_focused() {
3169 use crate::keysender::test_support::MockKeySender;
3170 use crossterm::event::KeyModifiers;
3171 let mut app = stream_keys_fixture();
3172 let ks = MockKeySender::default();
3173 stream_dispatch(
3174 &mut app,
3175 key_with(KeyCode::Char('e'), KeyModifiers::CONTROL),
3176 &ks,
3177 );
3178 assert_eq!(app.stage, Stage::StreamKeys);
3179 assert!(
3180 ks.calls.lock().unwrap().is_empty(),
3181 "the activation chord itself never forwards a keystroke"
3182 );
3183 }
3184
3185 #[test]
3186 fn ctrl_e_no_op_when_detail_not_focused() {
3187 use crate::keysender::test_support::MockKeySender;
3192 use crossterm::event::KeyModifiers;
3193 let mut app = App::new();
3194 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3195 app.dismiss_splash();
3196 assert_eq!(app.focused_pane, Pane::Roster);
3197 let ks = MockKeySender::default();
3198 stream_dispatch(
3199 &mut app,
3200 key_with(KeyCode::Char('e'), KeyModifiers::CONTROL),
3201 &ks,
3202 );
3203 assert_eq!(app.stage, Stage::Triptych);
3204 }
3205
3206 #[test]
3207 fn ctrl_e_no_op_when_no_agent_selected() {
3208 use crate::keysender::test_support::MockKeySender;
3211 use crossterm::event::KeyModifiers;
3212 let mut app = App::new();
3213 app.dismiss_splash();
3214 app.cycle_focus(); assert_eq!(app.selected_agent, None);
3216 let ks = MockKeySender::default();
3217 stream_dispatch(
3218 &mut app,
3219 key_with(KeyCode::Char('e'), KeyModifiers::CONTROL),
3220 &ks,
3221 );
3222 assert_eq!(app.stage, Stage::Triptych);
3223 }
3224
3225 #[test]
3226 fn esc_exits_stream_keys() {
3227 use crate::keysender::test_support::MockKeySender;
3228 let mut app = stream_keys_fixture();
3229 app.enter_stream_keys();
3230 assert_eq!(app.stage, Stage::StreamKeys);
3231 let ks = MockKeySender::default();
3232 stream_dispatch(&mut app, key(KeyCode::Esc), &ks);
3233 assert_eq!(app.stage, Stage::Triptych);
3234 assert!(
3235 ks.calls.lock().unwrap().is_empty(),
3236 "Esc is the exit chord — it must not forward as a keystroke"
3237 );
3238 }
3239
3240 #[test]
3241 fn stream_mode_forwards_printable_chars_to_target_session() {
3242 use crate::keysender::test_support::MockKeySender;
3243 let mut app = stream_keys_fixture();
3244 app.enter_stream_keys();
3245 let ks = MockKeySender::default();
3246 for c in "hi".chars() {
3247 stream_dispatch(&mut app, key(KeyCode::Char(c)), &ks);
3248 }
3249 let calls = ks.calls.lock().unwrap();
3250 assert_eq!(calls.len(), 2, "one tmux send-keys per keystroke");
3251 assert_eq!(calls[0].0, "t-p-a");
3254 assert_eq!(calls[0].1.args, vec!["-l".to_string(), "h".to_string()]);
3255 assert_eq!(calls[1].1.args, vec!["-l".to_string(), "i".to_string()]);
3256 }
3257
3258 #[test]
3259 fn stream_mode_passes_ctrl_c_through_to_agent() {
3260 use crate::keysender::test_support::MockKeySender;
3264 use crossterm::event::KeyModifiers;
3265 let mut app = stream_keys_fixture();
3266 app.enter_stream_keys();
3267 let ks = MockKeySender::default();
3268 stream_dispatch(
3269 &mut app,
3270 key_with(KeyCode::Char('c'), KeyModifiers::CONTROL),
3271 &ks,
3272 );
3273 assert_eq!(app.stage, Stage::StreamKeys, "Ctrl+C does NOT exit");
3274 let calls = ks.calls.lock().unwrap();
3275 assert_eq!(calls.len(), 1);
3276 assert_eq!(calls[0].1.args, vec!["C-c".to_string()]);
3277 }
3278
3279 #[test]
3280 fn stream_mode_forwards_enter_and_arrows() {
3281 use crate::keysender::test_support::MockKeySender;
3282 let mut app = stream_keys_fixture();
3283 app.enter_stream_keys();
3284 let ks = MockKeySender::default();
3285 stream_dispatch(&mut app, key(KeyCode::Enter), &ks);
3286 stream_dispatch(&mut app, key(KeyCode::Up), &ks);
3287 let calls = ks.calls.lock().unwrap();
3288 assert_eq!(calls[0].1.args, vec!["Enter".to_string()]);
3289 assert_eq!(calls[1].1.args, vec!["Up".to_string()]);
3290 }
3291
3292 #[test]
3293 fn stream_target_session_uses_focused_split_when_present() {
3294 let mut app = App::new();
3299 app.replace_team(fixture_team(vec![
3300 agent("p:a", AgentState::Running),
3301 agent("p:b", AgentState::Running),
3302 ]));
3303 app.dismiss_splash();
3304 app.cycle_focus(); app.selected_agent = Some(0);
3306 app.detail_splits
3308 .push(("p:b".into(), crate::app::SplitOrientation::Vertical));
3309 app.selected_split = 1; let target = app.stream_target_session();
3311 assert_eq!(
3312 target.as_deref(),
3313 Some("t-p-b"),
3314 "selected split's agent drives the target"
3315 );
3316 }
3317
3318 #[test]
3319 fn stream_mode_drops_back_when_target_session_disappears() {
3320 use crate::keysender::test_support::MockKeySender;
3325 let mut app = stream_keys_fixture();
3326 app.enter_stream_keys();
3327 app.selected_agent = None;
3329 app.team.agents.clear();
3330 let ks = MockKeySender::default();
3331 stream_dispatch(&mut app, key(KeyCode::Char('a')), &ks);
3332 assert_eq!(app.stage, Stage::Triptych);
3333 assert!(ks.calls.lock().unwrap().is_empty());
3334 }
3335
3336 fn pane_sync_fixture() -> App {
3339 let mut app = App::new();
3340 app.team = fixture_team(vec![
3341 agent("hello:mgr", AgentState::Running),
3342 agent("hello:dev", AgentState::Running),
3343 ]);
3344 app.selected_agent = Some(0);
3345 app.stage = Stage::Triptych;
3346 app.layout = MainLayout::Triptych;
3347 app
3348 }
3349
3350 #[test]
3351 fn sync_fires_resize_on_first_frame() {
3352 let mut app = pane_sync_fixture();
3353 let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
3354 sync_focused_pane_size_to(
3355 &mut app,
3356 ratatui::layout::Rect::new(0, 0, 120, 40),
3357 &resizer,
3358 );
3359 let calls = resizer.calls.lock().unwrap();
3360 assert_eq!(calls.len(), 1);
3363 assert_eq!(calls[0].0, "t-hello-mgr");
3364 assert_eq!(calls[0].1, 92); assert_eq!(calls[0].2, 24); }
3367
3368 #[test]
3369 fn sync_skips_when_size_unchanged() {
3370 let mut app = pane_sync_fixture();
3371 let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
3372 sync_focused_pane_size_to(
3374 &mut app,
3375 ratatui::layout::Rect::new(0, 0, 120, 40),
3376 &resizer,
3377 );
3378 sync_focused_pane_size_to(
3379 &mut app,
3380 ratatui::layout::Rect::new(0, 0, 120, 40),
3381 &resizer,
3382 );
3383 assert_eq!(resizer.calls.lock().unwrap().len(), 1);
3384 }
3385
3386 #[test]
3387 fn sync_fires_again_when_terminal_resizes() {
3388 let mut app = pane_sync_fixture();
3389 let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
3390 sync_focused_pane_size_to(
3391 &mut app,
3392 ratatui::layout::Rect::new(0, 0, 120, 40),
3393 &resizer,
3394 );
3395 sync_focused_pane_size_to(
3397 &mut app,
3398 ratatui::layout::Rect::new(0, 0, 200, 60),
3399 &resizer,
3400 );
3401 let calls = resizer.calls.lock().unwrap();
3402 assert_eq!(calls.len(), 2);
3403 assert_eq!(calls[0].1, 92);
3404 assert_eq!(calls[0].2, 24);
3405 assert_eq!(calls[1].1, 172); assert_eq!(calls[1].2, 36);
3408 }
3409
3410 #[test]
3411 fn sync_fires_on_focus_switch_to_unsynced_session() {
3412 let mut app = pane_sync_fixture();
3413 let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
3414 sync_focused_pane_size_to(
3415 &mut app,
3416 ratatui::layout::Rect::new(0, 0, 120, 40),
3417 &resizer,
3418 );
3419 app.selected_agent = Some(1);
3421 sync_focused_pane_size_to(
3422 &mut app,
3423 ratatui::layout::Rect::new(0, 0, 120, 40),
3424 &resizer,
3425 );
3426 let calls = resizer.calls.lock().unwrap();
3427 assert_eq!(calls.len(), 2);
3428 assert_eq!(calls[0].0, "t-hello-mgr");
3429 assert_eq!(calls[1].0, "t-hello-dev");
3430 }
3431
3432 #[test]
3433 fn sync_is_noop_when_no_agent_focused() {
3434 let mut app = pane_sync_fixture();
3435 app.selected_agent = None;
3436 let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
3437 sync_focused_pane_size_to(
3438 &mut app,
3439 ratatui::layout::Rect::new(0, 0, 120, 40),
3440 &resizer,
3441 );
3442 assert!(resizer.calls.lock().unwrap().is_empty());
3443 }
3444
3445 #[test]
3446 fn sync_is_noop_when_layout_is_not_triptych() {
3447 let mut app = pane_sync_fixture();
3448 app.layout = MainLayout::Wall;
3449 let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
3450 sync_focused_pane_size_to(
3451 &mut app,
3452 ratatui::layout::Rect::new(0, 0, 120, 40),
3453 &resizer,
3454 );
3455 assert!(resizer.calls.lock().unwrap().is_empty());
3458 }
3459
3460 #[test]
3461 fn sync_is_noop_on_degenerate_terminal_area() {
3462 let mut app = pane_sync_fixture();
3463 let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
3464 sync_focused_pane_size_to(&mut app, ratatui::layout::Rect::new(0, 0, 28, 40), &resizer);
3466 assert!(resizer.calls.lock().unwrap().is_empty());
3467 }
3468
3469 #[test]
3470 fn sync_accounts_for_approvals_stripe_when_present() {
3471 let mut app = pane_sync_fixture();
3472 app.pending_approvals = vec![crate::approvals::Approval {
3474 id: 1,
3475 project_id: "hello".into(),
3476 agent_id: "hello:dev".into(),
3477 action: "test".into(),
3478 summary: "test approval".into(),
3479 payload_json: String::new(),
3480 }];
3481 assert!(app.has_pending_approvals());
3482 let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
3483 sync_focused_pane_size_to(
3484 &mut app,
3485 ratatui::layout::Rect::new(0, 0, 120, 40),
3486 &resizer,
3487 );
3488 let calls = resizer.calls.lock().unwrap();
3489 assert_eq!(calls.len(), 1);
3491 assert_eq!(calls[0].2, 23);
3492 }
3493}