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::statusline;
33use crate::theme::{detect_capabilities, Capabilities};
34use crate::triptych::{self, MainLayout, Pane};
35use crate::tutorial;
36use crate::watch::Watch;
37
38const SPLASH_AUTO_DISMISS: Duration = Duration::from_secs(3);
39const POLL_INTERVAL: Duration = Duration::from_millis(50);
40const REFRESH_INTERVAL: Duration = Duration::from_secs(1);
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum Stage {
46 Splash,
47 Triptych,
48 QuitConfirm,
49 ApprovalsModal,
54 ComposeModal,
59 HelpOverlay,
62 Tutorial,
67 StreamKeys,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum SplitOrientation {
82 Vertical,
83 Horizontal,
84}
85
86pub struct App {
87 pub stage: Stage,
88 pub previous_stage: Stage,
90 pub focused_pane: Pane,
91 pub team: TeamSnapshot,
92 pub selected_agent: Option<usize>,
96 pub detail_buffer: Vec<String>,
100 pub version: &'static str,
101 pub capabilities: Capabilities,
102 pub splash_started: Instant,
103 pub last_refresh: Instant,
106 pub running: bool,
107 pub tutorial_completed: bool,
111 pub mailbox_tab: MailboxTab,
119 pub mailbox: MailboxBuffers,
123 pub pending_approvals: Vec<Approval>,
126 pub selected_approval: usize,
130 pub approval_error: Option<String>,
134 pub compose_target: Option<ComposeTarget>,
138 pub compose_editor: Editor,
142 pub compose_error: Option<String>,
146 pub layout: MainLayout,
149 pub wall_scroll: usize,
153 pub selected_channel: Option<usize>,
157 pub detail_splits: Vec<(String, SplitOrientation)>,
163 pub selected_split: usize,
164 pub pending_chord: Option<KeyCode>,
170 pub tutorial_pending_for_team: bool,
174 pub spinner_frame: usize,
177 pub tutorial_step: usize,
180 pub compose_picker_open: bool,
185 pub compose_picker_index: usize,
187 pub compose_attach_input_open: bool,
194 pub compose_attach_buffer: String,
197}
198
199const MAX_DETAIL_LINES: usize = 2000;
200
201impl App {
202 pub fn new() -> Self {
208 Self {
209 stage: Stage::Splash,
210 previous_stage: Stage::Splash,
211 focused_pane: Pane::Roster,
212 team: TeamSnapshot::empty(std::path::PathBuf::new()),
213 selected_agent: None,
214 detail_buffer: Vec::new(),
215 version: env!("CARGO_PKG_VERSION"),
216 capabilities: detect_capabilities(),
217 splash_started: Instant::now(),
218 last_refresh: Instant::now() - REFRESH_INTERVAL,
219 running: true,
220 tutorial_completed: tutorial::is_completed(),
221 mailbox_tab: MailboxTab::Inbox,
222 mailbox: MailboxBuffers::default(),
223 pending_approvals: Vec::new(),
224 selected_approval: 0,
225 approval_error: None,
226 compose_target: None,
227 compose_editor: Editor::default(),
228 compose_error: None,
229 layout: MainLayout::Triptych,
230 wall_scroll: 0,
231 selected_channel: None,
232 detail_splits: Vec::new(),
233 selected_split: 0,
234 compose_picker_open: false,
235 compose_picker_index: 0,
236 compose_attach_input_open: false,
237 compose_attach_buffer: String::new(),
238 pending_chord: None,
239 tutorial_pending_for_team: false,
240 spinner_frame: 0,
241 tutorial_step: 0,
242 }
243 }
244
245 pub fn enter_help_overlay(&mut self) {
248 self.previous_stage = self.stage;
249 self.stage = Stage::HelpOverlay;
250 }
251 pub fn close_help_overlay(&mut self) {
252 self.stage = self.previous_stage;
253 }
254 pub fn enter_tutorial(&mut self) {
255 self.previous_stage = self.stage;
256 self.stage = Stage::Tutorial;
257 self.tutorial_step = 0;
258 }
259 pub fn close_tutorial(&mut self) {
260 self.stage = self.previous_stage;
261 self.tutorial_pending_for_team = false;
262 if !self.team.root.as_os_str().is_empty() {
263 let _ = crate::onboarding::mark_completed(&self.team.root);
264 }
265 }
266 pub fn tutorial_advance(&mut self) {
267 let len = crate::onboarding::STEPS.len();
268 if len == 0 {
269 self.close_tutorial();
270 return;
271 }
272 if self.tutorial_step + 1 >= len {
273 self.close_tutorial();
274 } else {
275 self.tutorial_step += 1;
276 }
277 }
278 pub fn tutorial_back(&mut self) {
279 self.tutorial_step = self.tutorial_step.saturating_sub(1);
280 }
281
282 pub fn toggle_wall_layout(&mut self) {
283 self.layout = self.layout.toggle_wall();
284 }
285 pub fn toggle_mailbox_first_layout(&mut self) {
286 self.layout = self.layout.toggle_mailbox_first();
287 if matches!(self.layout, MainLayout::MailboxFirst) && self.selected_channel.is_none() {
290 self.selected_channel = if self.team.channels.is_empty() {
291 None
292 } else {
293 Some(0)
294 };
295 }
296 }
297 pub fn wall_scroll_up(&mut self) {
298 self.wall_scroll = self
299 .wall_scroll
300 .saturating_sub(crate::layouts::WALL_TILE_CAP);
301 }
302 pub fn wall_scroll_down(&mut self) {
303 let next = self.wall_scroll + crate::layouts::WALL_TILE_CAP;
304 if next < self.team.agents.len() {
305 self.wall_scroll = next;
306 }
307 }
308 pub fn select_next_channel(&mut self) {
309 if self.team.channels.is_empty() {
310 return;
311 }
312 self.selected_channel = Some(match self.selected_channel {
313 None => 0,
314 Some(i) => (i + 1) % self.team.channels.len(),
315 });
316 }
317 pub fn select_prev_channel(&mut self) {
318 if self.team.channels.is_empty() {
319 return;
320 }
321 self.selected_channel = Some(match self.selected_channel {
322 None | Some(0) => self.team.channels.len() - 1,
323 Some(i) => i - 1,
324 });
325 }
326
327 pub fn add_detail_split_vertical(&mut self) {
331 self.add_detail_split_with_orientation(SplitOrientation::Vertical);
332 }
333 pub fn add_detail_split_horizontal(&mut self) {
335 self.add_detail_split_with_orientation(SplitOrientation::Horizontal);
336 }
337 fn add_detail_split_with_orientation(&mut self, orientation: SplitOrientation) {
338 let Some(id) = self.selected_agent_id() else {
339 return;
340 };
341 if self.detail_splits.len() >= 4 {
342 return;
343 }
344 self.detail_splits.push((id, orientation));
345 self.selected_split = self.detail_splits.len() - 1;
346 }
347 pub fn add_detail_split(&mut self) {
352 self.add_detail_split_vertical();
353 }
354 pub fn close_focused_split(&mut self) {
355 if self.detail_splits.is_empty() {
356 return;
357 }
358 let i = self.selected_split.min(self.detail_splits.len() - 1);
359 self.detail_splits.remove(i);
360 self.selected_split = i.saturating_sub(1);
361 }
362 pub fn cycle_split_next(&mut self) {
363 if self.detail_splits.is_empty() {
364 return;
365 }
366 self.selected_split = (self.selected_split + 1) % self.detail_splits.len();
367 }
368 pub fn cycle_split_prev(&mut self) {
369 if self.detail_splits.is_empty() {
370 return;
371 }
372 self.selected_split = if self.selected_split == 0 {
373 self.detail_splits.len() - 1
374 } else {
375 self.selected_split - 1
376 };
377 }
378
379 pub fn enter_compose_broadcast_with_picker(&mut self) {
384 if self.team.channels.is_empty() {
385 self.enter_compose_broadcast();
389 return;
390 }
391 let project_id = self
392 .team
393 .channels
394 .first()
395 .map(|c| c.project_id.clone())
396 .unwrap_or_default();
397 self.previous_stage = self.stage;
398 self.stage = Stage::ComposeModal;
399 self.compose_target = Some(ComposeTarget::Broadcast {
400 channel_id: format!("{project_id}:all"),
401 project_id,
402 });
403 self.compose_editor = Editor::default();
404 self.compose_error = None;
405 self.compose_picker_open = true;
406 self.compose_picker_index = 0;
407 }
408 pub fn picker_next(&mut self) {
409 if self.team.channels.is_empty() {
410 return;
411 }
412 self.compose_picker_index = (self.compose_picker_index + 1) % self.team.channels.len();
413 }
414 pub fn picker_prev(&mut self) {
415 if self.team.channels.is_empty() {
416 return;
417 }
418 self.compose_picker_index = if self.compose_picker_index == 0 {
419 self.team.channels.len() - 1
420 } else {
421 self.compose_picker_index - 1
422 };
423 }
424 pub fn picker_confirm(&mut self) {
425 if let Some(ch) = self.team.channels.get(self.compose_picker_index) {
426 self.compose_target = Some(ComposeTarget::Broadcast {
427 channel_id: ch.id.clone(),
428 project_id: ch.project_id.clone(),
429 });
430 }
431 self.compose_picker_open = false;
432 }
433
434 pub fn open_compose_attach_input(&mut self) {
437 self.compose_attach_input_open = true;
438 self.compose_attach_buffer.clear();
439 }
440
441 pub fn confirm_compose_attach_input(&mut self) {
447 let path = self.compose_attach_buffer.trim().to_string();
448 if !path.is_empty() {
449 let marker = format!("📎 attachment: {path}");
450 if let Some(last) = self.compose_editor.lines.last_mut() {
455 if !last.is_empty() {
456 self.compose_editor.lines.push(marker);
457 } else {
458 *last = marker;
459 }
460 } else {
461 self.compose_editor.lines.push(marker);
462 }
463 self.compose_editor.cursor_row = self.compose_editor.lines.len() - 1;
466 self.compose_editor.cursor_col = self
467 .compose_editor
468 .lines
469 .last()
470 .map(|l| l.len())
471 .unwrap_or(0);
472 }
473 self.close_compose_attach_input();
474 }
475
476 pub fn close_compose_attach_input(&mut self) {
477 self.compose_attach_input_open = false;
478 self.compose_attach_buffer.clear();
479 }
480
481 pub fn cycle_mailbox_tab(&mut self) {
482 self.mailbox_tab = self.mailbox_tab.next();
483 }
484
485 pub fn cycle_mailbox_tab_back(&mut self) {
486 self.mailbox_tab = self.mailbox_tab.prev();
487 }
488
489 pub fn cycle_focus_back(&mut self) {
490 self.focused_pane = self.focused_pane.prev();
491 }
492
493 pub fn has_pending_approvals(&self) -> bool {
494 !self.pending_approvals.is_empty()
495 }
496
497 pub fn enter_approvals_modal(&mut self) {
498 if self.pending_approvals.is_empty() {
499 return;
500 }
501 self.previous_stage = self.stage;
502 self.stage = Stage::ApprovalsModal;
503 self.selected_approval = 0;
504 self.approval_error = None;
505 }
506
507 pub fn close_approvals_modal(&mut self) {
508 self.stage = self.previous_stage;
509 self.approval_error = None;
510 }
511
512 pub fn cycle_approval_next(&mut self) {
513 if self.pending_approvals.is_empty() {
514 return;
515 }
516 self.selected_approval = (self.selected_approval + 1) % self.pending_approvals.len();
517 }
518
519 pub fn cycle_approval_prev(&mut self) {
520 if self.pending_approvals.is_empty() {
521 return;
522 }
523 self.selected_approval = if self.selected_approval == 0 {
524 self.pending_approvals.len() - 1
525 } else {
526 self.selected_approval - 1
527 };
528 }
529
530 pub fn focused_approval(&self) -> Option<&Approval> {
531 self.pending_approvals.get(self.selected_approval)
532 }
533
534 pub fn replace_approvals(&mut self, approvals: Vec<Approval>) {
540 self.pending_approvals = approvals;
541 if self.pending_approvals.is_empty() {
542 if matches!(self.stage, Stage::ApprovalsModal) {
543 self.close_approvals_modal();
544 }
545 self.selected_approval = 0;
546 } else if self.selected_approval >= self.pending_approvals.len() {
547 self.selected_approval = self.pending_approvals.len() - 1;
548 }
549 }
550
551 pub fn apply_decision<D: ApprovalDecider>(&mut self, decider: &D, kind: Decision, note: &str) {
558 let Some(approval) = self.focused_approval().cloned() else {
559 return;
560 };
561 match decider.decide(&self.team.root, approval.id, kind, note) {
562 Ok(()) => {
563 self.pending_approvals.retain(|a| a.id != approval.id);
564 self.approval_error = None;
565 if self.pending_approvals.is_empty() {
566 self.close_approvals_modal();
567 } else if self.selected_approval >= self.pending_approvals.len() {
568 self.selected_approval = self.pending_approvals.len() - 1;
569 }
570 }
571 Err(err) => {
572 self.approval_error = Some(err.to_string());
573 }
574 }
575 }
576
577 pub fn enter_compose_dm_for_focused(&mut self) {
580 let Some(info) = self
581 .selected_agent
582 .and_then(|i| self.team.agents.get(i))
583 .cloned()
584 else {
585 return;
586 };
587 self.previous_stage = self.stage;
588 self.stage = Stage::ComposeModal;
589 self.compose_target = Some(ComposeTarget::Dm {
590 agent_id: info.id.clone(),
591 project_id: info.project.clone(),
592 });
593 self.compose_editor = Editor::default();
594 self.compose_error = None;
595 }
596
597 pub fn enter_compose_broadcast(&mut self) {
605 let project_id = self
606 .selected_agent
607 .and_then(|i| self.team.agents.get(i))
608 .map(|a| a.project.clone())
609 .or_else(|| self.team.agents.first().map(|a| a.project.clone()));
610 let Some(project_id) = project_id else {
611 return;
612 };
613 let channel_id = format!("{project_id}:all");
614 self.previous_stage = self.stage;
615 self.stage = Stage::ComposeModal;
616 self.compose_target = Some(ComposeTarget::Broadcast {
617 channel_id,
618 project_id,
619 });
620 self.compose_editor = Editor::default();
621 self.compose_error = None;
622 }
623
624 pub fn close_compose_modal(&mut self) {
625 self.stage = self.previous_stage;
626 self.compose_target = None;
627 self.compose_editor = Editor::default();
628 self.compose_error = None;
629 self.compose_attach_input_open = false;
632 self.compose_attach_buffer.clear();
633 }
634
635 pub fn apply_send<S: MessageSender, M: MailboxSource>(
641 &mut self,
642 sender: &S,
643 mailbox_source: &M,
644 ) {
645 let Some(target) = self.compose_target.clone() else {
646 return;
647 };
648 let body = self.compose_editor.body();
649 if body.is_empty() {
650 self.compose_error = Some("body is empty".into());
651 return;
652 }
653 let result = match &target {
654 ComposeTarget::Dm { agent_id, .. } => sender.send_dm(&self.team.root, agent_id, &body),
655 ComposeTarget::Broadcast { channel_id, .. } => {
656 sender.broadcast(&self.team.root, channel_id, &body)
657 }
658 };
659 match result {
660 Ok(()) => {
661 self.close_compose_modal();
662 refresh_mailbox(self, mailbox_source);
665 }
666 Err(err) => {
667 self.compose_error = Some(err.to_string());
668 }
669 }
670 }
671
672 pub fn dismiss_splash(&mut self) {
673 if matches!(self.stage, Stage::Splash) {
674 self.stage = Stage::Triptych;
675 self.previous_stage = Stage::Triptych;
676 }
677 }
678
679 pub fn cycle_focus(&mut self) {
680 self.focused_pane = self.focused_pane.next();
681 }
682
683 pub fn select_prev(&mut self) {
689 if self.team.agents.is_empty() {
690 self.selected_agent = None;
691 return;
692 }
693 let prior = self.selected_agent_id();
694 self.selected_agent = Some(match self.selected_agent {
695 None | Some(0) => self.team.agents.len() - 1,
696 Some(i) => i - 1,
697 });
698 if prior != self.selected_agent_id() {
699 self.mailbox.reset();
700 }
701 }
702
703 pub fn select_next(&mut self) {
706 if self.team.agents.is_empty() {
707 self.selected_agent = None;
708 return;
709 }
710 let prior = self.selected_agent_id();
711 self.selected_agent = Some(match self.selected_agent {
712 None => 0,
713 Some(i) => (i + 1) % self.team.agents.len(),
714 });
715 if prior != self.selected_agent_id() {
716 self.mailbox.reset();
717 }
718 }
719
720 pub fn selected_agent_id(&self) -> Option<String> {
722 self.selected_agent
723 .and_then(|i| self.team.agents.get(i))
724 .map(|a| a.id.clone())
725 }
726
727 pub fn enter_quit_confirm(&mut self) {
728 self.previous_stage = self.stage;
729 self.stage = Stage::QuitConfirm;
730 }
731
732 pub fn cancel_quit(&mut self) {
733 self.stage = self.previous_stage;
734 }
735
736 pub fn confirm_quit(&mut self) {
737 self.running = false;
738 }
739
740 pub fn replace_team(&mut self, team: TeamSnapshot) {
747 let prior_id = self.selected_agent_id();
748 self.team = team;
749 self.selected_agent = match (prior_id.clone(), self.team.agents.is_empty()) {
750 (_, true) => None,
751 (Some(id), false) => self.team.agents.iter().position(|a| a.id == id).or(Some(0)),
752 (None, false) => Some(0),
753 };
754 if prior_id != self.selected_agent_id() {
755 self.mailbox.reset();
756 }
757 }
758
759 pub fn focused_session(&self) -> Option<&str> {
762 self.selected_agent
763 .and_then(|i| self.team.agents.get(i))
764 .map(|a| a.tmux_session.as_str())
765 }
766
767 pub fn stream_target_session(&self) -> Option<String> {
774 if self.detail_splits.is_empty() || self.selected_split == 0 {
775 return self.focused_session().map(|s| s.to_string());
776 }
777 let split_idx = self.selected_split - 1;
778 let agent_id = self.detail_splits.get(split_idx).map(|(id, _)| id)?;
779 self.team
780 .agents
781 .iter()
782 .find(|a| &a.id == agent_id)
783 .map(|a| a.tmux_session.clone())
784 }
785
786 pub fn enter_stream_keys(&mut self) {
791 if self.stream_target_session().is_none() {
792 return;
793 }
794 self.previous_stage = self.stage;
795 self.stage = Stage::StreamKeys;
796 }
797
798 pub fn exit_stream_keys(&mut self) {
802 self.stage = self.previous_stage;
803 }
804
805 pub fn set_detail_buffer(&mut self, lines: Vec<String>) {
807 let len = lines.len();
808 let start = len.saturating_sub(MAX_DETAIL_LINES);
809 self.detail_buffer = lines[start..].to_vec();
810 }
811}
812
813impl Default for App {
814 fn default() -> Self {
815 Self::new()
816 }
817}
818
819pub fn refresh<P: PaneSource, M: MailboxSource, A: ApprovalSource>(
824 app: &mut App,
825 pane_source: &P,
826 mailbox_source: &M,
827 approval_source: &A,
828) {
829 if let Ok(Some(snapshot)) = TeamSnapshot::discover_and_load() {
830 app.replace_team(snapshot);
831 }
832 if let Some(session) = app.focused_session().map(|s| s.to_string()) {
833 if let Ok(lines) = pane_source.capture(&session) {
834 app.set_detail_buffer(lines);
835 }
836 } else {
837 app.detail_buffer.clear();
838 }
839 refresh_mailbox(app, mailbox_source);
840 refresh_approvals(app, approval_source);
841 app.last_refresh = Instant::now();
842}
843
844pub fn refresh_approvals<A: ApprovalSource>(app: &mut App, approval_source: &A) {
850 let approvals = approval_source.pending().unwrap_or_default();
851 app.replace_approvals(approvals);
852}
853
854pub fn refresh_mailbox<M: MailboxSource>(app: &mut App, mailbox_source: &M) {
859 let Some(agent_id) = app.selected_agent_id() else {
860 return;
863 };
864 let project_id = app
865 .selected_agent
866 .and_then(|i| app.team.agents.get(i))
867 .map(|a| a.project.clone())
868 .unwrap_or_default();
869 if let Ok(batch) = mailbox_source.inbox(&agent_id, app.mailbox.inbox_after) {
870 app.mailbox.extend(MailboxTab::Inbox, batch);
871 }
872 if let Ok(batch) = mailbox_source.sent(&agent_id, app.mailbox.sent_after) {
873 app.mailbox.extend(MailboxTab::Sent, batch);
874 }
875 if let Ok(batch) = mailbox_source.channel_feed(&agent_id, app.mailbox.channel_after) {
876 app.mailbox.extend(MailboxTab::Channel, batch);
877 }
878 if let Ok(batch) = mailbox_source.wire(&project_id, app.mailbox.wire_after) {
879 app.mailbox.extend(MailboxTab::Wire, batch);
880 }
881}
882
883pub fn run<B: Backend>(terminal: &mut Terminal<B>) -> Result<()> {
884 let mut app = App::new();
885 let pane_source = TmuxPaneSource;
886 let decider = CliApprovalDecider;
887 let sender = CliMessageSender;
888 let key_sender = TmuxKeySender;
889 refresh_with_default_sources(&mut app, &pane_source);
892 let mut watch = Watch::try_new(&app.team.root.join("state"));
893 while app.running {
894 terminal.draw(|f| draw(f, &app))?;
895 if event::poll(POLL_INTERVAL)? {
896 let db_path = app.team.root.join("state/mailbox.db");
900 let mailbox_source = BrokerMailboxSource::new(db_path);
901 handle_event(
902 &mut app,
903 event::read()?,
904 &decider,
905 &sender,
906 &mailbox_source,
907 &key_sender,
908 );
909 }
910 if matches!(app.stage, Stage::Splash) && app.splash_started.elapsed() >= SPLASH_AUTO_DISMISS
911 {
912 app.dismiss_splash();
913 }
914 let dirty = watch.take_dirty();
921 if dirty || app.last_refresh.elapsed() >= REFRESH_INTERVAL {
922 let prior_root = app.team.root.clone();
923 refresh_with_default_sources(&mut app, &pane_source);
924 if app.team.root != prior_root {
927 watch = Watch::try_new(&app.team.root.join("state"));
928 }
929 }
930 }
931 Ok(())
932}
933
934fn refresh_with_default_sources<P: PaneSource>(app: &mut App, pane_source: &P) {
939 if let Ok(Some(snapshot)) = TeamSnapshot::discover_and_load() {
940 app.replace_team(snapshot);
941 }
942 let db_path = app.team.root.join("state/mailbox.db");
943 let mailbox_source = BrokerMailboxSource::new(db_path.clone());
944 let approval_source = BrokerApprovalSource::new(db_path);
945 if let Some(session) = app.focused_session().map(|s| s.to_string()) {
946 if let Ok(lines) = pane_source.capture(&session) {
947 app.set_detail_buffer(lines);
948 }
949 } else {
950 app.detail_buffer.clear();
951 }
952 refresh_mailbox(app, &mailbox_source);
953 refresh_approvals(app, &approval_source);
954 app.last_refresh = Instant::now();
955}
956
957pub fn draw(f: &mut Frame<'_>, app: &App) {
958 let area = f.area();
959 match app.stage {
960 Stage::Splash => splash::draw(f, app),
961 Stage::Triptych => draw_main(f, area, app),
962 Stage::StreamKeys => draw_main(f, area, app),
967 Stage::QuitConfirm => {
968 draw_main(f, area, app);
969 draw_quit_confirm(f, area);
970 }
971 Stage::ApprovalsModal => {
972 draw_main(f, area, app);
973 draw_approvals_modal(f, area, app);
974 }
975 Stage::ComposeModal => {
976 draw_main(f, area, app);
977 draw_compose_modal(f, area, app);
978 }
979 Stage::HelpOverlay => {
980 draw_main(f, area, app);
981 let buf = f.buffer_mut();
982 render_help_overlay(area, buf, app);
983 }
984 Stage::Tutorial => {
985 draw_main(f, area, app);
986 let buf = f.buffer_mut();
987 render_tutorial(area, buf, app);
988 }
989 }
990}
991
992fn render_help_overlay(area: Rect, buf: &mut Buffer, app: &App) {
993 let popup_w = 70u16.min(area.width.saturating_sub(4));
994 let popup_h = 24u16.min(area.height.saturating_sub(2));
995 let popup = centered_rect(popup_w, popup_h, area);
996 Clear.render(popup, buf);
997 let block = Block::default()
998 .title("help · ? to close")
999 .borders(Borders::ALL)
1000 .border_style(Style::default().fg(app.capabilities.accent()));
1001 let inner = block.inner(popup);
1002 block.render(popup, buf);
1003 let muted = Style::default().fg(app.capabilities.muted());
1004 let bold = Style::default().add_modifier(Modifier::BOLD);
1005 let mut lines: Vec<ratatui::text::Line<'_>> = Vec::new();
1006 for group in crate::help::ALL_GROUPS {
1007 lines.push(ratatui::text::Line::styled(group.title, bold));
1008 for b in group.bindings {
1009 lines.push(ratatui::text::Line::raw(format!(
1010 " {:<22} {}",
1011 b.chord, b.description
1012 )));
1013 }
1014 lines.push(ratatui::text::Line::styled("", muted));
1015 }
1016 Paragraph::new(lines).render(inner, buf);
1017}
1018
1019fn render_tutorial(area: Rect, buf: &mut Buffer, app: &App) {
1020 let popup_w = 64u16.min(area.width.saturating_sub(4));
1021 let popup_h = 14u16.min(area.height.saturating_sub(2));
1022 let popup = centered_rect(popup_w, popup_h, area);
1023 Clear.render(popup, buf);
1024 let total = crate::onboarding::STEPS.len();
1025 let i = app.tutorial_step.min(total.saturating_sub(1));
1026 let step = &crate::onboarding::STEPS[i];
1027 let block = Block::default()
1028 .title(format!("tutorial · {}/{total}", i + 1))
1029 .borders(Borders::ALL)
1030 .border_style(Style::default().fg(app.capabilities.accent()));
1031 let inner = block.inner(popup);
1032 block.render(popup, buf);
1033 let muted = Style::default().fg(app.capabilities.muted());
1034 let lines = vec![
1035 ratatui::text::Line::styled(step.heading, Style::default().add_modifier(Modifier::BOLD)),
1036 ratatui::text::Line::raw(""),
1037 ratatui::text::Line::raw(step.body),
1038 ratatui::text::Line::raw(""),
1039 ratatui::text::Line::styled("any key next · k / ↑ / p back · Esc skip", muted),
1040 ];
1041 Paragraph::new(lines)
1047 .wrap(ratatui::widgets::Wrap { trim: true })
1048 .render(inner, buf);
1049}
1050
1051fn draw_main(f: &mut Frame<'_>, area: Rect, app: &App) {
1052 let chunks = Layout::default()
1053 .direction(Direction::Vertical)
1054 .constraints([Constraint::Min(3), Constraint::Length(1)])
1055 .split(area);
1056 let buf = f.buffer_mut();
1057 match app.layout {
1058 crate::triptych::MainLayout::Triptych => {
1059 triptych::Triptych { app }.render(chunks[0], buf);
1060 }
1061 crate::triptych::MainLayout::Wall => {
1062 layouts::Wall { app }.render(chunks[0], buf);
1063 }
1064 crate::triptych::MainLayout::MailboxFirst => {
1065 layouts::MailboxFirst { app }.render(chunks[0], buf);
1066 }
1067 }
1068 statusline::Statusline { app }.render(chunks[1], buf);
1069}
1070
1071fn draw_approvals_modal(f: &mut Frame<'_>, area: Rect, app: &App) {
1072 let buf = f.buffer_mut();
1073 render_approvals_modal(area, buf, app);
1074}
1075
1076fn draw_compose_modal(f: &mut Frame<'_>, area: Rect, app: &App) {
1077 let buf = f.buffer_mut();
1078 render_compose_modal(area, buf, app);
1079}
1080
1081fn render_compose_picker_body(inner: Rect, buf: &mut Buffer, app: &App) {
1082 let muted = Style::default().fg(app.capabilities.muted());
1083 let chunks = Layout::default()
1084 .direction(Direction::Vertical)
1085 .constraints([
1086 Constraint::Min(1),
1087 Constraint::Length(1),
1088 Constraint::Length(1),
1089 ])
1090 .split(inner);
1091 let lines: Vec<ratatui::text::Line<'_>> = if app.team.channels.is_empty() {
1092 vec![ratatui::text::Line::styled(
1093 "(no channels declared in team-compose)",
1094 muted,
1095 )]
1096 } else {
1097 app.team
1098 .channels
1099 .iter()
1100 .enumerate()
1101 .map(|(i, ch)| {
1102 let label = format!(" #{} ({})", ch.name, ch.project_id);
1103 let style = if i == app.compose_picker_index {
1104 Style::default()
1105 .fg(app.capabilities.accent())
1106 .add_modifier(Modifier::REVERSED)
1107 } else {
1108 Style::default()
1109 };
1110 ratatui::text::Line::styled(label, style)
1111 })
1112 .collect()
1113 };
1114 Paragraph::new(lines).render(chunks[0], buf);
1115 Paragraph::new("pick a channel to broadcast to")
1116 .style(muted)
1117 .render(chunks[1], buf);
1118 Paragraph::new("Enter pick · j/k navigate · Esc cancel")
1119 .style(muted)
1120 .render(chunks[2], buf);
1121}
1122
1123fn render_compose_modal(area: Rect, buf: &mut Buffer, app: &App) {
1124 let popup_w = 80u16.min(area.width.saturating_sub(4));
1125 let popup_h = 16u16.min(area.height.saturating_sub(2));
1126 let popup = centered_rect(popup_w, popup_h, area);
1127 Clear.render(popup, buf);
1128 let title = app
1129 .compose_target
1130 .as_ref()
1131 .map(|t| t.title(&app.team))
1132 .unwrap_or_else(|| "→ ?".into());
1133 let block = Block::default()
1134 .title(title)
1135 .borders(Borders::ALL)
1136 .border_style(Style::default().fg(app.capabilities.accent()));
1137 let inner = block.inner(popup);
1138 block.render(popup, buf);
1139
1140 if inner.height < 3 {
1141 return;
1142 }
1143 if app.compose_picker_open {
1147 render_compose_picker_body(inner, buf, app);
1148 return;
1149 }
1150 if app.compose_attach_input_open {
1151 render_compose_attach_input(inner, buf, app);
1152 return;
1153 }
1154 let chunks = Layout::default()
1157 .direction(Direction::Vertical)
1158 .constraints([
1159 Constraint::Min(1), Constraint::Length(1), Constraint::Length(1), ])
1163 .split(inner);
1164
1165 let muted = Style::default().fg(app.capabilities.muted());
1170 let body_lines: Vec<ratatui::text::Line<'_>> = app
1171 .compose_editor
1172 .lines
1173 .iter()
1174 .enumerate()
1175 .map(|(row, line)| {
1176 if row == app.compose_editor.cursor_row
1177 && app.compose_editor.mode == crate::compose::VimMode::Insert
1178 {
1179 let col = app.compose_editor.cursor_col.min(line.len());
1180 let (head, tail) = line.split_at(col);
1181 ratatui::text::Line::from(vec![
1182 ratatui::text::Span::raw(head.to_string()),
1183 ratatui::text::Span::styled(
1184 "▏",
1185 Style::default().fg(app.capabilities.accent()),
1186 ),
1187 ratatui::text::Span::raw(tail.to_string()),
1188 ])
1189 } else {
1190 ratatui::text::Line::raw(line.clone())
1191 }
1192 })
1193 .collect();
1194 Paragraph::new(body_lines).render(chunks[0], buf);
1195
1196 let error_line = match (&app.compose_error, app.compose_editor.mode) {
1197 (Some(e), _) => format!("error: {e}"),
1198 (None, crate::compose::VimMode::Ex) => format!(":{}", app.compose_editor.ex_buffer),
1199 (None, crate::compose::VimMode::Normal) => "-- NORMAL --".into(),
1200 (None, crate::compose::VimMode::Insert) => "-- INSERT --".into(),
1201 };
1202 let style = if app.compose_error.is_some() {
1203 Style::default().fg(app.capabilities.accent())
1204 } else {
1205 muted
1206 };
1207 Paragraph::new(error_line)
1208 .style(style)
1209 .render(chunks[1], buf);
1210
1211 Paragraph::new("Alt+Enter send · Esc Esc cancel · Tab attach")
1212 .style(muted)
1213 .render(chunks[2], buf);
1214}
1215
1216fn render_compose_attach_input(inner: Rect, buf: &mut Buffer, app: &App) {
1221 let muted = Style::default().fg(app.capabilities.muted());
1222 let chunks = Layout::default()
1223 .direction(Direction::Vertical)
1224 .constraints([
1225 Constraint::Min(1),
1226 Constraint::Length(1),
1227 Constraint::Length(1),
1228 ])
1229 .split(inner);
1230 let line = ratatui::text::Line::from(vec![
1231 ratatui::text::Span::raw(format!("path: {}", app.compose_attach_buffer)),
1232 ratatui::text::Span::styled("▏", Style::default().fg(app.capabilities.accent())),
1233 ]);
1234 Paragraph::new(line).render(chunks[0], buf);
1235 Paragraph::new("type or paste an absolute path; the agent reads it via the broker")
1236 .style(muted)
1237 .render(chunks[1], buf);
1238 Paragraph::new("Enter confirm · Esc cancel")
1239 .style(muted)
1240 .render(chunks[2], buf);
1241}
1242
1243fn render_approvals_modal(area: Rect, buf: &mut Buffer, app: &App) {
1244 let popup_w = 80u16.min(area.width.saturating_sub(4));
1245 let popup_h = 18u16.min(area.height.saturating_sub(2));
1246 let popup = centered_rect(popup_w, popup_h, area);
1247 Clear.render(popup, buf);
1248 let n = app.pending_approvals.len();
1249 let i = app.selected_approval.min(n.saturating_sub(1));
1250 let title = format!("approvals · {}/{n}", i + 1);
1251 let block = Block::default()
1252 .title(title)
1253 .borders(Borders::ALL)
1254 .border_style(Style::default().fg(app.capabilities.accent()));
1255 let inner = block.inner(popup);
1256 block.render(popup, buf);
1257
1258 let muted = Style::default().fg(app.capabilities.muted());
1259 let bold = Style::default().add_modifier(Modifier::BOLD);
1260
1261 let Some(a) = app.focused_approval() else {
1262 Paragraph::new("(no pending approvals)")
1263 .style(muted)
1264 .alignment(Alignment::Center)
1265 .render(inner, buf);
1266 return;
1267 };
1268
1269 let mut lines: Vec<ratatui::text::Line<'_>> = vec![
1270 ratatui::text::Line::styled(format!("#{} {}", a.id, a.action), bold),
1271 ratatui::text::Line::styled(
1272 format!("from: {}", crate::data::agent_label(&app.team, &a.agent_id)),
1273 muted,
1274 ),
1275 ratatui::text::Line::raw(""),
1276 ratatui::text::Line::raw(a.summary.clone()),
1277 ];
1278 if !a.payload_json.is_empty() && a.payload_json != "{}" {
1279 lines.push(ratatui::text::Line::raw(""));
1280 lines.push(ratatui::text::Line::styled("payload:", muted));
1281 for chunk in a.payload_json.lines().take(4) {
1282 lines.push(ratatui::text::Line::raw(chunk.to_string()));
1283 }
1284 }
1285 if let Some(err) = &app.approval_error {
1286 lines.push(ratatui::text::Line::raw(""));
1287 lines.push(ratatui::text::Line::styled(
1288 format!("error: {err}"),
1289 Style::default().fg(app.capabilities.accent()),
1290 ));
1291 }
1292 lines.push(ratatui::text::Line::raw(""));
1293 lines.push(ratatui::text::Line::styled(
1294 "[y] approve · [Shift-N] deny · [j/k] cycle · [Esc] close",
1295 muted,
1296 ));
1297 Paragraph::new(lines).render(inner, buf);
1298}
1299
1300fn draw_quit_confirm(f: &mut Frame<'_>, area: Rect) {
1301 let popup_w = 36u16.min(area.width.saturating_sub(2));
1302 let popup_h = 5u16.min(area.height.saturating_sub(2));
1303 let popup = centered_rect(popup_w, popup_h, area);
1304 let buf = f.buffer_mut();
1305 Clear.render(popup, buf);
1306 Paragraph::new("Quit teamctl-ui? [y / n]")
1307 .alignment(Alignment::Center)
1308 .block(Block::default().borders(Borders::ALL).title("confirm"))
1309 .render(popup, buf);
1310}
1311
1312fn centered_rect(w: u16, h: u16, area: Rect) -> Rect {
1313 let x = area.x + area.width.saturating_sub(w) / 2;
1314 let y = area.y + area.height.saturating_sub(h) / 2;
1315 Rect {
1316 x,
1317 y,
1318 width: w,
1319 height: h,
1320 }
1321}
1322
1323pub fn handle_event<D: ApprovalDecider, S: MessageSender, M: MailboxSource, K: KeySender>(
1324 app: &mut App,
1325 ev: Event,
1326 decider: &D,
1327 sender: &S,
1328 mailbox_source: &M,
1329 key_sender: &K,
1330) {
1331 use crossterm::event::KeyModifiers;
1332 match ev {
1333 Event::Key(k) if k.kind == KeyEventKind::Press => match app.stage {
1334 Stage::Splash => app.dismiss_splash(),
1335 Stage::Triptych => match k.code {
1336 KeyCode::Char('q') if app.pending_chord == Some(KeyCode::Char('w')) => {
1341 app.pending_chord = None;
1342 app.close_focused_split();
1343 }
1344 KeyCode::Char('o') if app.pending_chord == Some(KeyCode::Char('w')) => {
1345 app.pending_chord = None;
1346 if !app.detail_splits.is_empty() {
1347 let keep = app.selected_split.min(app.detail_splits.len() - 1);
1348 let kept = app.detail_splits.remove(keep);
1349 app.detail_splits.clear();
1350 app.detail_splits.push(kept);
1351 app.selected_split = 0;
1352 }
1353 }
1354 KeyCode::Char('q') if k.modifiers.is_empty() => app.enter_quit_confirm(),
1355 KeyCode::Char('a') => app.enter_approvals_modal(),
1359 KeyCode::Char('@') => app.enter_compose_dm_for_focused(),
1364 KeyCode::Char('!') => app.enter_compose_broadcast_with_picker(),
1365 KeyCode::Char('w') | KeyCode::Char('W')
1375 if k.modifiers.contains(KeyModifiers::CONTROL)
1376 && !app.detail_splits.is_empty() =>
1377 {
1378 app.pending_chord = Some(KeyCode::Char('w'))
1379 }
1380 KeyCode::Char('w') | KeyCode::Char('W')
1385 if k.modifiers.contains(KeyModifiers::CONTROL) =>
1386 {
1387 app.toggle_wall_layout()
1388 }
1389 KeyCode::Char('m') | KeyCode::Char('M')
1390 if k.modifiers.contains(KeyModifiers::CONTROL) =>
1391 {
1392 app.toggle_mailbox_first_layout()
1393 }
1394 KeyCode::Char('|') if k.modifiers.contains(KeyModifiers::CONTROL) => {
1398 app.add_detail_split_vertical()
1399 }
1400 KeyCode::Char('-') if k.modifiers.contains(KeyModifiers::CONTROL) => {
1401 app.add_detail_split_horizontal()
1402 }
1403 KeyCode::Char('h')
1408 | KeyCode::Char('H')
1409 | KeyCode::Char('k')
1410 | KeyCode::Char('K')
1411 if k.modifiers.contains(KeyModifiers::CONTROL) =>
1412 {
1413 app.cycle_split_prev()
1414 }
1415 KeyCode::Char('l')
1416 | KeyCode::Char('L')
1417 | KeyCode::Char('j')
1418 | KeyCode::Char('J')
1419 if k.modifiers.contains(KeyModifiers::CONTROL) =>
1420 {
1421 app.cycle_split_next()
1422 }
1423 KeyCode::Char('q') | KeyCode::Char('Q')
1428 if k.modifiers.contains(KeyModifiers::CONTROL) =>
1429 {
1430 app.close_focused_split()
1431 }
1432 KeyCode::Char('e') | KeyCode::Char('E')
1440 if k.modifiers.contains(KeyModifiers::CONTROL)
1441 && app.focused_pane == Pane::Detail =>
1442 {
1443 app.enter_stream_keys()
1444 }
1445 KeyCode::Char('?')
1453 if k.modifiers.is_empty() || k.modifiers == KeyModifiers::SHIFT =>
1454 {
1455 app.enter_help_overlay()
1456 }
1457 KeyCode::Char('t') if k.modifiers.is_empty() => app.enter_tutorial(),
1458 KeyCode::BackTab => app.cycle_focus_back(),
1462 KeyCode::Tab if k.modifiers.contains(KeyModifiers::SHIFT) => app.cycle_focus_back(),
1463 KeyCode::Tab => app.cycle_focus(),
1471 KeyCode::Right if app.focused_pane == Pane::Mailbox => app.cycle_mailbox_tab(),
1478 KeyCode::Left if app.focused_pane == Pane::Mailbox => app.cycle_mailbox_tab_back(),
1479 KeyCode::Up | KeyCode::Char('k') if matches!(app.layout, MainLayout::Wall) => {
1484 app.wall_scroll_up()
1485 }
1486 KeyCode::Down | KeyCode::Char('j') if matches!(app.layout, MainLayout::Wall) => {
1487 app.wall_scroll_down()
1488 }
1489 KeyCode::Up | KeyCode::Char('k')
1492 if matches!(app.layout, MainLayout::MailboxFirst) =>
1493 {
1494 app.select_prev_channel()
1495 }
1496 KeyCode::Down | KeyCode::Char('j')
1497 if matches!(app.layout, MainLayout::MailboxFirst) =>
1498 {
1499 app.select_next_channel()
1500 }
1501 KeyCode::Up | KeyCode::Char('k') if app.focused_pane == Pane::Roster => {
1505 app.select_prev()
1506 }
1507 KeyCode::Down | KeyCode::Char('j') if app.focused_pane == Pane::Roster => {
1508 app.select_next()
1509 }
1510 _ => {}
1511 },
1512 Stage::QuitConfirm => match k.code {
1513 KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => app.confirm_quit(),
1514 KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => app.cancel_quit(),
1515 _ => {}
1516 },
1517 Stage::ApprovalsModal => match k.code {
1518 KeyCode::Char('y') | KeyCode::Char('Y') => {
1527 app.apply_decision(decider, Decision::Approve, "")
1528 }
1529 KeyCode::Char('N') => app.apply_decision(decider, Decision::Deny, ""),
1530 KeyCode::Char('j') | KeyCode::Down => app.cycle_approval_next(),
1531 KeyCode::Char('k') | KeyCode::Up => app.cycle_approval_prev(),
1532 KeyCode::Esc | KeyCode::Char('q') => app.close_approvals_modal(),
1533 _ => {}
1534 },
1535 Stage::ComposeModal => {
1536 if app.compose_picker_open {
1540 match k.code {
1541 KeyCode::Down | KeyCode::Char('j') => app.picker_next(),
1542 KeyCode::Up | KeyCode::Char('k') => app.picker_prev(),
1543 KeyCode::Enter => app.picker_confirm(),
1544 KeyCode::Esc => {
1553 app.compose_picker_open = false;
1554 app.compose_picker_index = 0;
1555 }
1556 _ => {}
1557 }
1558 } else if app.compose_attach_input_open {
1559 match k.code {
1566 KeyCode::Char(c) => app.compose_attach_buffer.push(c),
1567 KeyCode::Backspace => {
1568 app.compose_attach_buffer.pop();
1569 }
1570 KeyCode::Enter => app.confirm_compose_attach_input(),
1571 KeyCode::Esc => app.close_compose_attach_input(),
1572 _ => {}
1573 }
1574 } else if k.code == KeyCode::Tab {
1575 app.open_compose_attach_input();
1580 } else {
1581 match app.compose_editor.apply_key(k) {
1584 EditorAction::Continue => {}
1585 EditorAction::Send => app.apply_send(sender, mailbox_source),
1586 EditorAction::Cancel => app.close_compose_modal(),
1587 }
1588 }
1589 }
1590 Stage::HelpOverlay => match k.code {
1591 KeyCode::Esc | KeyCode::Char('?') | KeyCode::Char('q') => app.close_help_overlay(),
1592 _ => {}
1593 },
1594 Stage::Tutorial => match k.code {
1595 KeyCode::Esc => app.close_tutorial(),
1596 KeyCode::Char('k') | KeyCode::Up | KeyCode::Char('p') => app.tutorial_back(),
1597 _ => app.tutorial_advance(),
1598 },
1599 Stage::StreamKeys => {
1607 if matches!(k.code, KeyCode::Esc) {
1608 app.exit_stream_keys();
1609 } else if let Some(session) = app.stream_target_session() {
1610 if let Some(encoded) = encode_key(k) {
1611 let _ = key_sender.send(&session, &encoded);
1616 }
1617 } else {
1618 app.exit_stream_keys();
1623 }
1624 }
1625 },
1626 Event::Resize(_, _) => {
1627 }
1629 Event::Mouse(m) if matches!(app.stage, Stage::Triptych) => {
1640 use crossterm::event::MouseEventKind;
1641 let direction = match m.kind {
1642 MouseEventKind::ScrollUp => Some(ScrollDirection::Up),
1643 MouseEventKind::ScrollDown => Some(ScrollDirection::Down),
1644 _ => None,
1645 };
1646 if let Some(dir) = direction {
1647 match app.focused_pane {
1648 Pane::Detail => {
1649 if let Some(session) = app.focused_session().map(|s| s.to_string()) {
1650 let _ = key_sender.scroll(&session, dir);
1655 }
1656 }
1657 Pane::Roster => match dir {
1658 ScrollDirection::Up => app.select_prev(),
1659 ScrollDirection::Down => app.select_next(),
1660 },
1661 Pane::Mailbox => {
1662 }
1665 }
1666 }
1667 }
1668 _ => {}
1669 }
1670}
1671
1672pub fn render_to_buffer(app: &App, width: u16, height: u16) -> Buffer {
1676 let area = Rect::new(0, 0, width, height);
1677 let mut buf = Buffer::empty(area);
1678 match app.stage {
1679 Stage::Splash => splash::Splash { app }.render(area, &mut buf),
1680 Stage::Triptych => render_main(app, area, &mut buf),
1681 Stage::StreamKeys => render_main(app, area, &mut buf),
1682 Stage::QuitConfirm => {
1683 render_main(app, area, &mut buf);
1684 render_quit_confirm(area, &mut buf);
1685 }
1686 Stage::ApprovalsModal => {
1687 render_main(app, area, &mut buf);
1688 render_approvals_modal(area, &mut buf, app);
1689 }
1690 Stage::ComposeModal => {
1691 render_main(app, area, &mut buf);
1692 render_compose_modal(area, &mut buf, app);
1693 }
1694 Stage::HelpOverlay => {
1695 render_main(app, area, &mut buf);
1696 render_help_overlay(area, &mut buf, app);
1697 }
1698 Stage::Tutorial => {
1699 render_main(app, area, &mut buf);
1700 render_tutorial(area, &mut buf, app);
1701 }
1702 }
1703 buf
1704}
1705
1706fn render_main(app: &App, area: Rect, buf: &mut Buffer) {
1707 let chunks = Layout::default()
1708 .direction(Direction::Vertical)
1709 .constraints([Constraint::Min(3), Constraint::Length(1)])
1710 .split(area);
1711 match app.layout {
1712 crate::triptych::MainLayout::Triptych => {
1713 triptych::Triptych { app }.render(chunks[0], buf);
1714 }
1715 crate::triptych::MainLayout::Wall => {
1716 layouts::Wall { app }.render(chunks[0], buf);
1717 }
1718 crate::triptych::MainLayout::MailboxFirst => {
1719 layouts::MailboxFirst { app }.render(chunks[0], buf);
1720 }
1721 }
1722 statusline::Statusline { app }.render(chunks[1], buf);
1723}
1724
1725fn render_quit_confirm(area: Rect, buf: &mut Buffer) {
1726 let popup_w = 36u16.min(area.width.saturating_sub(2));
1727 let popup_h = 5u16.min(area.height.saturating_sub(2));
1728 let popup = centered_rect(popup_w, popup_h, area);
1729 Clear.render(popup, buf);
1730 Paragraph::new("Quit teamctl-ui? [y / n]")
1731 .alignment(Alignment::Center)
1732 .block(Block::default().borders(Borders::ALL).title("confirm"))
1733 .render(popup, buf);
1734}
1735
1736#[cfg(test)]
1737mod tests {
1738 use super::*;
1739 use crate::data::AgentInfo;
1740 use crossterm::event::{KeyEvent, KeyEventState, KeyModifiers};
1741 use team_core::supervisor::AgentState;
1742
1743 fn key(code: KeyCode) -> Event {
1744 Event::Key(KeyEvent {
1745 code,
1746 modifiers: KeyModifiers::NONE,
1747 kind: KeyEventKind::Press,
1748 state: KeyEventState::NONE,
1749 })
1750 }
1751
1752 fn key_with(code: KeyCode, modifiers: KeyModifiers) -> Event {
1753 Event::Key(KeyEvent {
1754 code,
1755 modifiers,
1756 kind: KeyEventKind::Press,
1757 state: KeyEventState::NONE,
1758 })
1759 }
1760
1761 struct NoopDecider;
1763 impl crate::approvals::ApprovalDecider for NoopDecider {
1764 fn decide(
1765 &self,
1766 _root: &std::path::Path,
1767 _id: i64,
1768 _kind: crate::approvals::Decision,
1769 _note: &str,
1770 ) -> anyhow::Result<()> {
1771 Ok(())
1772 }
1773 }
1774
1775 struct NoopSender;
1777 impl crate::compose::MessageSender for NoopSender {
1778 fn send_dm(
1779 &self,
1780 _root: &std::path::Path,
1781 _agent: &str,
1782 _body: &str,
1783 ) -> anyhow::Result<()> {
1784 Ok(())
1785 }
1786 fn broadcast(
1787 &self,
1788 _root: &std::path::Path,
1789 _channel: &str,
1790 _body: &str,
1791 ) -> anyhow::Result<()> {
1792 Ok(())
1793 }
1794 }
1795
1796 struct EmptyMailbox;
1799 impl crate::mailbox::MailboxSource for EmptyMailbox {
1800 fn inbox(&self, _id: &str, _after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
1801 Ok(Vec::new())
1802 }
1803 fn sent(&self, _id: &str, _after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
1804 Ok(Vec::new())
1805 }
1806 fn channel_feed(
1807 &self,
1808 _id: &str,
1809 _after: i64,
1810 ) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
1811 Ok(Vec::new())
1812 }
1813 fn wire(&self, _id: &str, _after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
1814 Ok(Vec::new())
1815 }
1816 }
1817
1818 fn dispatch(app: &mut App, ev: Event) {
1821 super::handle_event(
1822 app,
1823 ev,
1824 &NoopDecider,
1825 &NoopSender,
1826 &EmptyMailbox,
1827 &crate::keysender::test_support::MockKeySender::default(),
1828 );
1829 }
1830
1831 fn agent(id: &str, state: AgentState) -> AgentInfo {
1832 AgentInfo {
1833 id: id.into(),
1834 agent: id
1835 .split_once(':')
1836 .map(|(_, a)| a.to_string())
1837 .unwrap_or_default(),
1838 project: id
1839 .split_once(':')
1840 .map(|(p, _)| p.to_string())
1841 .unwrap_or_default(),
1842 tmux_session: format!("t-{}", id.replace(':', "-")),
1843 state,
1844 unread_mail: 0,
1845 pending_approvals: 0,
1846 is_manager: false,
1847 display_name: None,
1848 }
1849 }
1850
1851 pub fn fixture_team(agents: Vec<AgentInfo>) -> TeamSnapshot {
1852 TeamSnapshot {
1853 root: std::path::PathBuf::from("/fixture"),
1854 team_name: "fixture".into(),
1855 agents,
1856 channels: Vec::new(),
1857 }
1858 }
1859
1860 #[test]
1861 fn splash_dismissed_by_any_key() {
1862 let mut app = App::new();
1863 assert_eq!(app.stage, Stage::Splash);
1864 dispatch(&mut app, key(KeyCode::Char(' ')));
1865 assert_eq!(app.stage, Stage::Triptych);
1866 }
1867
1868 #[test]
1869 fn tab_cycles_panes_uniformly_and_wraps_through_mailbox() {
1870 let mut app = App::new();
1877 app.dismiss_splash();
1878 assert_eq!(app.focused_pane, Pane::Roster);
1879 dispatch(&mut app, key(KeyCode::Tab));
1880 assert_eq!(app.focused_pane, Pane::Detail);
1881 dispatch(&mut app, key(KeyCode::Tab));
1882 assert_eq!(app.focused_pane, Pane::Mailbox);
1883 assert_eq!(
1884 app.mailbox_tab,
1885 MailboxTab::Inbox,
1886 "Tab into mailbox does NOT touch the active mailbox tab"
1887 );
1888 dispatch(&mut app, key(KeyCode::Tab));
1889 assert_eq!(
1890 app.focused_pane,
1891 Pane::Roster,
1892 "Tab from mailbox wraps to roster, not into mailbox subtabs"
1893 );
1894 assert_eq!(
1895 app.mailbox_tab,
1896 MailboxTab::Inbox,
1897 "mailbox tab still untouched"
1898 );
1899 }
1900
1901 #[test]
1902 fn arrow_keys_walk_mailbox_tabs_when_mailbox_focused() {
1903 let mut app = App::new();
1908 app.dismiss_splash();
1909 dispatch(&mut app, key(KeyCode::Tab));
1911 dispatch(&mut app, key(KeyCode::Tab));
1912 assert_eq!(app.focused_pane, Pane::Mailbox);
1913 assert_eq!(app.mailbox_tab, MailboxTab::Inbox);
1914
1915 dispatch(&mut app, key(KeyCode::Right));
1916 assert_eq!(app.mailbox_tab, MailboxTab::Sent);
1917 dispatch(&mut app, key(KeyCode::Right));
1918 assert_eq!(app.mailbox_tab, MailboxTab::Channel);
1919 dispatch(&mut app, key(KeyCode::Right));
1920 assert_eq!(app.mailbox_tab, MailboxTab::Wire);
1921 dispatch(&mut app, key(KeyCode::Right));
1922 assert_eq!(app.mailbox_tab, MailboxTab::Inbox, "→ wraps");
1923
1924 dispatch(&mut app, key(KeyCode::Left));
1925 assert_eq!(app.mailbox_tab, MailboxTab::Wire, "← walks back");
1926 }
1927
1928 #[test]
1929 fn arrow_keys_no_op_when_mailbox_not_focused() {
1930 let mut app = App::new();
1933 app.dismiss_splash();
1934 assert_eq!(app.focused_pane, Pane::Roster);
1935 let initial = app.mailbox_tab;
1936 dispatch(&mut app, key(KeyCode::Right));
1937 dispatch(&mut app, key(KeyCode::Left));
1938 assert_eq!(
1939 app.mailbox_tab, initial,
1940 "←/→ from non-mailbox panes must not flip the active tab"
1941 );
1942 }
1943
1944 #[test]
1945 fn brackets_no_longer_cycle_mailbox_tabs() {
1946 let mut app = App::new();
1951 app.dismiss_splash();
1952 dispatch(&mut app, key(KeyCode::Tab));
1953 dispatch(&mut app, key(KeyCode::Tab));
1954 assert_eq!(app.focused_pane, Pane::Mailbox);
1955 let initial = app.mailbox_tab;
1956
1957 dispatch(&mut app, key(KeyCode::Char(']')));
1958 dispatch(&mut app, key(KeyCode::Char('[')));
1959 assert_eq!(
1960 app.mailbox_tab, initial,
1961 "`[` / `]` must no longer cycle mailbox tabs (T-124 hard-swap)",
1962 );
1963 }
1964
1965 #[test]
1966 fn q_opens_confirm_then_n_cancels() {
1967 let mut app = App::new();
1968 app.dismiss_splash();
1969 dispatch(&mut app, key(KeyCode::Char('q')));
1970 assert_eq!(app.stage, Stage::QuitConfirm);
1971 dispatch(&mut app, key(KeyCode::Char('n')));
1972 assert_eq!(app.stage, Stage::Triptych);
1973 assert!(app.running, "n must not exit");
1974 }
1975
1976 #[test]
1977 fn q_then_y_exits() {
1978 let mut app = App::new();
1979 app.dismiss_splash();
1980 dispatch(&mut app, key(KeyCode::Char('q')));
1981 dispatch(&mut app, key(KeyCode::Char('y')));
1982 assert!(!app.running);
1983 }
1984
1985 #[test]
1986 fn esc_cancels_quit_confirm() {
1987 let mut app = App::new();
1988 app.dismiss_splash();
1989 app.enter_quit_confirm();
1990 dispatch(&mut app, key(KeyCode::Esc));
1991 assert_eq!(app.stage, Stage::Triptych);
1992 }
1993
1994 #[test]
1995 fn render_does_not_panic_at_minimal_size() {
1996 let app = App::new();
1997 let _ = render_to_buffer(&app, 20, 8);
1998 }
1999
2000 #[test]
2001 fn render_does_not_panic_at_huge_size() {
2002 let app = App::new();
2003 let _ = render_to_buffer(&app, 240, 80);
2004 }
2005
2006 #[test]
2007 fn select_next_wraps_through_team() {
2008 let mut app = App::new();
2009 app.replace_team(fixture_team(vec![
2010 agent("p:a", AgentState::Running),
2011 agent("p:b", AgentState::Running),
2012 agent("p:c", AgentState::Running),
2013 ]));
2014 assert_eq!(app.selected_agent, Some(0));
2015 app.select_next();
2016 assert_eq!(app.selected_agent, Some(1));
2017 app.select_next();
2018 assert_eq!(app.selected_agent, Some(2));
2019 app.select_next();
2020 assert_eq!(app.selected_agent, Some(0)); }
2022
2023 #[test]
2024 fn select_prev_wraps_at_top() {
2025 let mut app = App::new();
2026 app.replace_team(fixture_team(vec![
2027 agent("p:a", AgentState::Running),
2028 agent("p:b", AgentState::Running),
2029 ]));
2030 app.selected_agent = Some(0);
2031 app.select_prev();
2032 assert_eq!(app.selected_agent, Some(1));
2033 }
2034
2035 #[test]
2036 fn select_no_op_on_empty_team() {
2037 let mut app = App::new();
2038 app.select_next();
2039 assert_eq!(app.selected_agent, None);
2040 app.select_prev();
2041 assert_eq!(app.selected_agent, None);
2042 }
2043
2044 #[test]
2045 fn replace_team_preserves_selection_when_agent_still_present() {
2046 let mut app = App::new();
2047 app.replace_team(fixture_team(vec![
2048 agent("p:a", AgentState::Running),
2049 agent("p:b", AgentState::Running),
2050 ]));
2051 app.selected_agent = Some(1);
2052 app.replace_team(fixture_team(vec![
2053 agent("p:a", AgentState::Running),
2054 agent("p:b", AgentState::Stopped), ]));
2056 assert_eq!(app.selected_agent, Some(1), "selection follows the id");
2057 }
2058
2059 #[test]
2060 fn replace_team_resets_selection_when_agent_disappears() {
2061 let mut app = App::new();
2062 app.replace_team(fixture_team(vec![
2063 agent("p:a", AgentState::Running),
2064 agent("p:gone", AgentState::Running),
2065 ]));
2066 app.selected_agent = Some(1);
2067 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
2068 assert_eq!(app.selected_agent, Some(0), "falls back to first agent");
2069 }
2070
2071 #[test]
2072 fn switching_agent_resets_mailbox_buffers() {
2073 let mut app = App::new();
2077 app.replace_team(fixture_team(vec![
2078 agent("p:a", AgentState::Running),
2079 agent("p:b", AgentState::Running),
2080 ]));
2081 app.mailbox.extend(
2082 crate::mailbox::MailboxTab::Inbox,
2083 vec![crate::mailbox::MessageRow {
2084 id: 7,
2085 sender: "p:b".into(),
2086 recipient: "p:a".into(),
2087 text: "hi".into(),
2088 sent_at: 0.0,
2089 }],
2090 );
2091 assert_eq!(app.mailbox.inbox.len(), 1);
2092 assert_eq!(app.mailbox.inbox_after, 7);
2093 app.select_next();
2095 assert_eq!(app.selected_agent_id().as_deref(), Some("p:b"));
2096 assert!(app.mailbox.inbox.is_empty());
2097 assert_eq!(app.mailbox.inbox_after, 0);
2098 }
2099
2100 struct TripleFilterMock {
2105 inbox: Vec<crate::mailbox::MessageRow>,
2106 sent: Vec<crate::mailbox::MessageRow>,
2107 channel: Vec<crate::mailbox::MessageRow>,
2108 wire: Vec<crate::mailbox::MessageRow>,
2109 calls: std::sync::Mutex<Vec<(&'static str, String, i64)>>,
2110 }
2111 impl crate::mailbox::MailboxSource for TripleFilterMock {
2112 fn inbox(&self, id: &str, after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
2113 self.calls.lock().unwrap().push(("inbox", id.into(), after));
2114 Ok(self.inbox.clone())
2115 }
2116 fn sent(&self, id: &str, after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
2117 self.calls.lock().unwrap().push(("sent", id.into(), after));
2118 Ok(self.sent.clone())
2119 }
2120 fn channel_feed(
2121 &self,
2122 id: &str,
2123 after: i64,
2124 ) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
2125 self.calls
2126 .lock()
2127 .unwrap()
2128 .push(("channel", id.into(), after));
2129 Ok(self.channel.clone())
2130 }
2131 fn wire(&self, id: &str, after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
2132 self.calls.lock().unwrap().push(("wire", id.into(), after));
2133 Ok(self.wire.clone())
2134 }
2135 }
2136
2137 #[test]
2138 fn refresh_mailbox_fans_out_to_four_filters() {
2139 use crate::mailbox::MessageRow;
2140 let mut app = App::new();
2141 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
2142 let mock = TripleFilterMock {
2143 inbox: vec![MessageRow {
2144 id: 1,
2145 sender: "p:b".into(),
2146 recipient: "p:a".into(),
2147 text: "dm".into(),
2148 sent_at: 0.0,
2149 }],
2150 sent: vec![MessageRow {
2151 id: 4,
2152 sender: "p:a".into(),
2153 recipient: "p:b".into(),
2154 text: "outgoing dm".into(),
2155 sent_at: 0.0,
2156 }],
2157 channel: vec![MessageRow {
2158 id: 2,
2159 sender: "p:b".into(),
2160 recipient: "channel:p:editorial".into(),
2161 text: "ch".into(),
2162 sent_at: 0.0,
2163 }],
2164 wire: vec![MessageRow {
2165 id: 3,
2166 sender: "p:b".into(),
2167 recipient: "channel:p:all".into(),
2168 text: "wire".into(),
2169 sent_at: 0.0,
2170 }],
2171 calls: std::sync::Mutex::new(Vec::new()),
2172 };
2173 super::refresh_mailbox(&mut app, &mock);
2174 assert_eq!(app.mailbox.inbox.len(), 1);
2175 assert_eq!(app.mailbox.sent.len(), 1);
2176 assert_eq!(app.mailbox.channel.len(), 1);
2177 assert_eq!(app.mailbox.wire.len(), 1);
2178 let calls = mock.calls.lock().unwrap();
2179 assert!(calls.contains(&("inbox", "p:a".into(), 0)));
2182 assert!(calls.contains(&("sent", "p:a".into(), 0)));
2183 assert!(calls.contains(&("channel", "p:a".into(), 0)));
2184 assert!(calls.contains(&("wire", "p".into(), 0)));
2185 }
2186
2187 fn ap(id: i64) -> crate::approvals::Approval {
2188 crate::approvals::Approval {
2189 id,
2190 project_id: "p".into(),
2191 agent_id: "p:m".into(),
2192 action: "publish".into(),
2193 summary: format!("approval #{id}"),
2194 payload_json: String::new(),
2195 }
2196 }
2197
2198 #[test]
2199 fn has_pending_approvals_tracks_replace_calls() {
2200 let mut app = App::new();
2201 assert!(!app.has_pending_approvals());
2202 app.replace_approvals(vec![ap(1), ap(2)]);
2203 assert!(app.has_pending_approvals());
2204 app.replace_approvals(vec![]);
2205 assert!(!app.has_pending_approvals());
2206 }
2207
2208 #[test]
2209 fn enter_approvals_modal_no_op_when_queue_empty() {
2210 let mut app = App::new();
2211 app.dismiss_splash();
2212 app.enter_approvals_modal();
2213 assert_eq!(app.stage, Stage::Triptych, "no pending → no modal");
2214 }
2215
2216 #[test]
2217 fn a_chord_opens_modal_when_pending() {
2218 let mut app = App::new();
2219 app.dismiss_splash();
2220 app.replace_approvals(vec![ap(1), ap(2)]);
2221 dispatch(&mut app, key(KeyCode::Char('a')));
2222 assert_eq!(app.stage, Stage::ApprovalsModal);
2223 assert_eq!(app.selected_approval, 0);
2224 }
2225
2226 #[test]
2227 fn modal_cycle_jk_walks_approvals() {
2228 let mut app = App::new();
2229 app.dismiss_splash();
2230 app.replace_approvals(vec![ap(1), ap(2), ap(3)]);
2231 app.enter_approvals_modal();
2232 dispatch(&mut app, key(KeyCode::Char('j')));
2233 assert_eq!(app.selected_approval, 1);
2234 dispatch(&mut app, key(KeyCode::Char('j')));
2235 assert_eq!(app.selected_approval, 2);
2236 dispatch(&mut app, key(KeyCode::Char('j')));
2237 assert_eq!(app.selected_approval, 0, "wraps");
2238 dispatch(&mut app, key(KeyCode::Char('k')));
2239 assert_eq!(app.selected_approval, 2, "k wraps too");
2240 }
2241
2242 #[test]
2243 fn capital_y_routes_approve_through_decider() {
2244 use crate::approvals::test_support::MockApprovalDecider;
2245 let dec = MockApprovalDecider::default();
2246 let mut app = App::new();
2247 app.dismiss_splash();
2248 app.replace_approvals(vec![ap(7), ap(8)]);
2249 app.enter_approvals_modal();
2250 super::handle_event(
2251 &mut app,
2252 key(KeyCode::Char('Y')),
2253 &dec,
2254 &NoopSender,
2255 &EmptyMailbox,
2256 &crate::keysender::test_support::MockKeySender::default(),
2257 );
2258 let calls = dec.calls.lock().unwrap().clone();
2259 assert_eq!(calls.len(), 1);
2260 assert_eq!(calls[0].0, 7);
2261 assert_eq!(calls[0].1, crate::approvals::Decision::Approve);
2262 assert_eq!(app.pending_approvals.len(), 1);
2264 assert_eq!(app.pending_approvals[0].id, 8);
2265 }
2266
2267 #[test]
2268 fn capital_n_routes_deny_through_decider() {
2269 use crate::approvals::test_support::MockApprovalDecider;
2270 let dec = MockApprovalDecider::default();
2271 let mut app = App::new();
2272 app.dismiss_splash();
2273 app.replace_approvals(vec![ap(7)]);
2274 app.enter_approvals_modal();
2275 super::handle_event(
2276 &mut app,
2277 key(KeyCode::Char('N')),
2278 &dec,
2279 &NoopSender,
2280 &EmptyMailbox,
2281 &crate::keysender::test_support::MockKeySender::default(),
2282 );
2283 let calls = dec.calls.lock().unwrap().clone();
2284 assert_eq!(calls.len(), 1);
2285 assert_eq!(calls[0].1, crate::approvals::Decision::Deny);
2286 assert_eq!(app.stage, Stage::Triptych);
2288 }
2289
2290 #[test]
2291 fn esc_closes_approvals_modal() {
2292 let mut app = App::new();
2293 app.dismiss_splash();
2294 app.replace_approvals(vec![ap(1)]);
2295 app.enter_approvals_modal();
2296 dispatch(&mut app, key(KeyCode::Esc));
2297 assert_eq!(app.stage, Stage::Triptych);
2298 }
2299
2300 #[test]
2301 fn lowercase_y_routes_approve_through_decider() {
2302 use crate::approvals::test_support::MockApprovalDecider;
2306 let dec = MockApprovalDecider::default();
2307 let mut app = App::new();
2308 app.dismiss_splash();
2309 app.replace_approvals(vec![ap(7)]);
2310 app.enter_approvals_modal();
2311 super::handle_event(
2312 &mut app,
2313 key(KeyCode::Char('y')),
2314 &dec,
2315 &NoopSender,
2316 &EmptyMailbox,
2317 &crate::keysender::test_support::MockKeySender::default(),
2318 );
2319 let calls = dec.calls.lock().unwrap().clone();
2320 assert_eq!(calls.len(), 1);
2321 assert_eq!(calls[0].1, crate::approvals::Decision::Approve);
2322 }
2323
2324 #[test]
2325 fn lowercase_n_does_not_deny() {
2326 use crate::approvals::test_support::MockApprovalDecider;
2331 let dec = MockApprovalDecider::default();
2332 let mut app = App::new();
2333 app.dismiss_splash();
2334 app.replace_approvals(vec![ap(7)]);
2335 app.enter_approvals_modal();
2336 super::handle_event(
2337 &mut app,
2338 key(KeyCode::Char('n')),
2339 &dec,
2340 &NoopSender,
2341 &EmptyMailbox,
2342 &crate::keysender::test_support::MockKeySender::default(),
2343 );
2344 assert!(
2345 dec.calls.lock().unwrap().is_empty(),
2346 "lowercase n must not route through the decider"
2347 );
2348 assert_eq!(
2349 app.stage,
2350 Stage::ApprovalsModal,
2351 "stale lowercase n leaves the modal open"
2352 );
2353 }
2354
2355 #[test]
2356 fn shift_tab_cycles_panes_backward() {
2357 use crossterm::event::KeyModifiers;
2358 let mut app = App::new();
2359 app.dismiss_splash();
2360 assert_eq!(app.focused_pane, Pane::Roster);
2361 dispatch(&mut app, key(KeyCode::BackTab));
2364 assert_eq!(app.focused_pane, Pane::Mailbox);
2365 dispatch(&mut app, key_with(KeyCode::Tab, KeyModifiers::SHIFT));
2367 assert_eq!(app.focused_pane, Pane::Detail);
2368 }
2369
2370 #[test]
2371 fn at_chord_opens_compose_dm_to_focused_agent() {
2372 let mut app = App::new();
2373 app.replace_team(fixture_team(vec![
2374 agent("writing:manager", AgentState::Running),
2375 agent("writing:dev1", AgentState::Running),
2376 ]));
2377 app.dismiss_splash();
2378 app.select_next();
2379 dispatch(&mut app, key(KeyCode::Char('@')));
2380 assert_eq!(app.stage, Stage::ComposeModal);
2381 match app.compose_target.as_ref() {
2382 Some(crate::compose::ComposeTarget::Dm { agent_id, .. }) => {
2383 assert_eq!(agent_id, "writing:dev1");
2384 }
2385 other => panic!("expected DM target, got {other:?}"),
2386 }
2387 }
2388
2389 #[test]
2390 fn bang_chord_opens_compose_broadcast_to_all_channel() {
2391 let mut app = App::new();
2392 app.replace_team(fixture_team(vec![agent(
2393 "writing:manager",
2394 AgentState::Running,
2395 )]));
2396 app.dismiss_splash();
2397 dispatch(&mut app, key(KeyCode::Char('!')));
2398 assert_eq!(app.stage, Stage::ComposeModal);
2399 match app.compose_target.as_ref() {
2400 Some(crate::compose::ComposeTarget::Broadcast { channel_id, .. }) => {
2401 assert_eq!(channel_id, "writing:all");
2402 }
2403 other => panic!("expected Broadcast target, got {other:?}"),
2404 }
2405 }
2406
2407 #[test]
2408 fn send_routes_dm_through_mock_sender() {
2409 use crate::compose::test_support::MockMessageSender;
2410 let sender = MockMessageSender::default();
2411 let mailbox = EmptyMailbox;
2412 let mut app = App::new();
2413 app.replace_team(fixture_team(vec![agent(
2414 "writing:dev1",
2415 AgentState::Running,
2416 )]));
2417 app.dismiss_splash();
2418 app.enter_compose_dm_for_focused();
2419 for c in "ship it".chars() {
2420 super::handle_event(
2421 &mut app,
2422 key(KeyCode::Char(c)),
2423 &NoopDecider,
2424 &sender,
2425 &mailbox,
2426 &crate::keysender::test_support::MockKeySender::default(),
2427 );
2428 }
2429 super::handle_event(
2430 &mut app,
2431 key_with(KeyCode::Enter, crossterm::event::KeyModifiers::CONTROL),
2432 &NoopDecider,
2433 &sender,
2434 &mailbox,
2435 &crate::keysender::test_support::MockKeySender::default(),
2436 );
2437 let calls = sender.dm_calls.lock().unwrap().clone();
2438 assert_eq!(calls.len(), 1);
2439 assert_eq!(calls[0].0, "writing:dev1");
2440 assert_eq!(calls[0].1, "ship it");
2441 assert_eq!(app.stage, Stage::Triptych, "modal closes on send");
2442 }
2443
2444 #[test]
2445 fn esc_esc_cancels_compose_without_send() {
2446 use crate::compose::test_support::MockMessageSender;
2447 let sender = MockMessageSender::default();
2448 let mailbox = EmptyMailbox;
2449 let mut app = App::new();
2450 app.replace_team(fixture_team(vec![agent(
2451 "writing:dev1",
2452 AgentState::Running,
2453 )]));
2454 app.dismiss_splash();
2455 app.enter_compose_dm_for_focused();
2456 for c in "draft".chars() {
2457 super::handle_event(
2458 &mut app,
2459 key(KeyCode::Char(c)),
2460 &NoopDecider,
2461 &sender,
2462 &mailbox,
2463 &crate::keysender::test_support::MockKeySender::default(),
2464 );
2465 }
2466 super::handle_event(
2467 &mut app,
2468 key(KeyCode::Esc),
2469 &NoopDecider,
2470 &sender,
2471 &mailbox,
2472 &crate::keysender::test_support::MockKeySender::default(),
2473 );
2474 super::handle_event(
2475 &mut app,
2476 key(KeyCode::Esc),
2477 &NoopDecider,
2478 &sender,
2479 &mailbox,
2480 &crate::keysender::test_support::MockKeySender::default(),
2481 );
2482 assert_eq!(app.stage, Stage::Triptych);
2483 assert!(sender.dm_calls.lock().unwrap().is_empty());
2484 }
2485
2486 #[test]
2487 fn send_failure_surfaces_error_inline_keeps_modal_open() {
2488 use crate::compose::test_support::MockMessageSender;
2489 let sender = MockMessageSender::default();
2490 *sender.fail_next.lock().unwrap() = Some("rate limit".into());
2491 let mailbox = EmptyMailbox;
2492 let mut app = App::new();
2493 app.replace_team(fixture_team(vec![agent(
2494 "writing:dev1",
2495 AgentState::Running,
2496 )]));
2497 app.dismiss_splash();
2498 app.enter_compose_dm_for_focused();
2499 super::handle_event(
2500 &mut app,
2501 key(KeyCode::Char('x')),
2502 &NoopDecider,
2503 &sender,
2504 &mailbox,
2505 &crate::keysender::test_support::MockKeySender::default(),
2506 );
2507 super::handle_event(
2508 &mut app,
2509 key_with(KeyCode::Enter, crossterm::event::KeyModifiers::CONTROL),
2510 &NoopDecider,
2511 &sender,
2512 &mailbox,
2513 &crate::keysender::test_support::MockKeySender::default(),
2514 );
2515 assert_eq!(app.stage, Stage::ComposeModal, "modal stays open on err");
2516 assert!(app
2517 .compose_error
2518 .as_deref()
2519 .unwrap_or_default()
2520 .contains("rate limit"));
2521 }
2522
2523 fn channel(id: &str, project: &str) -> crate::data::ChannelInfo {
2524 crate::data::ChannelInfo {
2525 id: id.into(),
2526 name: id
2527 .rsplit_once(':')
2528 .map(|(_, n)| n.to_string())
2529 .unwrap_or_default(),
2530 project_id: project.into(),
2531 }
2532 }
2533
2534 fn fixture_team_with_channels(
2535 agents: Vec<AgentInfo>,
2536 channels: Vec<crate::data::ChannelInfo>,
2537 ) -> TeamSnapshot {
2538 TeamSnapshot {
2539 root: std::path::PathBuf::from("/fixture"),
2540 team_name: "fixture".into(),
2541 agents,
2542 channels,
2543 }
2544 }
2545
2546 #[test]
2547 fn ctrl_w_toggles_wall_layout() {
2548 use crossterm::event::KeyModifiers;
2549 let mut app = App::new();
2550 app.dismiss_splash();
2551 assert_eq!(app.layout, MainLayout::Triptych);
2552 dispatch(
2553 &mut app,
2554 key_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
2555 );
2556 assert_eq!(app.layout, MainLayout::Wall);
2557 dispatch(
2558 &mut app,
2559 key_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
2560 );
2561 assert_eq!(app.layout, MainLayout::Triptych);
2562 }
2563
2564 #[test]
2565 fn ctrl_m_toggles_mailbox_first_layout() {
2566 use crossterm::event::KeyModifiers;
2567 let mut app = App::new();
2568 app.dismiss_splash();
2569 dispatch(
2570 &mut app,
2571 key_with(KeyCode::Char('m'), KeyModifiers::CONTROL),
2572 );
2573 assert_eq!(app.layout, MainLayout::MailboxFirst);
2574 dispatch(
2575 &mut app,
2576 key_with(KeyCode::Char('m'), KeyModifiers::CONTROL),
2577 );
2578 assert_eq!(app.layout, MainLayout::Triptych);
2579 }
2580
2581 #[test]
2582 fn wall_scroll_pages_through_overflow_agents() {
2583 let mut app = App::new();
2584 let mut agents: Vec<_> = (1..=10)
2585 .map(|i| agent(&format!("p:agent-{i:02}"), AgentState::Running))
2586 .collect();
2587 for a in agents.iter_mut() {
2589 a.is_manager = false;
2590 }
2591 app.replace_team(fixture_team(agents));
2592 app.dismiss_splash();
2593 app.toggle_wall_layout();
2594 assert_eq!(app.wall_scroll, 0);
2595 app.wall_scroll_down();
2596 assert_eq!(app.wall_scroll, 4);
2597 app.wall_scroll_down();
2598 assert_eq!(app.wall_scroll, 8);
2599 app.wall_scroll_down();
2601 assert_eq!(app.wall_scroll, 8, "scroll capped at last full window");
2602 app.wall_scroll_up();
2603 assert_eq!(app.wall_scroll, 4);
2604 }
2605
2606 #[test]
2607 fn ctrl_pipe_adds_detail_split_capped_at_four() {
2608 use crossterm::event::KeyModifiers;
2609 let mut app = App::new();
2610 app.replace_team(fixture_team(vec![
2611 agent("p:a", AgentState::Running),
2612 agent("p:b", AgentState::Running),
2613 ]));
2614 app.dismiss_splash();
2615 for _ in 0..6 {
2616 dispatch(
2617 &mut app,
2618 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
2619 );
2620 }
2621 assert_eq!(app.detail_splits.len(), 4, "split count capped at 4");
2622 }
2623
2624 #[test]
2625 fn ctrl_q_closes_focused_split() {
2626 use crossterm::event::KeyModifiers;
2627 let mut app = App::new();
2628 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
2629 app.dismiss_splash();
2630 dispatch(
2631 &mut app,
2632 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
2633 );
2634 dispatch(
2635 &mut app,
2636 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
2637 );
2638 assert_eq!(app.detail_splits.len(), 2);
2639 dispatch(
2640 &mut app,
2641 key_with(KeyCode::Char('Q'), KeyModifiers::CONTROL),
2642 );
2643 assert_eq!(app.detail_splits.len(), 1);
2644 }
2645
2646 #[test]
2647 fn ctrl_hjkl_cycles_splits() {
2648 use crossterm::event::KeyModifiers;
2649 let mut app = App::new();
2650 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
2651 app.dismiss_splash();
2652 for _ in 0..3 {
2653 dispatch(
2654 &mut app,
2655 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
2656 );
2657 }
2658 assert_eq!(app.selected_split, 2);
2659 dispatch(
2660 &mut app,
2661 key_with(KeyCode::Char('l'), KeyModifiers::CONTROL),
2662 );
2663 assert_eq!(app.selected_split, 0, "wraps");
2664 dispatch(
2665 &mut app,
2666 key_with(KeyCode::Char('h'), KeyModifiers::CONTROL),
2667 );
2668 assert_eq!(app.selected_split, 2);
2669 }
2670
2671 #[test]
2672 fn wall_scroll_at_exactly_cap_agents_does_not_scroll() {
2673 let mut app = App::new();
2678 let agents: Vec<_> = (1..=4)
2679 .map(|i| agent(&format!("p:agent-{i}"), AgentState::Running))
2680 .collect();
2681 app.replace_team(fixture_team(agents));
2682 app.dismiss_splash();
2683 app.toggle_wall_layout();
2684 assert_eq!(app.wall_scroll, 0);
2685 app.wall_scroll_down();
2686 assert_eq!(app.wall_scroll, 0, "exactly-cap should not advance");
2687 app.wall_scroll_up();
2688 assert_eq!(app.wall_scroll, 0);
2689 }
2690
2691 #[test]
2692 fn wall_scroll_at_cap_plus_one_advances_then_stops() {
2693 let mut app = App::new();
2698 let agents: Vec<_> = (1..=5)
2699 .map(|i| agent(&format!("p:agent-{i}"), AgentState::Running))
2700 .collect();
2701 app.replace_team(fixture_team(agents));
2702 app.dismiss_splash();
2703 app.toggle_wall_layout();
2704 app.wall_scroll_down();
2705 assert_eq!(app.wall_scroll, 4, "first scroll exposes agent 5");
2706 app.wall_scroll_down();
2707 assert_eq!(app.wall_scroll, 4, "second scroll caps; nothing past");
2708 }
2709
2710 #[test]
2711 fn esc_in_picker_dismisses_overlay_only_keeps_modal_open() {
2712 let mut app = App::new();
2718 app.replace_team(fixture_team_with_channels(
2719 vec![agent("writing:manager", AgentState::Running)],
2720 vec![
2721 channel("writing:all", "writing"),
2722 channel("writing:editorial", "writing"),
2723 ],
2724 ));
2725 app.dismiss_splash();
2726 dispatch(&mut app, key(KeyCode::Char('!')));
2727 assert!(app.compose_picker_open);
2728 assert_eq!(app.stage, Stage::ComposeModal);
2729 dispatch(&mut app, key(KeyCode::Esc));
2730 assert!(!app.compose_picker_open, "picker dismissed");
2731 assert_eq!(app.stage, Stage::ComposeModal, "compose modal stays open");
2732 }
2733
2734 #[test]
2735 fn send_routes_broadcast_through_mock_sender_via_picker() {
2736 use crate::compose::test_support::MockMessageSender;
2742 let sender = MockMessageSender::default();
2743 let mailbox = EmptyMailbox;
2744 let mut app = App::new();
2745 app.replace_team(fixture_team_with_channels(
2746 vec![agent("writing:manager", AgentState::Running)],
2747 vec![
2748 channel("writing:all", "writing"),
2749 channel("writing:editorial", "writing"),
2750 channel("writing:critique", "writing"),
2751 ],
2752 ));
2753 app.dismiss_splash();
2754 super::handle_event(
2757 &mut app,
2758 key(KeyCode::Char('!')),
2759 &NoopDecider,
2760 &sender,
2761 &mailbox,
2762 &crate::keysender::test_support::MockKeySender::default(),
2763 );
2764 super::handle_event(
2765 &mut app,
2766 key(KeyCode::Char('j')),
2767 &NoopDecider,
2768 &sender,
2769 &mailbox,
2770 &crate::keysender::test_support::MockKeySender::default(),
2771 );
2772 super::handle_event(
2773 &mut app,
2774 key(KeyCode::Enter),
2775 &NoopDecider,
2776 &sender,
2777 &mailbox,
2778 &crate::keysender::test_support::MockKeySender::default(),
2779 );
2780 for c in "ship docs".chars() {
2781 super::handle_event(
2782 &mut app,
2783 key(KeyCode::Char(c)),
2784 &NoopDecider,
2785 &sender,
2786 &mailbox,
2787 &crate::keysender::test_support::MockKeySender::default(),
2788 );
2789 }
2790 super::handle_event(
2791 &mut app,
2792 key_with(KeyCode::Enter, crossterm::event::KeyModifiers::CONTROL),
2793 &NoopDecider,
2794 &sender,
2795 &mailbox,
2796 &crate::keysender::test_support::MockKeySender::default(),
2797 );
2798 let dm_calls = sender.dm_calls.lock().unwrap().clone();
2799 let bcast_calls = sender.broadcast_calls.lock().unwrap().clone();
2800 assert!(dm_calls.is_empty(), "broadcast must not route via send_dm");
2801 assert_eq!(bcast_calls.len(), 1);
2802 assert_eq!(
2803 bcast_calls[0].0, "writing:editorial",
2804 "channel id from picker selection"
2805 );
2806 assert_eq!(bcast_calls[0].1, "ship docs");
2807 assert_eq!(app.stage, Stage::Triptych, "modal closes on send");
2808 }
2809
2810 #[test]
2811 fn bang_chord_opens_picker_when_channels_available() {
2812 let mut app = App::new();
2813 app.replace_team(fixture_team_with_channels(
2814 vec![agent("writing:manager", AgentState::Running)],
2815 vec![
2816 channel("writing:all", "writing"),
2817 channel("writing:editorial", "writing"),
2818 channel("writing:critique", "writing"),
2819 ],
2820 ));
2821 app.dismiss_splash();
2822 dispatch(&mut app, key(KeyCode::Char('!')));
2823 assert_eq!(app.stage, Stage::ComposeModal);
2824 assert!(app.compose_picker_open);
2825 dispatch(&mut app, key(KeyCode::Char('j')));
2827 assert_eq!(app.compose_picker_index, 1);
2828 dispatch(&mut app, key(KeyCode::Enter));
2830 assert!(!app.compose_picker_open, "picker closes on confirm");
2831 match app.compose_target.as_ref() {
2832 Some(crate::compose::ComposeTarget::Broadcast { channel_id, .. }) => {
2833 assert_eq!(channel_id, "writing:editorial");
2834 }
2835 other => panic!("expected Broadcast target, got {other:?}"),
2836 }
2837 }
2838
2839 #[test]
2840 fn mailbox_first_layout_seeds_channel_selection_on_entry() {
2841 let mut app = App::new();
2842 app.replace_team(fixture_team_with_channels(
2843 vec![agent("writing:manager", AgentState::Running)],
2844 vec![
2845 channel("writing:all", "writing"),
2846 channel("writing:editorial", "writing"),
2847 ],
2848 ));
2849 app.dismiss_splash();
2850 assert!(app.selected_channel.is_none());
2851 app.toggle_mailbox_first_layout();
2852 assert_eq!(app.selected_channel, Some(0));
2853 }
2854
2855 #[test]
2856 fn help_overlay_opens_on_question_mark_closes_on_esc() {
2857 let mut app = App::new();
2858 app.dismiss_splash();
2859 dispatch(&mut app, key(KeyCode::Char('?')));
2860 assert_eq!(app.stage, Stage::HelpOverlay);
2861 dispatch(&mut app, key(KeyCode::Esc));
2862 assert_eq!(app.stage, Stage::Triptych);
2863 }
2864
2865 #[test]
2866 fn tutorial_opens_on_t_advances_and_closes() {
2867 let mut app = App::new();
2868 app.dismiss_splash();
2869 dispatch(&mut app, key(KeyCode::Char('t')));
2870 assert_eq!(app.stage, Stage::Tutorial);
2871 assert_eq!(app.tutorial_step, 0);
2872 dispatch(&mut app, key(KeyCode::Char(' ')));
2874 assert_eq!(app.tutorial_step, 1);
2875 dispatch(&mut app, key(KeyCode::Char('k')));
2877 assert_eq!(app.tutorial_step, 0);
2878 dispatch(&mut app, key(KeyCode::Esc));
2880 assert_eq!(app.stage, Stage::Triptych);
2881 }
2882
2883 #[test]
2884 fn tutorial_walk_back_at_step_zero_is_no_op() {
2885 let mut app = App::new();
2890 app.dismiss_splash();
2891 app.enter_tutorial();
2892 assert_eq!(app.tutorial_step, 0);
2893 dispatch(&mut app, key(KeyCode::Char('k')));
2894 assert_eq!(app.tutorial_step, 0, "step-0 walk-back is no-op");
2895 assert_eq!(app.stage, Stage::Tutorial);
2898 }
2899
2900 #[test]
2901 fn ctrl_pipe_adds_vertical_split_ctrl_minus_adds_horizontal() {
2902 use crossterm::event::KeyModifiers;
2903 let mut app = App::new();
2904 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
2905 app.dismiss_splash();
2906 dispatch(
2907 &mut app,
2908 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
2909 );
2910 dispatch(
2911 &mut app,
2912 key_with(KeyCode::Char('-'), KeyModifiers::CONTROL),
2913 );
2914 assert_eq!(app.detail_splits.len(), 2);
2915 assert_eq!(app.detail_splits[0].1, SplitOrientation::Vertical);
2916 assert_eq!(app.detail_splits[1].1, SplitOrientation::Horizontal);
2917 }
2918
2919 #[test]
2920 fn ctrl_w_q_chord_prefix_closes_focused_split() {
2921 use crossterm::event::KeyModifiers;
2922 let mut app = App::new();
2923 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
2924 app.dismiss_splash();
2925 dispatch(
2928 &mut app,
2929 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
2930 );
2931 dispatch(
2932 &mut app,
2933 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
2934 );
2935 dispatch(
2936 &mut app,
2937 key_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
2938 );
2939 assert_eq!(app.pending_chord, Some(KeyCode::Char('w')));
2940 dispatch(&mut app, key(KeyCode::Char('q')));
2943 assert_eq!(app.detail_splits.len(), 1);
2944 assert_eq!(app.stage, Stage::Triptych, "must not enter quit confirm");
2945 assert_eq!(app.pending_chord, None, "chord cleared");
2946 }
2947
2948 #[test]
2949 fn ctrl_w_o_chord_keeps_only_focused_split() {
2950 use crossterm::event::KeyModifiers;
2951 let mut app = App::new();
2952 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
2953 app.dismiss_splash();
2954 for _ in 0..3 {
2955 dispatch(
2956 &mut app,
2957 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
2958 );
2959 }
2960 app.selected_split = 1;
2962 let kept_id = app.detail_splits[1].0.clone();
2963 dispatch(
2964 &mut app,
2965 key_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
2966 );
2967 dispatch(&mut app, key(KeyCode::Char('o')));
2968 assert_eq!(app.detail_splits.len(), 1);
2969 assert_eq!(app.detail_splits[0].0, kept_id);
2970 assert_eq!(app.selected_split, 0);
2971 }
2972
2973 #[test]
2974 fn add_detail_split_saturates_at_four_with_explicit_4_and_5_calls() {
2975 let mut app = App::new();
2980 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
2981 for _ in 0..4 {
2982 app.add_detail_split();
2983 }
2984 assert_eq!(app.detail_splits.len(), 4);
2985 let snapshot_len = app.detail_splits.len();
2986 app.add_detail_split();
2987 assert_eq!(app.detail_splits.len(), snapshot_len, "5th call rejected");
2988 }
2989
2990 #[test]
2991 fn replace_approvals_clamps_selection_in_range() {
2992 let mut app = App::new();
2993 app.replace_approvals(vec![ap(1), ap(2), ap(3)]);
2994 app.selected_approval = 2;
2995 app.replace_approvals(vec![ap(1), ap(2)]);
2997 assert_eq!(app.selected_approval, 1, "clamps to last index");
2998 }
2999
3000 #[test]
3001 fn arrow_keys_navigate_only_when_roster_focused() {
3002 let mut app = App::new();
3003 app.replace_team(fixture_team(vec![
3004 agent("p:a", AgentState::Running),
3005 agent("p:b", AgentState::Running),
3006 ]));
3007 app.dismiss_splash();
3008 app.selected_agent = Some(0);
3010 dispatch(&mut app, key(KeyCode::Down));
3011 assert_eq!(app.selected_agent, Some(1));
3012 app.cycle_focus();
3014 dispatch(&mut app, key(KeyCode::Down));
3015 assert_eq!(
3016 app.selected_agent,
3017 Some(1),
3018 "non-roster focus ignores arrows"
3019 );
3020 }
3021
3022 fn stream_keys_fixture() -> App {
3028 let mut app = App::new();
3029 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3030 app.dismiss_splash();
3031 app.cycle_focus(); assert_eq!(app.focused_pane, Pane::Detail);
3033 assert_eq!(app.selected_agent, Some(0));
3034 app
3035 }
3036
3037 fn stream_dispatch(
3038 app: &mut App,
3039 ev: Event,
3040 key_sender: &crate::keysender::test_support::MockKeySender,
3041 ) {
3042 super::handle_event(
3043 app,
3044 ev,
3045 &NoopDecider,
3046 &NoopSender,
3047 &EmptyMailbox,
3048 key_sender,
3049 );
3050 }
3051
3052 #[test]
3053 fn ctrl_e_enters_stream_keys_when_detail_focused() {
3054 use crate::keysender::test_support::MockKeySender;
3055 use crossterm::event::KeyModifiers;
3056 let mut app = stream_keys_fixture();
3057 let ks = MockKeySender::default();
3058 stream_dispatch(
3059 &mut app,
3060 key_with(KeyCode::Char('e'), KeyModifiers::CONTROL),
3061 &ks,
3062 );
3063 assert_eq!(app.stage, Stage::StreamKeys);
3064 assert!(
3065 ks.calls.lock().unwrap().is_empty(),
3066 "the activation chord itself never forwards a keystroke"
3067 );
3068 }
3069
3070 #[test]
3071 fn ctrl_e_no_op_when_detail_not_focused() {
3072 use crate::keysender::test_support::MockKeySender;
3077 use crossterm::event::KeyModifiers;
3078 let mut app = App::new();
3079 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3080 app.dismiss_splash();
3081 assert_eq!(app.focused_pane, Pane::Roster);
3082 let ks = MockKeySender::default();
3083 stream_dispatch(
3084 &mut app,
3085 key_with(KeyCode::Char('e'), KeyModifiers::CONTROL),
3086 &ks,
3087 );
3088 assert_eq!(app.stage, Stage::Triptych);
3089 }
3090
3091 #[test]
3092 fn ctrl_e_no_op_when_no_agent_selected() {
3093 use crate::keysender::test_support::MockKeySender;
3096 use crossterm::event::KeyModifiers;
3097 let mut app = App::new();
3098 app.dismiss_splash();
3099 app.cycle_focus(); assert_eq!(app.selected_agent, None);
3101 let ks = MockKeySender::default();
3102 stream_dispatch(
3103 &mut app,
3104 key_with(KeyCode::Char('e'), KeyModifiers::CONTROL),
3105 &ks,
3106 );
3107 assert_eq!(app.stage, Stage::Triptych);
3108 }
3109
3110 #[test]
3111 fn esc_exits_stream_keys() {
3112 use crate::keysender::test_support::MockKeySender;
3113 let mut app = stream_keys_fixture();
3114 app.enter_stream_keys();
3115 assert_eq!(app.stage, Stage::StreamKeys);
3116 let ks = MockKeySender::default();
3117 stream_dispatch(&mut app, key(KeyCode::Esc), &ks);
3118 assert_eq!(app.stage, Stage::Triptych);
3119 assert!(
3120 ks.calls.lock().unwrap().is_empty(),
3121 "Esc is the exit chord — it must not forward as a keystroke"
3122 );
3123 }
3124
3125 #[test]
3126 fn stream_mode_forwards_printable_chars_to_target_session() {
3127 use crate::keysender::test_support::MockKeySender;
3128 let mut app = stream_keys_fixture();
3129 app.enter_stream_keys();
3130 let ks = MockKeySender::default();
3131 for c in "hi".chars() {
3132 stream_dispatch(&mut app, key(KeyCode::Char(c)), &ks);
3133 }
3134 let calls = ks.calls.lock().unwrap();
3135 assert_eq!(calls.len(), 2, "one tmux send-keys per keystroke");
3136 assert_eq!(calls[0].0, "t-p-a");
3139 assert_eq!(calls[0].1.args, vec!["-l".to_string(), "h".to_string()]);
3140 assert_eq!(calls[1].1.args, vec!["-l".to_string(), "i".to_string()]);
3141 }
3142
3143 #[test]
3144 fn stream_mode_passes_ctrl_c_through_to_agent() {
3145 use crate::keysender::test_support::MockKeySender;
3149 use crossterm::event::KeyModifiers;
3150 let mut app = stream_keys_fixture();
3151 app.enter_stream_keys();
3152 let ks = MockKeySender::default();
3153 stream_dispatch(
3154 &mut app,
3155 key_with(KeyCode::Char('c'), KeyModifiers::CONTROL),
3156 &ks,
3157 );
3158 assert_eq!(app.stage, Stage::StreamKeys, "Ctrl+C does NOT exit");
3159 let calls = ks.calls.lock().unwrap();
3160 assert_eq!(calls.len(), 1);
3161 assert_eq!(calls[0].1.args, vec!["C-c".to_string()]);
3162 }
3163
3164 #[test]
3165 fn stream_mode_forwards_enter_and_arrows() {
3166 use crate::keysender::test_support::MockKeySender;
3167 let mut app = stream_keys_fixture();
3168 app.enter_stream_keys();
3169 let ks = MockKeySender::default();
3170 stream_dispatch(&mut app, key(KeyCode::Enter), &ks);
3171 stream_dispatch(&mut app, key(KeyCode::Up), &ks);
3172 let calls = ks.calls.lock().unwrap();
3173 assert_eq!(calls[0].1.args, vec!["Enter".to_string()]);
3174 assert_eq!(calls[1].1.args, vec!["Up".to_string()]);
3175 }
3176
3177 #[test]
3178 fn stream_target_session_uses_focused_split_when_present() {
3179 let mut app = App::new();
3184 app.replace_team(fixture_team(vec![
3185 agent("p:a", AgentState::Running),
3186 agent("p:b", AgentState::Running),
3187 ]));
3188 app.dismiss_splash();
3189 app.cycle_focus(); app.selected_agent = Some(0);
3191 app.detail_splits
3193 .push(("p:b".into(), crate::app::SplitOrientation::Vertical));
3194 app.selected_split = 1; let target = app.stream_target_session();
3196 assert_eq!(
3197 target.as_deref(),
3198 Some("t-p-b"),
3199 "selected split's agent drives the target"
3200 );
3201 }
3202
3203 #[test]
3204 fn stream_mode_drops_back_when_target_session_disappears() {
3205 use crate::keysender::test_support::MockKeySender;
3210 let mut app = stream_keys_fixture();
3211 app.enter_stream_keys();
3212 app.selected_agent = None;
3214 app.team.agents.clear();
3215 let ks = MockKeySender::default();
3216 stream_dispatch(&mut app, key(KeyCode::Char('a')), &ks);
3217 assert_eq!(app.stage, Stage::Triptych);
3218 assert!(ks.calls.lock().unwrap().is_empty());
3219 }
3220}