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::layouts;
28use crate::mailbox::{BrokerMailboxSource, MailboxBuffers, MailboxSource, MailboxTab};
29use crate::pane::{PaneSource, TmuxPaneSource};
30use crate::splash;
31use crate::statusline;
32use crate::theme::{detect_capabilities, Capabilities};
33use crate::triptych::{self, MainLayout, Pane};
34use crate::tutorial;
35use crate::watch::Watch;
36
37const SPLASH_AUTO_DISMISS: Duration = Duration::from_secs(3);
38const POLL_INTERVAL: Duration = Duration::from_millis(50);
39const REFRESH_INTERVAL: Duration = Duration::from_secs(1);
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum Stage {
45 Splash,
46 Triptych,
47 QuitConfirm,
48 ApprovalsModal,
53 ComposeModal,
58 HelpOverlay,
61 Tutorial,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum SplitOrientation {
73 Vertical,
74 Horizontal,
75}
76
77pub struct App {
78 pub stage: Stage,
79 pub previous_stage: Stage,
81 pub focused_pane: Pane,
82 pub team: TeamSnapshot,
83 pub selected_agent: Option<usize>,
87 pub detail_buffer: Vec<String>,
91 pub version: &'static str,
92 pub capabilities: Capabilities,
93 pub splash_started: Instant,
94 pub last_refresh: Instant,
97 pub running: bool,
98 pub tutorial_completed: bool,
102 pub mailbox_tab: MailboxTab,
108 pub mailbox: MailboxBuffers,
112 pub pending_approvals: Vec<Approval>,
115 pub selected_approval: usize,
119 pub approval_error: Option<String>,
123 pub compose_target: Option<ComposeTarget>,
127 pub compose_editor: Editor,
131 pub compose_error: Option<String>,
135 pub layout: MainLayout,
138 pub wall_scroll: usize,
142 pub selected_channel: Option<usize>,
146 pub detail_splits: Vec<(String, SplitOrientation)>,
152 pub selected_split: usize,
153 pub pending_chord: Option<KeyCode>,
159 pub tutorial_pending_for_team: bool,
163 pub spinner_frame: usize,
166 pub tutorial_step: usize,
169 pub compose_picker_open: bool,
174 pub compose_picker_index: usize,
176}
177
178const MAX_DETAIL_LINES: usize = 2000;
179
180impl App {
181 pub fn new() -> Self {
187 Self {
188 stage: Stage::Splash,
189 previous_stage: Stage::Splash,
190 focused_pane: Pane::Roster,
191 team: TeamSnapshot::empty(std::path::PathBuf::new()),
192 selected_agent: None,
193 detail_buffer: Vec::new(),
194 version: env!("CARGO_PKG_VERSION"),
195 capabilities: detect_capabilities(),
196 splash_started: Instant::now(),
197 last_refresh: Instant::now() - REFRESH_INTERVAL,
198 running: true,
199 tutorial_completed: tutorial::is_completed(),
200 mailbox_tab: MailboxTab::Inbox,
201 mailbox: MailboxBuffers::default(),
202 pending_approvals: Vec::new(),
203 selected_approval: 0,
204 approval_error: None,
205 compose_target: None,
206 compose_editor: Editor::default(),
207 compose_error: None,
208 layout: MainLayout::Triptych,
209 wall_scroll: 0,
210 selected_channel: None,
211 detail_splits: Vec::new(),
212 selected_split: 0,
213 compose_picker_open: false,
214 compose_picker_index: 0,
215 pending_chord: None,
216 tutorial_pending_for_team: false,
217 spinner_frame: 0,
218 tutorial_step: 0,
219 }
220 }
221
222 pub fn enter_help_overlay(&mut self) {
225 self.previous_stage = self.stage;
226 self.stage = Stage::HelpOverlay;
227 }
228 pub fn close_help_overlay(&mut self) {
229 self.stage = self.previous_stage;
230 }
231 pub fn enter_tutorial(&mut self) {
232 self.previous_stage = self.stage;
233 self.stage = Stage::Tutorial;
234 self.tutorial_step = 0;
235 }
236 pub fn close_tutorial(&mut self) {
237 self.stage = self.previous_stage;
238 self.tutorial_pending_for_team = false;
239 if !self.team.root.as_os_str().is_empty() {
240 let _ = crate::onboarding::mark_completed(&self.team.root);
241 }
242 }
243 pub fn tutorial_advance(&mut self) {
244 let len = crate::onboarding::STEPS.len();
245 if len == 0 {
246 self.close_tutorial();
247 return;
248 }
249 if self.tutorial_step + 1 >= len {
250 self.close_tutorial();
251 } else {
252 self.tutorial_step += 1;
253 }
254 }
255 pub fn tutorial_back(&mut self) {
256 self.tutorial_step = self.tutorial_step.saturating_sub(1);
257 }
258
259 pub fn toggle_wall_layout(&mut self) {
260 self.layout = self.layout.toggle_wall();
261 }
262 pub fn toggle_mailbox_first_layout(&mut self) {
263 self.layout = self.layout.toggle_mailbox_first();
264 if matches!(self.layout, MainLayout::MailboxFirst) && self.selected_channel.is_none() {
267 self.selected_channel = if self.team.channels.is_empty() {
268 None
269 } else {
270 Some(0)
271 };
272 }
273 }
274 pub fn wall_scroll_up(&mut self) {
275 self.wall_scroll = self
276 .wall_scroll
277 .saturating_sub(crate::layouts::WALL_TILE_CAP);
278 }
279 pub fn wall_scroll_down(&mut self) {
280 let next = self.wall_scroll + crate::layouts::WALL_TILE_CAP;
281 if next < self.team.agents.len() {
282 self.wall_scroll = next;
283 }
284 }
285 pub fn select_next_channel(&mut self) {
286 if self.team.channels.is_empty() {
287 return;
288 }
289 self.selected_channel = Some(match self.selected_channel {
290 None => 0,
291 Some(i) => (i + 1) % self.team.channels.len(),
292 });
293 }
294 pub fn select_prev_channel(&mut self) {
295 if self.team.channels.is_empty() {
296 return;
297 }
298 self.selected_channel = Some(match self.selected_channel {
299 None | Some(0) => self.team.channels.len() - 1,
300 Some(i) => i - 1,
301 });
302 }
303
304 pub fn add_detail_split_vertical(&mut self) {
308 self.add_detail_split_with_orientation(SplitOrientation::Vertical);
309 }
310 pub fn add_detail_split_horizontal(&mut self) {
312 self.add_detail_split_with_orientation(SplitOrientation::Horizontal);
313 }
314 fn add_detail_split_with_orientation(&mut self, orientation: SplitOrientation) {
315 let Some(id) = self.selected_agent_id() else {
316 return;
317 };
318 if self.detail_splits.len() >= 4 {
319 return;
320 }
321 self.detail_splits.push((id, orientation));
322 self.selected_split = self.detail_splits.len() - 1;
323 }
324 pub fn add_detail_split(&mut self) {
329 self.add_detail_split_vertical();
330 }
331 pub fn close_focused_split(&mut self) {
332 if self.detail_splits.is_empty() {
333 return;
334 }
335 let i = self.selected_split.min(self.detail_splits.len() - 1);
336 self.detail_splits.remove(i);
337 self.selected_split = i.saturating_sub(1);
338 }
339 pub fn cycle_split_next(&mut self) {
340 if self.detail_splits.is_empty() {
341 return;
342 }
343 self.selected_split = (self.selected_split + 1) % self.detail_splits.len();
344 }
345 pub fn cycle_split_prev(&mut self) {
346 if self.detail_splits.is_empty() {
347 return;
348 }
349 self.selected_split = if self.selected_split == 0 {
350 self.detail_splits.len() - 1
351 } else {
352 self.selected_split - 1
353 };
354 }
355
356 pub fn enter_compose_broadcast_with_picker(&mut self) {
361 if self.team.channels.is_empty() {
362 self.enter_compose_broadcast();
366 return;
367 }
368 let project_id = self
369 .team
370 .channels
371 .first()
372 .map(|c| c.project_id.clone())
373 .unwrap_or_default();
374 self.previous_stage = self.stage;
375 self.stage = Stage::ComposeModal;
376 self.compose_target = Some(ComposeTarget::Broadcast {
377 channel_id: format!("{project_id}:all"),
378 project_id,
379 });
380 self.compose_editor = Editor::default();
381 self.compose_error = None;
382 self.compose_picker_open = true;
383 self.compose_picker_index = 0;
384 }
385 pub fn picker_next(&mut self) {
386 if self.team.channels.is_empty() {
387 return;
388 }
389 self.compose_picker_index = (self.compose_picker_index + 1) % self.team.channels.len();
390 }
391 pub fn picker_prev(&mut self) {
392 if self.team.channels.is_empty() {
393 return;
394 }
395 self.compose_picker_index = if self.compose_picker_index == 0 {
396 self.team.channels.len() - 1
397 } else {
398 self.compose_picker_index - 1
399 };
400 }
401 pub fn picker_confirm(&mut self) {
402 if let Some(ch) = self.team.channels.get(self.compose_picker_index) {
403 self.compose_target = Some(ComposeTarget::Broadcast {
404 channel_id: ch.id.clone(),
405 project_id: ch.project_id.clone(),
406 });
407 }
408 self.compose_picker_open = false;
409 }
410
411 pub fn cycle_mailbox_tab(&mut self) {
412 self.mailbox_tab = self.mailbox_tab.next();
413 }
414
415 pub fn cycle_mailbox_tab_back(&mut self) {
416 self.mailbox_tab = self.mailbox_tab.prev();
417 }
418
419 pub fn cycle_focus_back(&mut self) {
420 self.focused_pane = self.focused_pane.prev();
421 }
422
423 pub fn has_pending_approvals(&self) -> bool {
424 !self.pending_approvals.is_empty()
425 }
426
427 pub fn enter_approvals_modal(&mut self) {
428 if self.pending_approvals.is_empty() {
429 return;
430 }
431 self.previous_stage = self.stage;
432 self.stage = Stage::ApprovalsModal;
433 self.selected_approval = 0;
434 self.approval_error = None;
435 }
436
437 pub fn close_approvals_modal(&mut self) {
438 self.stage = self.previous_stage;
439 self.approval_error = None;
440 }
441
442 pub fn cycle_approval_next(&mut self) {
443 if self.pending_approvals.is_empty() {
444 return;
445 }
446 self.selected_approval = (self.selected_approval + 1) % self.pending_approvals.len();
447 }
448
449 pub fn cycle_approval_prev(&mut self) {
450 if self.pending_approvals.is_empty() {
451 return;
452 }
453 self.selected_approval = if self.selected_approval == 0 {
454 self.pending_approvals.len() - 1
455 } else {
456 self.selected_approval - 1
457 };
458 }
459
460 pub fn focused_approval(&self) -> Option<&Approval> {
461 self.pending_approvals.get(self.selected_approval)
462 }
463
464 pub fn replace_approvals(&mut self, approvals: Vec<Approval>) {
470 self.pending_approvals = approvals;
471 if self.pending_approvals.is_empty() {
472 if matches!(self.stage, Stage::ApprovalsModal) {
473 self.close_approvals_modal();
474 }
475 self.selected_approval = 0;
476 } else if self.selected_approval >= self.pending_approvals.len() {
477 self.selected_approval = self.pending_approvals.len() - 1;
478 }
479 }
480
481 pub fn apply_decision<D: ApprovalDecider>(&mut self, decider: &D, kind: Decision, note: &str) {
488 let Some(approval) = self.focused_approval().cloned() else {
489 return;
490 };
491 match decider.decide(&self.team.root, approval.id, kind, note) {
492 Ok(()) => {
493 self.pending_approvals.retain(|a| a.id != approval.id);
494 self.approval_error = None;
495 if self.pending_approvals.is_empty() {
496 self.close_approvals_modal();
497 } else if self.selected_approval >= self.pending_approvals.len() {
498 self.selected_approval = self.pending_approvals.len() - 1;
499 }
500 }
501 Err(err) => {
502 self.approval_error = Some(err.to_string());
503 }
504 }
505 }
506
507 pub fn enter_compose_dm_for_focused(&mut self) {
510 let Some(info) = self
511 .selected_agent
512 .and_then(|i| self.team.agents.get(i))
513 .cloned()
514 else {
515 return;
516 };
517 self.previous_stage = self.stage;
518 self.stage = Stage::ComposeModal;
519 self.compose_target = Some(ComposeTarget::Dm {
520 agent_id: info.id.clone(),
521 project_id: info.project.clone(),
522 });
523 self.compose_editor = Editor::default();
524 self.compose_error = None;
525 }
526
527 pub fn enter_compose_broadcast(&mut self) {
535 let project_id = self
536 .selected_agent
537 .and_then(|i| self.team.agents.get(i))
538 .map(|a| a.project.clone())
539 .or_else(|| self.team.agents.first().map(|a| a.project.clone()));
540 let Some(project_id) = project_id else {
541 return;
542 };
543 let channel_id = format!("{project_id}:all");
544 self.previous_stage = self.stage;
545 self.stage = Stage::ComposeModal;
546 self.compose_target = Some(ComposeTarget::Broadcast {
547 channel_id,
548 project_id,
549 });
550 self.compose_editor = Editor::default();
551 self.compose_error = None;
552 }
553
554 pub fn close_compose_modal(&mut self) {
555 self.stage = self.previous_stage;
556 self.compose_target = None;
557 self.compose_editor = Editor::default();
558 self.compose_error = None;
559 }
560
561 pub fn apply_send<S: MessageSender, M: MailboxSource>(
567 &mut self,
568 sender: &S,
569 mailbox_source: &M,
570 ) {
571 let Some(target) = self.compose_target.clone() else {
572 return;
573 };
574 let body = self.compose_editor.body();
575 if body.is_empty() {
576 self.compose_error = Some("body is empty".into());
577 return;
578 }
579 let result = match &target {
580 ComposeTarget::Dm { agent_id, .. } => sender.send_dm(&self.team.root, agent_id, &body),
581 ComposeTarget::Broadcast { channel_id, .. } => {
582 sender.broadcast(&self.team.root, channel_id, &body)
583 }
584 };
585 match result {
586 Ok(()) => {
587 self.close_compose_modal();
588 refresh_mailbox(self, mailbox_source);
591 }
592 Err(err) => {
593 self.compose_error = Some(err.to_string());
594 }
595 }
596 }
597
598 pub fn dismiss_splash(&mut self) {
599 if matches!(self.stage, Stage::Splash) {
600 self.stage = Stage::Triptych;
601 self.previous_stage = Stage::Triptych;
602 }
603 }
604
605 pub fn cycle_focus(&mut self) {
606 self.focused_pane = self.focused_pane.next();
607 }
608
609 pub fn select_prev(&mut self) {
615 if self.team.agents.is_empty() {
616 self.selected_agent = None;
617 return;
618 }
619 let prior = self.selected_agent_id();
620 self.selected_agent = Some(match self.selected_agent {
621 None | Some(0) => self.team.agents.len() - 1,
622 Some(i) => i - 1,
623 });
624 if prior != self.selected_agent_id() {
625 self.mailbox.reset();
626 }
627 }
628
629 pub fn select_next(&mut self) {
632 if self.team.agents.is_empty() {
633 self.selected_agent = None;
634 return;
635 }
636 let prior = self.selected_agent_id();
637 self.selected_agent = Some(match self.selected_agent {
638 None => 0,
639 Some(i) => (i + 1) % self.team.agents.len(),
640 });
641 if prior != self.selected_agent_id() {
642 self.mailbox.reset();
643 }
644 }
645
646 pub fn selected_agent_id(&self) -> Option<String> {
648 self.selected_agent
649 .and_then(|i| self.team.agents.get(i))
650 .map(|a| a.id.clone())
651 }
652
653 pub fn enter_quit_confirm(&mut self) {
654 self.previous_stage = self.stage;
655 self.stage = Stage::QuitConfirm;
656 }
657
658 pub fn cancel_quit(&mut self) {
659 self.stage = self.previous_stage;
660 }
661
662 pub fn confirm_quit(&mut self) {
663 self.running = false;
664 }
665
666 pub fn replace_team(&mut self, team: TeamSnapshot) {
673 let prior_id = self.selected_agent_id();
674 self.team = team;
675 self.selected_agent = match (prior_id.clone(), self.team.agents.is_empty()) {
676 (_, true) => None,
677 (Some(id), false) => self.team.agents.iter().position(|a| a.id == id).or(Some(0)),
678 (None, false) => Some(0),
679 };
680 if prior_id != self.selected_agent_id() {
681 self.mailbox.reset();
682 }
683 }
684
685 pub fn focused_session(&self) -> Option<&str> {
688 self.selected_agent
689 .and_then(|i| self.team.agents.get(i))
690 .map(|a| a.tmux_session.as_str())
691 }
692
693 pub fn set_detail_buffer(&mut self, lines: Vec<String>) {
695 let len = lines.len();
696 let start = len.saturating_sub(MAX_DETAIL_LINES);
697 self.detail_buffer = lines[start..].to_vec();
698 }
699}
700
701impl Default for App {
702 fn default() -> Self {
703 Self::new()
704 }
705}
706
707pub fn refresh<P: PaneSource, M: MailboxSource, A: ApprovalSource>(
712 app: &mut App,
713 pane_source: &P,
714 mailbox_source: &M,
715 approval_source: &A,
716) {
717 if let Ok(Some(snapshot)) = TeamSnapshot::discover_and_load() {
718 app.replace_team(snapshot);
719 }
720 if let Some(session) = app.focused_session().map(|s| s.to_string()) {
721 if let Ok(lines) = pane_source.capture(&session) {
722 app.set_detail_buffer(lines);
723 }
724 } else {
725 app.detail_buffer.clear();
726 }
727 refresh_mailbox(app, mailbox_source);
728 refresh_approvals(app, approval_source);
729 app.last_refresh = Instant::now();
730}
731
732pub fn refresh_approvals<A: ApprovalSource>(app: &mut App, approval_source: &A) {
738 let approvals = approval_source.pending().unwrap_or_default();
739 app.replace_approvals(approvals);
740}
741
742pub fn refresh_mailbox<M: MailboxSource>(app: &mut App, mailbox_source: &M) {
747 let Some(agent_id) = app.selected_agent_id() else {
748 return;
751 };
752 let project_id = app
753 .selected_agent
754 .and_then(|i| app.team.agents.get(i))
755 .map(|a| a.project.clone())
756 .unwrap_or_default();
757 if let Ok(batch) = mailbox_source.inbox(&agent_id, app.mailbox.inbox_after) {
758 app.mailbox.extend(MailboxTab::Inbox, batch);
759 }
760 if let Ok(batch) = mailbox_source.channel_feed(&agent_id, app.mailbox.channel_after) {
761 app.mailbox.extend(MailboxTab::Channel, batch);
762 }
763 if let Ok(batch) = mailbox_source.wire(&project_id, app.mailbox.wire_after) {
764 app.mailbox.extend(MailboxTab::Wire, batch);
765 }
766}
767
768pub fn run<B: Backend>(terminal: &mut Terminal<B>) -> Result<()> {
769 let mut app = App::new();
770 let pane_source = TmuxPaneSource;
771 let decider = CliApprovalDecider;
772 let sender = CliMessageSender;
773 refresh_with_default_sources(&mut app, &pane_source);
776 let mut watch = Watch::try_new(&app.team.root.join("state"));
777 while app.running {
778 terminal.draw(|f| draw(f, &app))?;
779 if event::poll(POLL_INTERVAL)? {
780 let db_path = app.team.root.join("state/mailbox.db");
784 let mailbox_source = BrokerMailboxSource::new(db_path);
785 handle_event(&mut app, event::read()?, &decider, &sender, &mailbox_source);
786 }
787 if matches!(app.stage, Stage::Splash) && app.splash_started.elapsed() >= SPLASH_AUTO_DISMISS
788 {
789 app.dismiss_splash();
790 }
791 let dirty = watch.take_dirty();
798 if dirty || app.last_refresh.elapsed() >= REFRESH_INTERVAL {
799 let prior_root = app.team.root.clone();
800 refresh_with_default_sources(&mut app, &pane_source);
801 if app.team.root != prior_root {
804 watch = Watch::try_new(&app.team.root.join("state"));
805 }
806 }
807 }
808 Ok(())
809}
810
811fn refresh_with_default_sources<P: PaneSource>(app: &mut App, pane_source: &P) {
816 if let Ok(Some(snapshot)) = TeamSnapshot::discover_and_load() {
817 app.replace_team(snapshot);
818 }
819 let db_path = app.team.root.join("state/mailbox.db");
820 let mailbox_source = BrokerMailboxSource::new(db_path.clone());
821 let approval_source = BrokerApprovalSource::new(db_path);
822 if let Some(session) = app.focused_session().map(|s| s.to_string()) {
823 if let Ok(lines) = pane_source.capture(&session) {
824 app.set_detail_buffer(lines);
825 }
826 } else {
827 app.detail_buffer.clear();
828 }
829 refresh_mailbox(app, &mailbox_source);
830 refresh_approvals(app, &approval_source);
831 app.last_refresh = Instant::now();
832}
833
834pub fn draw(f: &mut Frame<'_>, app: &App) {
835 let area = f.area();
836 match app.stage {
837 Stage::Splash => splash::draw(f, app),
838 Stage::Triptych => draw_main(f, area, app),
839 Stage::QuitConfirm => {
840 draw_main(f, area, app);
841 draw_quit_confirm(f, area);
842 }
843 Stage::ApprovalsModal => {
844 draw_main(f, area, app);
845 draw_approvals_modal(f, area, app);
846 }
847 Stage::ComposeModal => {
848 draw_main(f, area, app);
849 draw_compose_modal(f, area, app);
850 }
851 Stage::HelpOverlay => {
852 draw_main(f, area, app);
853 let buf = f.buffer_mut();
854 render_help_overlay(area, buf, app);
855 }
856 Stage::Tutorial => {
857 draw_main(f, area, app);
858 let buf = f.buffer_mut();
859 render_tutorial(area, buf, app);
860 }
861 }
862}
863
864fn render_help_overlay(area: Rect, buf: &mut Buffer, app: &App) {
865 let popup_w = 70u16.min(area.width.saturating_sub(4));
866 let popup_h = 24u16.min(area.height.saturating_sub(2));
867 let popup = centered_rect(popup_w, popup_h, area);
868 Clear.render(popup, buf);
869 let block = Block::default()
870 .title("help · ? to close")
871 .borders(Borders::ALL)
872 .border_style(Style::default().fg(app.capabilities.accent()));
873 let inner = block.inner(popup);
874 block.render(popup, buf);
875 let muted = Style::default().fg(app.capabilities.muted());
876 let bold = Style::default().add_modifier(Modifier::BOLD);
877 let mut lines: Vec<ratatui::text::Line<'_>> = Vec::new();
878 for group in crate::help::ALL_GROUPS {
879 lines.push(ratatui::text::Line::styled(group.title, bold));
880 for b in group.bindings {
881 lines.push(ratatui::text::Line::raw(format!(
882 " {:<22} {}",
883 b.chord, b.description
884 )));
885 }
886 lines.push(ratatui::text::Line::styled("", muted));
887 }
888 Paragraph::new(lines).render(inner, buf);
889}
890
891fn render_tutorial(area: Rect, buf: &mut Buffer, app: &App) {
892 let popup_w = 64u16.min(area.width.saturating_sub(4));
893 let popup_h = 14u16.min(area.height.saturating_sub(2));
894 let popup = centered_rect(popup_w, popup_h, area);
895 Clear.render(popup, buf);
896 let total = crate::onboarding::STEPS.len();
897 let i = app.tutorial_step.min(total.saturating_sub(1));
898 let step = &crate::onboarding::STEPS[i];
899 let block = Block::default()
900 .title(format!("tutorial · {}/{total}", i + 1))
901 .borders(Borders::ALL)
902 .border_style(Style::default().fg(app.capabilities.accent()));
903 let inner = block.inner(popup);
904 block.render(popup, buf);
905 let muted = Style::default().fg(app.capabilities.muted());
906 let lines = vec![
907 ratatui::text::Line::styled(step.heading, Style::default().add_modifier(Modifier::BOLD)),
908 ratatui::text::Line::raw(""),
909 ratatui::text::Line::raw(step.body),
910 ratatui::text::Line::raw(""),
911 ratatui::text::Line::styled("any key next · k / ↑ / p back · Esc skip", muted),
912 ];
913 Paragraph::new(lines)
919 .wrap(ratatui::widgets::Wrap { trim: true })
920 .render(inner, buf);
921}
922
923fn draw_main(f: &mut Frame<'_>, area: Rect, app: &App) {
924 let chunks = Layout::default()
925 .direction(Direction::Vertical)
926 .constraints([Constraint::Min(3), Constraint::Length(1)])
927 .split(area);
928 let buf = f.buffer_mut();
929 match app.layout {
930 crate::triptych::MainLayout::Triptych => {
931 triptych::Triptych { app }.render(chunks[0], buf);
932 }
933 crate::triptych::MainLayout::Wall => {
934 layouts::Wall { app }.render(chunks[0], buf);
935 }
936 crate::triptych::MainLayout::MailboxFirst => {
937 layouts::MailboxFirst { app }.render(chunks[0], buf);
938 }
939 }
940 statusline::Statusline { app }.render(chunks[1], buf);
941}
942
943fn draw_approvals_modal(f: &mut Frame<'_>, area: Rect, app: &App) {
944 let buf = f.buffer_mut();
945 render_approvals_modal(area, buf, app);
946}
947
948fn draw_compose_modal(f: &mut Frame<'_>, area: Rect, app: &App) {
949 let buf = f.buffer_mut();
950 render_compose_modal(area, buf, app);
951}
952
953fn render_compose_picker_body(inner: Rect, buf: &mut Buffer, app: &App) {
954 let muted = Style::default().fg(app.capabilities.muted());
955 let chunks = Layout::default()
956 .direction(Direction::Vertical)
957 .constraints([
958 Constraint::Min(1),
959 Constraint::Length(1),
960 Constraint::Length(1),
961 ])
962 .split(inner);
963 let lines: Vec<ratatui::text::Line<'_>> = if app.team.channels.is_empty() {
964 vec![ratatui::text::Line::styled(
965 "(no channels declared in team-compose)",
966 muted,
967 )]
968 } else {
969 app.team
970 .channels
971 .iter()
972 .enumerate()
973 .map(|(i, ch)| {
974 let label = format!(" #{} ({})", ch.name, ch.project_id);
975 let style = if i == app.compose_picker_index {
976 Style::default()
977 .fg(app.capabilities.accent())
978 .add_modifier(Modifier::REVERSED)
979 } else {
980 Style::default()
981 };
982 ratatui::text::Line::styled(label, style)
983 })
984 .collect()
985 };
986 Paragraph::new(lines).render(chunks[0], buf);
987 Paragraph::new("pick a channel to broadcast to")
988 .style(muted)
989 .render(chunks[1], buf);
990 Paragraph::new("Enter pick · j/k navigate · Esc cancel")
991 .style(muted)
992 .render(chunks[2], buf);
993}
994
995fn render_compose_modal(area: Rect, buf: &mut Buffer, app: &App) {
996 let popup_w = 80u16.min(area.width.saturating_sub(4));
997 let popup_h = 16u16.min(area.height.saturating_sub(2));
998 let popup = centered_rect(popup_w, popup_h, area);
999 Clear.render(popup, buf);
1000 let title = app
1001 .compose_target
1002 .as_ref()
1003 .map(|t| t.title())
1004 .unwrap_or_else(|| "→ ?".into());
1005 let block = Block::default()
1006 .title(title)
1007 .borders(Borders::ALL)
1008 .border_style(Style::default().fg(app.capabilities.accent()));
1009 let inner = block.inner(popup);
1010 block.render(popup, buf);
1011
1012 if inner.height < 3 {
1013 return;
1014 }
1015 if app.compose_picker_open {
1019 render_compose_picker_body(inner, buf, app);
1020 return;
1021 }
1022 let chunks = Layout::default()
1025 .direction(Direction::Vertical)
1026 .constraints([
1027 Constraint::Min(1), Constraint::Length(1), Constraint::Length(1), ])
1031 .split(inner);
1032
1033 let muted = Style::default().fg(app.capabilities.muted());
1038 let body_lines: Vec<ratatui::text::Line<'_>> = app
1039 .compose_editor
1040 .lines
1041 .iter()
1042 .enumerate()
1043 .map(|(row, line)| {
1044 if row == app.compose_editor.cursor_row
1045 && app.compose_editor.mode == crate::compose::VimMode::Insert
1046 {
1047 let col = app.compose_editor.cursor_col.min(line.len());
1048 let (head, tail) = line.split_at(col);
1049 ratatui::text::Line::from(vec![
1050 ratatui::text::Span::raw(head.to_string()),
1051 ratatui::text::Span::styled(
1052 "▏",
1053 Style::default().fg(app.capabilities.accent()),
1054 ),
1055 ratatui::text::Span::raw(tail.to_string()),
1056 ])
1057 } else {
1058 ratatui::text::Line::raw(line.clone())
1059 }
1060 })
1061 .collect();
1062 Paragraph::new(body_lines).render(chunks[0], buf);
1063
1064 let error_line = match (&app.compose_error, app.compose_editor.mode) {
1065 (Some(e), _) => format!("error: {e}"),
1066 (None, crate::compose::VimMode::Ex) => format!(":{}", app.compose_editor.ex_buffer),
1067 (None, crate::compose::VimMode::Normal) => "-- NORMAL --".into(),
1068 (None, crate::compose::VimMode::Insert) => "-- INSERT --".into(),
1069 };
1070 let style = if app.compose_error.is_some() {
1071 Style::default().fg(app.capabilities.accent())
1072 } else {
1073 muted
1074 };
1075 Paragraph::new(error_line)
1076 .style(style)
1077 .render(chunks[1], buf);
1078
1079 Paragraph::new("Alt+Enter send · Esc Esc cancel · Tab attach (TODO #32)")
1080 .style(muted)
1081 .render(chunks[2], buf);
1082}
1083
1084fn render_approvals_modal(area: Rect, buf: &mut Buffer, app: &App) {
1085 let popup_w = 80u16.min(area.width.saturating_sub(4));
1086 let popup_h = 18u16.min(area.height.saturating_sub(2));
1087 let popup = centered_rect(popup_w, popup_h, area);
1088 Clear.render(popup, buf);
1089 let n = app.pending_approvals.len();
1090 let i = app.selected_approval.min(n.saturating_sub(1));
1091 let title = format!("approvals · {}/{n}", i + 1);
1092 let block = Block::default()
1093 .title(title)
1094 .borders(Borders::ALL)
1095 .border_style(Style::default().fg(app.capabilities.accent()));
1096 let inner = block.inner(popup);
1097 block.render(popup, buf);
1098
1099 let muted = Style::default().fg(app.capabilities.muted());
1100 let bold = Style::default().add_modifier(Modifier::BOLD);
1101
1102 let Some(a) = app.focused_approval() else {
1103 Paragraph::new("(no pending approvals)")
1104 .style(muted)
1105 .alignment(Alignment::Center)
1106 .render(inner, buf);
1107 return;
1108 };
1109
1110 let mut lines: Vec<ratatui::text::Line<'_>> = vec![
1111 ratatui::text::Line::styled(format!("#{} {}", a.id, a.action), bold),
1112 ratatui::text::Line::styled(format!("from: {}", a.agent_id), muted),
1113 ratatui::text::Line::raw(""),
1114 ratatui::text::Line::raw(a.summary.clone()),
1115 ];
1116 if !a.payload_json.is_empty() && a.payload_json != "{}" {
1117 lines.push(ratatui::text::Line::raw(""));
1118 lines.push(ratatui::text::Line::styled("payload:", muted));
1119 for chunk in a.payload_json.lines().take(4) {
1120 lines.push(ratatui::text::Line::raw(chunk.to_string()));
1121 }
1122 }
1123 if let Some(err) = &app.approval_error {
1124 lines.push(ratatui::text::Line::raw(""));
1125 lines.push(ratatui::text::Line::styled(
1126 format!("error: {err}"),
1127 Style::default().fg(app.capabilities.accent()),
1128 ));
1129 }
1130 lines.push(ratatui::text::Line::raw(""));
1131 lines.push(ratatui::text::Line::styled(
1132 "[y] approve · [Shift-N] deny · [j/k] cycle · [Esc] close",
1133 muted,
1134 ));
1135 Paragraph::new(lines).render(inner, buf);
1136}
1137
1138fn draw_quit_confirm(f: &mut Frame<'_>, area: Rect) {
1139 let popup_w = 36u16.min(area.width.saturating_sub(2));
1140 let popup_h = 5u16.min(area.height.saturating_sub(2));
1141 let popup = centered_rect(popup_w, popup_h, area);
1142 let buf = f.buffer_mut();
1143 Clear.render(popup, buf);
1144 Paragraph::new("Quit teamctl-ui? [y / n]")
1145 .alignment(Alignment::Center)
1146 .block(Block::default().borders(Borders::ALL).title("confirm"))
1147 .render(popup, buf);
1148}
1149
1150fn centered_rect(w: u16, h: u16, area: Rect) -> Rect {
1151 let x = area.x + area.width.saturating_sub(w) / 2;
1152 let y = area.y + area.height.saturating_sub(h) / 2;
1153 Rect {
1154 x,
1155 y,
1156 width: w,
1157 height: h,
1158 }
1159}
1160
1161pub fn handle_event<D: ApprovalDecider, S: MessageSender, M: MailboxSource>(
1162 app: &mut App,
1163 ev: Event,
1164 decider: &D,
1165 sender: &S,
1166 mailbox_source: &M,
1167) {
1168 use crossterm::event::KeyModifiers;
1169 match ev {
1170 Event::Key(k) if k.kind == KeyEventKind::Press => match app.stage {
1171 Stage::Splash => app.dismiss_splash(),
1172 Stage::Triptych => match k.code {
1173 KeyCode::Char('q') if app.pending_chord == Some(KeyCode::Char('w')) => {
1178 app.pending_chord = None;
1179 app.close_focused_split();
1180 }
1181 KeyCode::Char('o') if app.pending_chord == Some(KeyCode::Char('w')) => {
1182 app.pending_chord = None;
1183 if !app.detail_splits.is_empty() {
1184 let keep = app.selected_split.min(app.detail_splits.len() - 1);
1185 let kept = app.detail_splits.remove(keep);
1186 app.detail_splits.clear();
1187 app.detail_splits.push(kept);
1188 app.selected_split = 0;
1189 }
1190 }
1191 KeyCode::Char('q') if k.modifiers.is_empty() => app.enter_quit_confirm(),
1192 KeyCode::Char('a') => app.enter_approvals_modal(),
1196 KeyCode::Char('@') => app.enter_compose_dm_for_focused(),
1201 KeyCode::Char('!') => app.enter_compose_broadcast_with_picker(),
1202 KeyCode::Char('w') | KeyCode::Char('W')
1212 if k.modifiers.contains(KeyModifiers::CONTROL)
1213 && !app.detail_splits.is_empty() =>
1214 {
1215 app.pending_chord = Some(KeyCode::Char('w'))
1216 }
1217 KeyCode::Char('w') | KeyCode::Char('W')
1222 if k.modifiers.contains(KeyModifiers::CONTROL) =>
1223 {
1224 app.toggle_wall_layout()
1225 }
1226 KeyCode::Char('m') | KeyCode::Char('M')
1227 if k.modifiers.contains(KeyModifiers::CONTROL) =>
1228 {
1229 app.toggle_mailbox_first_layout()
1230 }
1231 KeyCode::Char('|') if k.modifiers.contains(KeyModifiers::CONTROL) => {
1235 app.add_detail_split_vertical()
1236 }
1237 KeyCode::Char('-') if k.modifiers.contains(KeyModifiers::CONTROL) => {
1238 app.add_detail_split_horizontal()
1239 }
1240 KeyCode::Char('h')
1245 | KeyCode::Char('H')
1246 | KeyCode::Char('k')
1247 | KeyCode::Char('K')
1248 if k.modifiers.contains(KeyModifiers::CONTROL) =>
1249 {
1250 app.cycle_split_prev()
1251 }
1252 KeyCode::Char('l')
1253 | KeyCode::Char('L')
1254 | KeyCode::Char('j')
1255 | KeyCode::Char('J')
1256 if k.modifiers.contains(KeyModifiers::CONTROL) =>
1257 {
1258 app.cycle_split_next()
1259 }
1260 KeyCode::Char('q') | KeyCode::Char('Q')
1265 if k.modifiers.contains(KeyModifiers::CONTROL) =>
1266 {
1267 app.close_focused_split()
1268 }
1269 KeyCode::Char('?')
1277 if k.modifiers.is_empty() || k.modifiers == KeyModifiers::SHIFT =>
1278 {
1279 app.enter_help_overlay()
1280 }
1281 KeyCode::Char('t') if k.modifiers.is_empty() => app.enter_tutorial(),
1282 KeyCode::BackTab => app.cycle_focus_back(),
1286 KeyCode::Tab if k.modifiers.contains(KeyModifiers::SHIFT) => app.cycle_focus_back(),
1287 KeyCode::Tab => app.cycle_focus(),
1295 KeyCode::Char(']') if app.focused_pane == Pane::Mailbox => app.cycle_mailbox_tab(),
1302 KeyCode::Char('[') if app.focused_pane == Pane::Mailbox => {
1303 app.cycle_mailbox_tab_back()
1304 }
1305 KeyCode::Up | KeyCode::Char('k') if matches!(app.layout, MainLayout::Wall) => {
1310 app.wall_scroll_up()
1311 }
1312 KeyCode::Down | KeyCode::Char('j') if matches!(app.layout, MainLayout::Wall) => {
1313 app.wall_scroll_down()
1314 }
1315 KeyCode::Up | KeyCode::Char('k')
1318 if matches!(app.layout, MainLayout::MailboxFirst) =>
1319 {
1320 app.select_prev_channel()
1321 }
1322 KeyCode::Down | KeyCode::Char('j')
1323 if matches!(app.layout, MainLayout::MailboxFirst) =>
1324 {
1325 app.select_next_channel()
1326 }
1327 KeyCode::Up | KeyCode::Char('k') if app.focused_pane == Pane::Roster => {
1331 app.select_prev()
1332 }
1333 KeyCode::Down | KeyCode::Char('j') if app.focused_pane == Pane::Roster => {
1334 app.select_next()
1335 }
1336 _ => {}
1337 },
1338 Stage::QuitConfirm => match k.code {
1339 KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => app.confirm_quit(),
1340 KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => app.cancel_quit(),
1341 _ => {}
1342 },
1343 Stage::ApprovalsModal => match k.code {
1344 KeyCode::Char('y') | KeyCode::Char('Y') => {
1353 app.apply_decision(decider, Decision::Approve, "")
1354 }
1355 KeyCode::Char('N') => app.apply_decision(decider, Decision::Deny, ""),
1356 KeyCode::Char('j') | KeyCode::Down => app.cycle_approval_next(),
1357 KeyCode::Char('k') | KeyCode::Up => app.cycle_approval_prev(),
1358 KeyCode::Esc | KeyCode::Char('q') => app.close_approvals_modal(),
1359 _ => {}
1360 },
1361 Stage::ComposeModal => {
1362 if app.compose_picker_open {
1366 match k.code {
1367 KeyCode::Down | KeyCode::Char('j') => app.picker_next(),
1368 KeyCode::Up | KeyCode::Char('k') => app.picker_prev(),
1369 KeyCode::Enter => app.picker_confirm(),
1370 KeyCode::Esc => {
1379 app.compose_picker_open = false;
1380 app.compose_picker_index = 0;
1381 }
1382 _ => {}
1383 }
1384 } else {
1385 match app.compose_editor.apply_key(k) {
1388 EditorAction::Continue => {}
1389 EditorAction::Send => app.apply_send(sender, mailbox_source),
1390 EditorAction::Cancel => app.close_compose_modal(),
1391 }
1392 }
1393 }
1394 Stage::HelpOverlay => match k.code {
1395 KeyCode::Esc | KeyCode::Char('?') | KeyCode::Char('q') => app.close_help_overlay(),
1396 _ => {}
1397 },
1398 Stage::Tutorial => match k.code {
1399 KeyCode::Esc => app.close_tutorial(),
1400 KeyCode::Char('k') | KeyCode::Up | KeyCode::Char('p') => app.tutorial_back(),
1401 _ => app.tutorial_advance(),
1402 },
1403 },
1404 Event::Resize(_, _) => {
1405 }
1407 _ => {}
1408 }
1409}
1410
1411pub fn render_to_buffer(app: &App, width: u16, height: u16) -> Buffer {
1415 let area = Rect::new(0, 0, width, height);
1416 let mut buf = Buffer::empty(area);
1417 match app.stage {
1418 Stage::Splash => splash::Splash { app }.render(area, &mut buf),
1419 Stage::Triptych => render_main(app, area, &mut buf),
1420 Stage::QuitConfirm => {
1421 render_main(app, area, &mut buf);
1422 render_quit_confirm(area, &mut buf);
1423 }
1424 Stage::ApprovalsModal => {
1425 render_main(app, area, &mut buf);
1426 render_approvals_modal(area, &mut buf, app);
1427 }
1428 Stage::ComposeModal => {
1429 render_main(app, area, &mut buf);
1430 render_compose_modal(area, &mut buf, app);
1431 }
1432 Stage::HelpOverlay => {
1433 render_main(app, area, &mut buf);
1434 render_help_overlay(area, &mut buf, app);
1435 }
1436 Stage::Tutorial => {
1437 render_main(app, area, &mut buf);
1438 render_tutorial(area, &mut buf, app);
1439 }
1440 }
1441 buf
1442}
1443
1444fn render_main(app: &App, area: Rect, buf: &mut Buffer) {
1445 let chunks = Layout::default()
1446 .direction(Direction::Vertical)
1447 .constraints([Constraint::Min(3), Constraint::Length(1)])
1448 .split(area);
1449 match app.layout {
1450 crate::triptych::MainLayout::Triptych => {
1451 triptych::Triptych { app }.render(chunks[0], buf);
1452 }
1453 crate::triptych::MainLayout::Wall => {
1454 layouts::Wall { app }.render(chunks[0], buf);
1455 }
1456 crate::triptych::MainLayout::MailboxFirst => {
1457 layouts::MailboxFirst { app }.render(chunks[0], buf);
1458 }
1459 }
1460 statusline::Statusline { app }.render(chunks[1], buf);
1461}
1462
1463fn render_quit_confirm(area: Rect, buf: &mut Buffer) {
1464 let popup_w = 36u16.min(area.width.saturating_sub(2));
1465 let popup_h = 5u16.min(area.height.saturating_sub(2));
1466 let popup = centered_rect(popup_w, popup_h, area);
1467 Clear.render(popup, buf);
1468 Paragraph::new("Quit teamctl-ui? [y / n]")
1469 .alignment(Alignment::Center)
1470 .block(Block::default().borders(Borders::ALL).title("confirm"))
1471 .render(popup, buf);
1472}
1473
1474#[cfg(test)]
1475mod tests {
1476 use super::*;
1477 use crate::data::AgentInfo;
1478 use crossterm::event::{KeyEvent, KeyEventState, KeyModifiers};
1479 use team_core::supervisor::AgentState;
1480
1481 fn key(code: KeyCode) -> Event {
1482 Event::Key(KeyEvent {
1483 code,
1484 modifiers: KeyModifiers::NONE,
1485 kind: KeyEventKind::Press,
1486 state: KeyEventState::NONE,
1487 })
1488 }
1489
1490 fn key_with(code: KeyCode, modifiers: KeyModifiers) -> Event {
1491 Event::Key(KeyEvent {
1492 code,
1493 modifiers,
1494 kind: KeyEventKind::Press,
1495 state: KeyEventState::NONE,
1496 })
1497 }
1498
1499 struct NoopDecider;
1501 impl crate::approvals::ApprovalDecider for NoopDecider {
1502 fn decide(
1503 &self,
1504 _root: &std::path::Path,
1505 _id: i64,
1506 _kind: crate::approvals::Decision,
1507 _note: &str,
1508 ) -> anyhow::Result<()> {
1509 Ok(())
1510 }
1511 }
1512
1513 struct NoopSender;
1515 impl crate::compose::MessageSender for NoopSender {
1516 fn send_dm(
1517 &self,
1518 _root: &std::path::Path,
1519 _agent: &str,
1520 _body: &str,
1521 ) -> anyhow::Result<()> {
1522 Ok(())
1523 }
1524 fn broadcast(
1525 &self,
1526 _root: &std::path::Path,
1527 _channel: &str,
1528 _body: &str,
1529 ) -> anyhow::Result<()> {
1530 Ok(())
1531 }
1532 }
1533
1534 struct EmptyMailbox;
1537 impl crate::mailbox::MailboxSource for EmptyMailbox {
1538 fn inbox(&self, _id: &str, _after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
1539 Ok(Vec::new())
1540 }
1541 fn channel_feed(
1542 &self,
1543 _id: &str,
1544 _after: i64,
1545 ) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
1546 Ok(Vec::new())
1547 }
1548 fn wire(&self, _id: &str, _after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
1549 Ok(Vec::new())
1550 }
1551 }
1552
1553 fn dispatch(app: &mut App, ev: Event) {
1556 super::handle_event(app, ev, &NoopDecider, &NoopSender, &EmptyMailbox);
1557 }
1558
1559 fn agent(id: &str, state: AgentState) -> AgentInfo {
1560 AgentInfo {
1561 id: id.into(),
1562 agent: id
1563 .split_once(':')
1564 .map(|(_, a)| a.to_string())
1565 .unwrap_or_default(),
1566 project: id
1567 .split_once(':')
1568 .map(|(p, _)| p.to_string())
1569 .unwrap_or_default(),
1570 tmux_session: format!("t-{}", id.replace(':', "-")),
1571 state,
1572 unread_mail: 0,
1573 pending_approvals: 0,
1574 is_manager: false,
1575 }
1576 }
1577
1578 pub fn fixture_team(agents: Vec<AgentInfo>) -> TeamSnapshot {
1579 TeamSnapshot {
1580 root: std::path::PathBuf::from("/fixture"),
1581 team_name: "fixture".into(),
1582 agents,
1583 channels: Vec::new(),
1584 }
1585 }
1586
1587 #[test]
1588 fn splash_dismissed_by_any_key() {
1589 let mut app = App::new();
1590 assert_eq!(app.stage, Stage::Splash);
1591 dispatch(&mut app, key(KeyCode::Char(' ')));
1592 assert_eq!(app.stage, Stage::Triptych);
1593 }
1594
1595 #[test]
1596 fn tab_cycles_panes_uniformly_and_wraps_through_mailbox() {
1597 let mut app = App::new();
1604 app.dismiss_splash();
1605 assert_eq!(app.focused_pane, Pane::Roster);
1606 dispatch(&mut app, key(KeyCode::Tab));
1607 assert_eq!(app.focused_pane, Pane::Detail);
1608 dispatch(&mut app, key(KeyCode::Tab));
1609 assert_eq!(app.focused_pane, Pane::Mailbox);
1610 assert_eq!(
1611 app.mailbox_tab,
1612 MailboxTab::Inbox,
1613 "Tab into mailbox does NOT touch the active mailbox tab"
1614 );
1615 dispatch(&mut app, key(KeyCode::Tab));
1616 assert_eq!(
1617 app.focused_pane,
1618 Pane::Roster,
1619 "Tab from mailbox wraps to roster, not into mailbox subtabs"
1620 );
1621 assert_eq!(
1622 app.mailbox_tab,
1623 MailboxTab::Inbox,
1624 "mailbox tab still untouched"
1625 );
1626 }
1627
1628 #[test]
1629 fn bracket_chords_walk_mailbox_tabs_when_mailbox_focused() {
1630 let mut app = App::new();
1635 app.dismiss_splash();
1636 dispatch(&mut app, key(KeyCode::Tab));
1638 dispatch(&mut app, key(KeyCode::Tab));
1639 assert_eq!(app.focused_pane, Pane::Mailbox);
1640 assert_eq!(app.mailbox_tab, MailboxTab::Inbox);
1641
1642 dispatch(&mut app, key(KeyCode::Char(']')));
1643 assert_eq!(app.mailbox_tab, MailboxTab::Channel);
1644 dispatch(&mut app, key(KeyCode::Char(']')));
1645 assert_eq!(app.mailbox_tab, MailboxTab::Wire);
1646 dispatch(&mut app, key(KeyCode::Char(']')));
1647 assert_eq!(app.mailbox_tab, MailboxTab::Inbox, "] wraps");
1648
1649 dispatch(&mut app, key(KeyCode::Char('[')));
1650 assert_eq!(app.mailbox_tab, MailboxTab::Wire, "[ walks back");
1651 }
1652
1653 #[test]
1654 fn bracket_chords_no_op_when_mailbox_not_focused() {
1655 let mut app = App::new();
1658 app.dismiss_splash();
1659 assert_eq!(app.focused_pane, Pane::Roster);
1660 let initial = app.mailbox_tab;
1661 dispatch(&mut app, key(KeyCode::Char(']')));
1662 dispatch(&mut app, key(KeyCode::Char('[')));
1663 assert_eq!(
1664 app.mailbox_tab, initial,
1665 "[/] from non-mailbox panes must not flip the active tab"
1666 );
1667 }
1668
1669 #[test]
1670 fn q_opens_confirm_then_n_cancels() {
1671 let mut app = App::new();
1672 app.dismiss_splash();
1673 dispatch(&mut app, key(KeyCode::Char('q')));
1674 assert_eq!(app.stage, Stage::QuitConfirm);
1675 dispatch(&mut app, key(KeyCode::Char('n')));
1676 assert_eq!(app.stage, Stage::Triptych);
1677 assert!(app.running, "n must not exit");
1678 }
1679
1680 #[test]
1681 fn q_then_y_exits() {
1682 let mut app = App::new();
1683 app.dismiss_splash();
1684 dispatch(&mut app, key(KeyCode::Char('q')));
1685 dispatch(&mut app, key(KeyCode::Char('y')));
1686 assert!(!app.running);
1687 }
1688
1689 #[test]
1690 fn esc_cancels_quit_confirm() {
1691 let mut app = App::new();
1692 app.dismiss_splash();
1693 app.enter_quit_confirm();
1694 dispatch(&mut app, key(KeyCode::Esc));
1695 assert_eq!(app.stage, Stage::Triptych);
1696 }
1697
1698 #[test]
1699 fn render_does_not_panic_at_minimal_size() {
1700 let app = App::new();
1701 let _ = render_to_buffer(&app, 20, 8);
1702 }
1703
1704 #[test]
1705 fn render_does_not_panic_at_huge_size() {
1706 let app = App::new();
1707 let _ = render_to_buffer(&app, 240, 80);
1708 }
1709
1710 #[test]
1711 fn select_next_wraps_through_team() {
1712 let mut app = App::new();
1713 app.replace_team(fixture_team(vec![
1714 agent("p:a", AgentState::Running),
1715 agent("p:b", AgentState::Running),
1716 agent("p:c", AgentState::Running),
1717 ]));
1718 assert_eq!(app.selected_agent, Some(0));
1719 app.select_next();
1720 assert_eq!(app.selected_agent, Some(1));
1721 app.select_next();
1722 assert_eq!(app.selected_agent, Some(2));
1723 app.select_next();
1724 assert_eq!(app.selected_agent, Some(0)); }
1726
1727 #[test]
1728 fn select_prev_wraps_at_top() {
1729 let mut app = App::new();
1730 app.replace_team(fixture_team(vec![
1731 agent("p:a", AgentState::Running),
1732 agent("p:b", AgentState::Running),
1733 ]));
1734 app.selected_agent = Some(0);
1735 app.select_prev();
1736 assert_eq!(app.selected_agent, Some(1));
1737 }
1738
1739 #[test]
1740 fn select_no_op_on_empty_team() {
1741 let mut app = App::new();
1742 app.select_next();
1743 assert_eq!(app.selected_agent, None);
1744 app.select_prev();
1745 assert_eq!(app.selected_agent, None);
1746 }
1747
1748 #[test]
1749 fn replace_team_preserves_selection_when_agent_still_present() {
1750 let mut app = App::new();
1751 app.replace_team(fixture_team(vec![
1752 agent("p:a", AgentState::Running),
1753 agent("p:b", AgentState::Running),
1754 ]));
1755 app.selected_agent = Some(1);
1756 app.replace_team(fixture_team(vec![
1757 agent("p:a", AgentState::Running),
1758 agent("p:b", AgentState::Stopped), ]));
1760 assert_eq!(app.selected_agent, Some(1), "selection follows the id");
1761 }
1762
1763 #[test]
1764 fn replace_team_resets_selection_when_agent_disappears() {
1765 let mut app = App::new();
1766 app.replace_team(fixture_team(vec![
1767 agent("p:a", AgentState::Running),
1768 agent("p:gone", AgentState::Running),
1769 ]));
1770 app.selected_agent = Some(1);
1771 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
1772 assert_eq!(app.selected_agent, Some(0), "falls back to first agent");
1773 }
1774
1775 #[test]
1776 fn switching_agent_resets_mailbox_buffers() {
1777 let mut app = App::new();
1781 app.replace_team(fixture_team(vec![
1782 agent("p:a", AgentState::Running),
1783 agent("p:b", AgentState::Running),
1784 ]));
1785 app.mailbox.extend(
1786 crate::mailbox::MailboxTab::Inbox,
1787 vec![crate::mailbox::MessageRow {
1788 id: 7,
1789 sender: "p:b".into(),
1790 recipient: "p:a".into(),
1791 text: "hi".into(),
1792 sent_at: 0.0,
1793 }],
1794 );
1795 assert_eq!(app.mailbox.inbox.len(), 1);
1796 assert_eq!(app.mailbox.inbox_after, 7);
1797 app.select_next();
1799 assert_eq!(app.selected_agent_id().as_deref(), Some("p:b"));
1800 assert!(app.mailbox.inbox.is_empty());
1801 assert_eq!(app.mailbox.inbox_after, 0);
1802 }
1803
1804 struct TripleFilterMock {
1809 inbox: Vec<crate::mailbox::MessageRow>,
1810 channel: Vec<crate::mailbox::MessageRow>,
1811 wire: Vec<crate::mailbox::MessageRow>,
1812 calls: std::sync::Mutex<Vec<(&'static str, String, i64)>>,
1813 }
1814 impl crate::mailbox::MailboxSource for TripleFilterMock {
1815 fn inbox(&self, id: &str, after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
1816 self.calls.lock().unwrap().push(("inbox", id.into(), after));
1817 Ok(self.inbox.clone())
1818 }
1819 fn channel_feed(
1820 &self,
1821 id: &str,
1822 after: i64,
1823 ) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
1824 self.calls
1825 .lock()
1826 .unwrap()
1827 .push(("channel", id.into(), after));
1828 Ok(self.channel.clone())
1829 }
1830 fn wire(&self, id: &str, after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
1831 self.calls.lock().unwrap().push(("wire", id.into(), after));
1832 Ok(self.wire.clone())
1833 }
1834 }
1835
1836 #[test]
1837 fn refresh_mailbox_fans_out_to_three_filters() {
1838 use crate::mailbox::MessageRow;
1839 let mut app = App::new();
1840 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
1841 let mock = TripleFilterMock {
1842 inbox: vec![MessageRow {
1843 id: 1,
1844 sender: "p:b".into(),
1845 recipient: "p:a".into(),
1846 text: "dm".into(),
1847 sent_at: 0.0,
1848 }],
1849 channel: vec![MessageRow {
1850 id: 2,
1851 sender: "p:b".into(),
1852 recipient: "channel:p:editorial".into(),
1853 text: "ch".into(),
1854 sent_at: 0.0,
1855 }],
1856 wire: vec![MessageRow {
1857 id: 3,
1858 sender: "p:b".into(),
1859 recipient: "channel:p:all".into(),
1860 text: "wire".into(),
1861 sent_at: 0.0,
1862 }],
1863 calls: std::sync::Mutex::new(Vec::new()),
1864 };
1865 super::refresh_mailbox(&mut app, &mock);
1866 assert_eq!(app.mailbox.inbox.len(), 1);
1867 assert_eq!(app.mailbox.channel.len(), 1);
1868 assert_eq!(app.mailbox.wire.len(), 1);
1869 let calls = mock.calls.lock().unwrap();
1870 assert!(calls.contains(&("inbox", "p:a".into(), 0)));
1873 assert!(calls.contains(&("channel", "p:a".into(), 0)));
1874 assert!(calls.contains(&("wire", "p".into(), 0)));
1875 }
1876
1877 fn ap(id: i64) -> crate::approvals::Approval {
1878 crate::approvals::Approval {
1879 id,
1880 project_id: "p".into(),
1881 agent_id: "p:m".into(),
1882 action: "publish".into(),
1883 summary: format!("approval #{id}"),
1884 payload_json: String::new(),
1885 }
1886 }
1887
1888 #[test]
1889 fn has_pending_approvals_tracks_replace_calls() {
1890 let mut app = App::new();
1891 assert!(!app.has_pending_approvals());
1892 app.replace_approvals(vec![ap(1), ap(2)]);
1893 assert!(app.has_pending_approvals());
1894 app.replace_approvals(vec![]);
1895 assert!(!app.has_pending_approvals());
1896 }
1897
1898 #[test]
1899 fn enter_approvals_modal_no_op_when_queue_empty() {
1900 let mut app = App::new();
1901 app.dismiss_splash();
1902 app.enter_approvals_modal();
1903 assert_eq!(app.stage, Stage::Triptych, "no pending → no modal");
1904 }
1905
1906 #[test]
1907 fn a_chord_opens_modal_when_pending() {
1908 let mut app = App::new();
1909 app.dismiss_splash();
1910 app.replace_approvals(vec![ap(1), ap(2)]);
1911 dispatch(&mut app, key(KeyCode::Char('a')));
1912 assert_eq!(app.stage, Stage::ApprovalsModal);
1913 assert_eq!(app.selected_approval, 0);
1914 }
1915
1916 #[test]
1917 fn modal_cycle_jk_walks_approvals() {
1918 let mut app = App::new();
1919 app.dismiss_splash();
1920 app.replace_approvals(vec![ap(1), ap(2), ap(3)]);
1921 app.enter_approvals_modal();
1922 dispatch(&mut app, key(KeyCode::Char('j')));
1923 assert_eq!(app.selected_approval, 1);
1924 dispatch(&mut app, key(KeyCode::Char('j')));
1925 assert_eq!(app.selected_approval, 2);
1926 dispatch(&mut app, key(KeyCode::Char('j')));
1927 assert_eq!(app.selected_approval, 0, "wraps");
1928 dispatch(&mut app, key(KeyCode::Char('k')));
1929 assert_eq!(app.selected_approval, 2, "k wraps too");
1930 }
1931
1932 #[test]
1933 fn capital_y_routes_approve_through_decider() {
1934 use crate::approvals::test_support::MockApprovalDecider;
1935 let dec = MockApprovalDecider::default();
1936 let mut app = App::new();
1937 app.dismiss_splash();
1938 app.replace_approvals(vec![ap(7), ap(8)]);
1939 app.enter_approvals_modal();
1940 super::handle_event(
1941 &mut app,
1942 key(KeyCode::Char('Y')),
1943 &dec,
1944 &NoopSender,
1945 &EmptyMailbox,
1946 );
1947 let calls = dec.calls.lock().unwrap().clone();
1948 assert_eq!(calls.len(), 1);
1949 assert_eq!(calls[0].0, 7);
1950 assert_eq!(calls[0].1, crate::approvals::Decision::Approve);
1951 assert_eq!(app.pending_approvals.len(), 1);
1953 assert_eq!(app.pending_approvals[0].id, 8);
1954 }
1955
1956 #[test]
1957 fn capital_n_routes_deny_through_decider() {
1958 use crate::approvals::test_support::MockApprovalDecider;
1959 let dec = MockApprovalDecider::default();
1960 let mut app = App::new();
1961 app.dismiss_splash();
1962 app.replace_approvals(vec![ap(7)]);
1963 app.enter_approvals_modal();
1964 super::handle_event(
1965 &mut app,
1966 key(KeyCode::Char('N')),
1967 &dec,
1968 &NoopSender,
1969 &EmptyMailbox,
1970 );
1971 let calls = dec.calls.lock().unwrap().clone();
1972 assert_eq!(calls.len(), 1);
1973 assert_eq!(calls[0].1, crate::approvals::Decision::Deny);
1974 assert_eq!(app.stage, Stage::Triptych);
1976 }
1977
1978 #[test]
1979 fn esc_closes_approvals_modal() {
1980 let mut app = App::new();
1981 app.dismiss_splash();
1982 app.replace_approvals(vec![ap(1)]);
1983 app.enter_approvals_modal();
1984 dispatch(&mut app, key(KeyCode::Esc));
1985 assert_eq!(app.stage, Stage::Triptych);
1986 }
1987
1988 #[test]
1989 fn lowercase_y_routes_approve_through_decider() {
1990 use crate::approvals::test_support::MockApprovalDecider;
1994 let dec = MockApprovalDecider::default();
1995 let mut app = App::new();
1996 app.dismiss_splash();
1997 app.replace_approvals(vec![ap(7)]);
1998 app.enter_approvals_modal();
1999 super::handle_event(
2000 &mut app,
2001 key(KeyCode::Char('y')),
2002 &dec,
2003 &NoopSender,
2004 &EmptyMailbox,
2005 );
2006 let calls = dec.calls.lock().unwrap().clone();
2007 assert_eq!(calls.len(), 1);
2008 assert_eq!(calls[0].1, crate::approvals::Decision::Approve);
2009 }
2010
2011 #[test]
2012 fn lowercase_n_does_not_deny() {
2013 use crate::approvals::test_support::MockApprovalDecider;
2018 let dec = MockApprovalDecider::default();
2019 let mut app = App::new();
2020 app.dismiss_splash();
2021 app.replace_approvals(vec![ap(7)]);
2022 app.enter_approvals_modal();
2023 super::handle_event(
2024 &mut app,
2025 key(KeyCode::Char('n')),
2026 &dec,
2027 &NoopSender,
2028 &EmptyMailbox,
2029 );
2030 assert!(
2031 dec.calls.lock().unwrap().is_empty(),
2032 "lowercase n must not route through the decider"
2033 );
2034 assert_eq!(
2035 app.stage,
2036 Stage::ApprovalsModal,
2037 "stale lowercase n leaves the modal open"
2038 );
2039 }
2040
2041 #[test]
2042 fn shift_tab_cycles_panes_backward() {
2043 use crossterm::event::KeyModifiers;
2044 let mut app = App::new();
2045 app.dismiss_splash();
2046 assert_eq!(app.focused_pane, Pane::Roster);
2047 dispatch(&mut app, key(KeyCode::BackTab));
2050 assert_eq!(app.focused_pane, Pane::Mailbox);
2051 dispatch(&mut app, key_with(KeyCode::Tab, KeyModifiers::SHIFT));
2053 assert_eq!(app.focused_pane, Pane::Detail);
2054 }
2055
2056 #[test]
2057 fn at_chord_opens_compose_dm_to_focused_agent() {
2058 let mut app = App::new();
2059 app.replace_team(fixture_team(vec![
2060 agent("writing:manager", AgentState::Running),
2061 agent("writing:dev1", AgentState::Running),
2062 ]));
2063 app.dismiss_splash();
2064 app.select_next();
2065 dispatch(&mut app, key(KeyCode::Char('@')));
2066 assert_eq!(app.stage, Stage::ComposeModal);
2067 match app.compose_target.as_ref() {
2068 Some(crate::compose::ComposeTarget::Dm { agent_id, .. }) => {
2069 assert_eq!(agent_id, "writing:dev1");
2070 }
2071 other => panic!("expected DM target, got {other:?}"),
2072 }
2073 }
2074
2075 #[test]
2076 fn bang_chord_opens_compose_broadcast_to_all_channel() {
2077 let mut app = App::new();
2078 app.replace_team(fixture_team(vec![agent(
2079 "writing:manager",
2080 AgentState::Running,
2081 )]));
2082 app.dismiss_splash();
2083 dispatch(&mut app, key(KeyCode::Char('!')));
2084 assert_eq!(app.stage, Stage::ComposeModal);
2085 match app.compose_target.as_ref() {
2086 Some(crate::compose::ComposeTarget::Broadcast { channel_id, .. }) => {
2087 assert_eq!(channel_id, "writing:all");
2088 }
2089 other => panic!("expected Broadcast target, got {other:?}"),
2090 }
2091 }
2092
2093 #[test]
2094 fn send_routes_dm_through_mock_sender() {
2095 use crate::compose::test_support::MockMessageSender;
2096 let sender = MockMessageSender::default();
2097 let mailbox = EmptyMailbox;
2098 let mut app = App::new();
2099 app.replace_team(fixture_team(vec![agent(
2100 "writing:dev1",
2101 AgentState::Running,
2102 )]));
2103 app.dismiss_splash();
2104 app.enter_compose_dm_for_focused();
2105 for c in "ship it".chars() {
2106 super::handle_event(
2107 &mut app,
2108 key(KeyCode::Char(c)),
2109 &NoopDecider,
2110 &sender,
2111 &mailbox,
2112 );
2113 }
2114 super::handle_event(
2115 &mut app,
2116 key_with(KeyCode::Enter, crossterm::event::KeyModifiers::CONTROL),
2117 &NoopDecider,
2118 &sender,
2119 &mailbox,
2120 );
2121 let calls = sender.dm_calls.lock().unwrap().clone();
2122 assert_eq!(calls.len(), 1);
2123 assert_eq!(calls[0].0, "writing:dev1");
2124 assert_eq!(calls[0].1, "ship it");
2125 assert_eq!(app.stage, Stage::Triptych, "modal closes on send");
2126 }
2127
2128 #[test]
2129 fn esc_esc_cancels_compose_without_send() {
2130 use crate::compose::test_support::MockMessageSender;
2131 let sender = MockMessageSender::default();
2132 let mailbox = EmptyMailbox;
2133 let mut app = App::new();
2134 app.replace_team(fixture_team(vec![agent(
2135 "writing:dev1",
2136 AgentState::Running,
2137 )]));
2138 app.dismiss_splash();
2139 app.enter_compose_dm_for_focused();
2140 for c in "draft".chars() {
2141 super::handle_event(
2142 &mut app,
2143 key(KeyCode::Char(c)),
2144 &NoopDecider,
2145 &sender,
2146 &mailbox,
2147 );
2148 }
2149 super::handle_event(&mut app, key(KeyCode::Esc), &NoopDecider, &sender, &mailbox);
2150 super::handle_event(&mut app, key(KeyCode::Esc), &NoopDecider, &sender, &mailbox);
2151 assert_eq!(app.stage, Stage::Triptych);
2152 assert!(sender.dm_calls.lock().unwrap().is_empty());
2153 }
2154
2155 #[test]
2156 fn send_failure_surfaces_error_inline_keeps_modal_open() {
2157 use crate::compose::test_support::MockMessageSender;
2158 let sender = MockMessageSender::default();
2159 *sender.fail_next.lock().unwrap() = Some("rate limit".into());
2160 let mailbox = EmptyMailbox;
2161 let mut app = App::new();
2162 app.replace_team(fixture_team(vec![agent(
2163 "writing:dev1",
2164 AgentState::Running,
2165 )]));
2166 app.dismiss_splash();
2167 app.enter_compose_dm_for_focused();
2168 super::handle_event(
2169 &mut app,
2170 key(KeyCode::Char('x')),
2171 &NoopDecider,
2172 &sender,
2173 &mailbox,
2174 );
2175 super::handle_event(
2176 &mut app,
2177 key_with(KeyCode::Enter, crossterm::event::KeyModifiers::CONTROL),
2178 &NoopDecider,
2179 &sender,
2180 &mailbox,
2181 );
2182 assert_eq!(app.stage, Stage::ComposeModal, "modal stays open on err");
2183 assert!(app
2184 .compose_error
2185 .as_deref()
2186 .unwrap_or_default()
2187 .contains("rate limit"));
2188 }
2189
2190 fn channel(id: &str, project: &str) -> crate::data::ChannelInfo {
2191 crate::data::ChannelInfo {
2192 id: id.into(),
2193 name: id
2194 .rsplit_once(':')
2195 .map(|(_, n)| n.to_string())
2196 .unwrap_or_default(),
2197 project_id: project.into(),
2198 }
2199 }
2200
2201 fn fixture_team_with_channels(
2202 agents: Vec<AgentInfo>,
2203 channels: Vec<crate::data::ChannelInfo>,
2204 ) -> TeamSnapshot {
2205 TeamSnapshot {
2206 root: std::path::PathBuf::from("/fixture"),
2207 team_name: "fixture".into(),
2208 agents,
2209 channels,
2210 }
2211 }
2212
2213 #[test]
2214 fn ctrl_w_toggles_wall_layout() {
2215 use crossterm::event::KeyModifiers;
2216 let mut app = App::new();
2217 app.dismiss_splash();
2218 assert_eq!(app.layout, MainLayout::Triptych);
2219 dispatch(
2220 &mut app,
2221 key_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
2222 );
2223 assert_eq!(app.layout, MainLayout::Wall);
2224 dispatch(
2225 &mut app,
2226 key_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
2227 );
2228 assert_eq!(app.layout, MainLayout::Triptych);
2229 }
2230
2231 #[test]
2232 fn ctrl_m_toggles_mailbox_first_layout() {
2233 use crossterm::event::KeyModifiers;
2234 let mut app = App::new();
2235 app.dismiss_splash();
2236 dispatch(
2237 &mut app,
2238 key_with(KeyCode::Char('m'), KeyModifiers::CONTROL),
2239 );
2240 assert_eq!(app.layout, MainLayout::MailboxFirst);
2241 dispatch(
2242 &mut app,
2243 key_with(KeyCode::Char('m'), KeyModifiers::CONTROL),
2244 );
2245 assert_eq!(app.layout, MainLayout::Triptych);
2246 }
2247
2248 #[test]
2249 fn wall_scroll_pages_through_overflow_agents() {
2250 let mut app = App::new();
2251 let mut agents: Vec<_> = (1..=10)
2252 .map(|i| agent(&format!("p:agent-{i:02}"), AgentState::Running))
2253 .collect();
2254 for a in agents.iter_mut() {
2256 a.is_manager = false;
2257 }
2258 app.replace_team(fixture_team(agents));
2259 app.dismiss_splash();
2260 app.toggle_wall_layout();
2261 assert_eq!(app.wall_scroll, 0);
2262 app.wall_scroll_down();
2263 assert_eq!(app.wall_scroll, 4);
2264 app.wall_scroll_down();
2265 assert_eq!(app.wall_scroll, 8);
2266 app.wall_scroll_down();
2268 assert_eq!(app.wall_scroll, 8, "scroll capped at last full window");
2269 app.wall_scroll_up();
2270 assert_eq!(app.wall_scroll, 4);
2271 }
2272
2273 #[test]
2274 fn ctrl_pipe_adds_detail_split_capped_at_four() {
2275 use crossterm::event::KeyModifiers;
2276 let mut app = App::new();
2277 app.replace_team(fixture_team(vec![
2278 agent("p:a", AgentState::Running),
2279 agent("p:b", AgentState::Running),
2280 ]));
2281 app.dismiss_splash();
2282 for _ in 0..6 {
2283 dispatch(
2284 &mut app,
2285 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
2286 );
2287 }
2288 assert_eq!(app.detail_splits.len(), 4, "split count capped at 4");
2289 }
2290
2291 #[test]
2292 fn ctrl_q_closes_focused_split() {
2293 use crossterm::event::KeyModifiers;
2294 let mut app = App::new();
2295 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
2296 app.dismiss_splash();
2297 dispatch(
2298 &mut app,
2299 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
2300 );
2301 dispatch(
2302 &mut app,
2303 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
2304 );
2305 assert_eq!(app.detail_splits.len(), 2);
2306 dispatch(
2307 &mut app,
2308 key_with(KeyCode::Char('Q'), KeyModifiers::CONTROL),
2309 );
2310 assert_eq!(app.detail_splits.len(), 1);
2311 }
2312
2313 #[test]
2314 fn ctrl_hjkl_cycles_splits() {
2315 use crossterm::event::KeyModifiers;
2316 let mut app = App::new();
2317 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
2318 app.dismiss_splash();
2319 for _ in 0..3 {
2320 dispatch(
2321 &mut app,
2322 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
2323 );
2324 }
2325 assert_eq!(app.selected_split, 2);
2326 dispatch(
2327 &mut app,
2328 key_with(KeyCode::Char('l'), KeyModifiers::CONTROL),
2329 );
2330 assert_eq!(app.selected_split, 0, "wraps");
2331 dispatch(
2332 &mut app,
2333 key_with(KeyCode::Char('h'), KeyModifiers::CONTROL),
2334 );
2335 assert_eq!(app.selected_split, 2);
2336 }
2337
2338 #[test]
2339 fn wall_scroll_at_exactly_cap_agents_does_not_scroll() {
2340 let mut app = App::new();
2345 let agents: Vec<_> = (1..=4)
2346 .map(|i| agent(&format!("p:agent-{i}"), AgentState::Running))
2347 .collect();
2348 app.replace_team(fixture_team(agents));
2349 app.dismiss_splash();
2350 app.toggle_wall_layout();
2351 assert_eq!(app.wall_scroll, 0);
2352 app.wall_scroll_down();
2353 assert_eq!(app.wall_scroll, 0, "exactly-cap should not advance");
2354 app.wall_scroll_up();
2355 assert_eq!(app.wall_scroll, 0);
2356 }
2357
2358 #[test]
2359 fn wall_scroll_at_cap_plus_one_advances_then_stops() {
2360 let mut app = App::new();
2365 let agents: Vec<_> = (1..=5)
2366 .map(|i| agent(&format!("p:agent-{i}"), AgentState::Running))
2367 .collect();
2368 app.replace_team(fixture_team(agents));
2369 app.dismiss_splash();
2370 app.toggle_wall_layout();
2371 app.wall_scroll_down();
2372 assert_eq!(app.wall_scroll, 4, "first scroll exposes agent 5");
2373 app.wall_scroll_down();
2374 assert_eq!(app.wall_scroll, 4, "second scroll caps; nothing past");
2375 }
2376
2377 #[test]
2378 fn esc_in_picker_dismisses_overlay_only_keeps_modal_open() {
2379 let mut app = App::new();
2385 app.replace_team(fixture_team_with_channels(
2386 vec![agent("writing:manager", AgentState::Running)],
2387 vec![
2388 channel("writing:all", "writing"),
2389 channel("writing:editorial", "writing"),
2390 ],
2391 ));
2392 app.dismiss_splash();
2393 dispatch(&mut app, key(KeyCode::Char('!')));
2394 assert!(app.compose_picker_open);
2395 assert_eq!(app.stage, Stage::ComposeModal);
2396 dispatch(&mut app, key(KeyCode::Esc));
2397 assert!(!app.compose_picker_open, "picker dismissed");
2398 assert_eq!(app.stage, Stage::ComposeModal, "compose modal stays open");
2399 }
2400
2401 #[test]
2402 fn send_routes_broadcast_through_mock_sender_via_picker() {
2403 use crate::compose::test_support::MockMessageSender;
2409 let sender = MockMessageSender::default();
2410 let mailbox = EmptyMailbox;
2411 let mut app = App::new();
2412 app.replace_team(fixture_team_with_channels(
2413 vec![agent("writing:manager", AgentState::Running)],
2414 vec![
2415 channel("writing:all", "writing"),
2416 channel("writing:editorial", "writing"),
2417 channel("writing:critique", "writing"),
2418 ],
2419 ));
2420 app.dismiss_splash();
2421 super::handle_event(
2424 &mut app,
2425 key(KeyCode::Char('!')),
2426 &NoopDecider,
2427 &sender,
2428 &mailbox,
2429 );
2430 super::handle_event(
2431 &mut app,
2432 key(KeyCode::Char('j')),
2433 &NoopDecider,
2434 &sender,
2435 &mailbox,
2436 );
2437 super::handle_event(
2438 &mut app,
2439 key(KeyCode::Enter),
2440 &NoopDecider,
2441 &sender,
2442 &mailbox,
2443 );
2444 for c in "ship docs".chars() {
2445 super::handle_event(
2446 &mut app,
2447 key(KeyCode::Char(c)),
2448 &NoopDecider,
2449 &sender,
2450 &mailbox,
2451 );
2452 }
2453 super::handle_event(
2454 &mut app,
2455 key_with(KeyCode::Enter, crossterm::event::KeyModifiers::CONTROL),
2456 &NoopDecider,
2457 &sender,
2458 &mailbox,
2459 );
2460 let dm_calls = sender.dm_calls.lock().unwrap().clone();
2461 let bcast_calls = sender.broadcast_calls.lock().unwrap().clone();
2462 assert!(dm_calls.is_empty(), "broadcast must not route via send_dm");
2463 assert_eq!(bcast_calls.len(), 1);
2464 assert_eq!(
2465 bcast_calls[0].0, "writing:editorial",
2466 "channel id from picker selection"
2467 );
2468 assert_eq!(bcast_calls[0].1, "ship docs");
2469 assert_eq!(app.stage, Stage::Triptych, "modal closes on send");
2470 }
2471
2472 #[test]
2473 fn bang_chord_opens_picker_when_channels_available() {
2474 let mut app = App::new();
2475 app.replace_team(fixture_team_with_channels(
2476 vec![agent("writing:manager", AgentState::Running)],
2477 vec![
2478 channel("writing:all", "writing"),
2479 channel("writing:editorial", "writing"),
2480 channel("writing:critique", "writing"),
2481 ],
2482 ));
2483 app.dismiss_splash();
2484 dispatch(&mut app, key(KeyCode::Char('!')));
2485 assert_eq!(app.stage, Stage::ComposeModal);
2486 assert!(app.compose_picker_open);
2487 dispatch(&mut app, key(KeyCode::Char('j')));
2489 assert_eq!(app.compose_picker_index, 1);
2490 dispatch(&mut app, key(KeyCode::Enter));
2492 assert!(!app.compose_picker_open, "picker closes on confirm");
2493 match app.compose_target.as_ref() {
2494 Some(crate::compose::ComposeTarget::Broadcast { channel_id, .. }) => {
2495 assert_eq!(channel_id, "writing:editorial");
2496 }
2497 other => panic!("expected Broadcast target, got {other:?}"),
2498 }
2499 }
2500
2501 #[test]
2502 fn mailbox_first_layout_seeds_channel_selection_on_entry() {
2503 let mut app = App::new();
2504 app.replace_team(fixture_team_with_channels(
2505 vec![agent("writing:manager", AgentState::Running)],
2506 vec![
2507 channel("writing:all", "writing"),
2508 channel("writing:editorial", "writing"),
2509 ],
2510 ));
2511 app.dismiss_splash();
2512 assert!(app.selected_channel.is_none());
2513 app.toggle_mailbox_first_layout();
2514 assert_eq!(app.selected_channel, Some(0));
2515 }
2516
2517 #[test]
2518 fn help_overlay_opens_on_question_mark_closes_on_esc() {
2519 let mut app = App::new();
2520 app.dismiss_splash();
2521 dispatch(&mut app, key(KeyCode::Char('?')));
2522 assert_eq!(app.stage, Stage::HelpOverlay);
2523 dispatch(&mut app, key(KeyCode::Esc));
2524 assert_eq!(app.stage, Stage::Triptych);
2525 }
2526
2527 #[test]
2528 fn tutorial_opens_on_t_advances_and_closes() {
2529 let mut app = App::new();
2530 app.dismiss_splash();
2531 dispatch(&mut app, key(KeyCode::Char('t')));
2532 assert_eq!(app.stage, Stage::Tutorial);
2533 assert_eq!(app.tutorial_step, 0);
2534 dispatch(&mut app, key(KeyCode::Char(' ')));
2536 assert_eq!(app.tutorial_step, 1);
2537 dispatch(&mut app, key(KeyCode::Char('k')));
2539 assert_eq!(app.tutorial_step, 0);
2540 dispatch(&mut app, key(KeyCode::Esc));
2542 assert_eq!(app.stage, Stage::Triptych);
2543 }
2544
2545 #[test]
2546 fn tutorial_walk_back_at_step_zero_is_no_op() {
2547 let mut app = App::new();
2552 app.dismiss_splash();
2553 app.enter_tutorial();
2554 assert_eq!(app.tutorial_step, 0);
2555 dispatch(&mut app, key(KeyCode::Char('k')));
2556 assert_eq!(app.tutorial_step, 0, "step-0 walk-back is no-op");
2557 assert_eq!(app.stage, Stage::Tutorial);
2560 }
2561
2562 #[test]
2563 fn ctrl_pipe_adds_vertical_split_ctrl_minus_adds_horizontal() {
2564 use crossterm::event::KeyModifiers;
2565 let mut app = App::new();
2566 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
2567 app.dismiss_splash();
2568 dispatch(
2569 &mut app,
2570 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
2571 );
2572 dispatch(
2573 &mut app,
2574 key_with(KeyCode::Char('-'), KeyModifiers::CONTROL),
2575 );
2576 assert_eq!(app.detail_splits.len(), 2);
2577 assert_eq!(app.detail_splits[0].1, SplitOrientation::Vertical);
2578 assert_eq!(app.detail_splits[1].1, SplitOrientation::Horizontal);
2579 }
2580
2581 #[test]
2582 fn ctrl_w_q_chord_prefix_closes_focused_split() {
2583 use crossterm::event::KeyModifiers;
2584 let mut app = App::new();
2585 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
2586 app.dismiss_splash();
2587 dispatch(
2590 &mut app,
2591 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
2592 );
2593 dispatch(
2594 &mut app,
2595 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
2596 );
2597 dispatch(
2598 &mut app,
2599 key_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
2600 );
2601 assert_eq!(app.pending_chord, Some(KeyCode::Char('w')));
2602 dispatch(&mut app, key(KeyCode::Char('q')));
2605 assert_eq!(app.detail_splits.len(), 1);
2606 assert_eq!(app.stage, Stage::Triptych, "must not enter quit confirm");
2607 assert_eq!(app.pending_chord, None, "chord cleared");
2608 }
2609
2610 #[test]
2611 fn ctrl_w_o_chord_keeps_only_focused_split() {
2612 use crossterm::event::KeyModifiers;
2613 let mut app = App::new();
2614 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
2615 app.dismiss_splash();
2616 for _ in 0..3 {
2617 dispatch(
2618 &mut app,
2619 key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
2620 );
2621 }
2622 app.selected_split = 1;
2624 let kept_id = app.detail_splits[1].0.clone();
2625 dispatch(
2626 &mut app,
2627 key_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
2628 );
2629 dispatch(&mut app, key(KeyCode::Char('o')));
2630 assert_eq!(app.detail_splits.len(), 1);
2631 assert_eq!(app.detail_splits[0].0, kept_id);
2632 assert_eq!(app.selected_split, 0);
2633 }
2634
2635 #[test]
2636 fn add_detail_split_saturates_at_four_with_explicit_4_and_5_calls() {
2637 let mut app = App::new();
2642 app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
2643 for _ in 0..4 {
2644 app.add_detail_split();
2645 }
2646 assert_eq!(app.detail_splits.len(), 4);
2647 let snapshot_len = app.detail_splits.len();
2648 app.add_detail_split();
2649 assert_eq!(app.detail_splits.len(), snapshot_len, "5th call rejected");
2650 }
2651
2652 #[test]
2653 fn replace_approvals_clamps_selection_in_range() {
2654 let mut app = App::new();
2655 app.replace_approvals(vec![ap(1), ap(2), ap(3)]);
2656 app.selected_approval = 2;
2657 app.replace_approvals(vec![ap(1), ap(2)]);
2659 assert_eq!(app.selected_approval, 1, "clamps to last index");
2660 }
2661
2662 #[test]
2663 fn arrow_keys_navigate_only_when_roster_focused() {
2664 let mut app = App::new();
2665 app.replace_team(fixture_team(vec![
2666 agent("p:a", AgentState::Running),
2667 agent("p:b", AgentState::Running),
2668 ]));
2669 app.dismiss_splash();
2670 app.selected_agent = Some(0);
2672 dispatch(&mut app, key(KeyCode::Down));
2673 assert_eq!(app.selected_agent, Some(1));
2674 app.cycle_focus();
2676 dispatch(&mut app, key(KeyCode::Down));
2677 assert_eq!(
2678 app.selected_agent,
2679 Some(1),
2680 "non-roster focus ignores arrows"
2681 );
2682 }
2683}