Skip to main content

teamctl_ui/
app.rs

1//! App state and the top-level run loop.
2//!
3//! Three stages today: `Splash` (figlet logo for ~3s or until first
4//! key), `Triptych` (the default read view, now backed by a live
5//! team snapshot from PR-UI-2), and `QuitConfirm` (a modal asking
6//! "really?"). Subsequent stacked PRs bolt on more modals and the
7//! layout variants from SPEC §3 — those wire in by adding `Stage`
8//! variants and dispatching from `draw`/`handle_event`, no
9//! rearchitecting.
10
11use 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);
39/// How often the team snapshot + detail-pane capture get refreshed.
40/// PR-UI-2 polls; PR-UI-3 may upgrade to event subscriptions.
41const 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    /// Approvals modal — opens on `a` (only when there's a
49    /// pending approval), routes Approve/Deny via the existing
50    /// `teamctl approve|deny` CLI so T-031's `delivered_at`
51    /// contract stays honored.
52    ApprovalsModal,
53    /// Compose modal — opens on `@` (DM-to-focused-agent) or `!`
54    /// (broadcast-to-current-channel). Routes through `teamctl
55    /// send|broadcast` so the channel-ACL + ratelimit + delivery
56    /// hooks the CLI already runs through ride for free.
57    ComposeModal,
58    /// `?` help overlay — modal listing every chord registered in
59    /// `help::ALL_GROUPS`. Read-only; closes on Esc / `?`.
60    HelpOverlay,
61    /// Onboarding tutorial walkthrough. Auto-opens on first
62    /// launch (per-team sentinel at
63    /// `.team/state/ui-tutorial-completed`); reopenable via `t`
64    /// from any non-modal state.
65    Tutorial,
66}
67
68/// Splitscreen orientation per detail-pane split (PR-UI-7 lift
69/// of PR-UI-6's deferred Q1). `Vertical` subdivides side-by-side
70/// (Ctrl+|); `Horizontal` stacks top-to-bottom (Ctrl+-).
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum SplitOrientation {
73    Vertical,
74    Horizontal,
75}
76
77pub struct App {
78    pub stage: Stage,
79    /// Tracked so QuitConfirm can return to whichever stage opened it.
80    pub previous_stage: Stage,
81    pub focused_pane: Pane,
82    pub team: TeamSnapshot,
83    /// Index into `team.agents` of the agent the detail pane is
84    /// streaming. `None` when the team is empty or roster
85    /// navigation hasn't picked one yet.
86    pub selected_agent: Option<usize>,
87    /// Lines from the most recent pane capture. Bounded to the last
88    /// `MAX_DETAIL_LINES` so the buffer doesn't grow unboundedly
89    /// over a long-running session.
90    pub detail_buffer: Vec<String>,
91    pub version: &'static str,
92    pub capabilities: Capabilities,
93    pub splash_started: Instant,
94    /// Last time the snapshot + pane capture were refreshed. Used by
95    /// `tick()` to gate the next refresh.
96    pub last_refresh: Instant,
97    pub running: bool,
98    /// First-launch detection — when the marker file exists, future
99    /// stacked-PRs (PR-UI-7) skip the tutorial after splash. PR-UI-1
100    /// only reads the flag; nothing routes off it yet.
101    pub tutorial_completed: bool,
102    /// Active tab inside the mailbox pane (PR-UI-3). Walked with
103    /// `[` / `]` when `focused_pane == Mailbox` (T-074 bug 6).
104    /// `Tab` always cycles pane focus, never mailbox tabs — the
105    /// previous "Tab cycles tabs when mailbox is focused" shape
106    /// stranded operators inside the mailbox.
107    pub mailbox_tab: MailboxTab,
108    /// Per-tab buffers + cursors for the focused agent's mailbox
109    /// view. Reset whenever the focused agent changes — switching
110    /// agents starts the operator at the head of fresh traffic.
111    pub mailbox: MailboxBuffers,
112    /// Pending approvals snapshot (PR-UI-4). Drives the conditional
113    /// stripe at the top of Triptych and the modal opened by `a`.
114    pub pending_approvals: Vec<Approval>,
115    /// Index into `pending_approvals` of the row the modal is
116    /// currently showing. Reset to 0 each time the modal opens;
117    /// `j` / `k` (or `↑` / `↓`) cycle.
118    pub selected_approval: usize,
119    /// Last error from a CLI-routed Approve/Deny call — surfaced
120    /// inline in the modal so the operator sees why a decision
121    /// didn't take.
122    pub approval_error: Option<String>,
123    /// Open compose target — `Some` while `Stage::ComposeModal`
124    /// is the active stage, `None` otherwise. Stored on App so
125    /// the editor's contents survive rerenders.
126    pub compose_target: Option<ComposeTarget>,
127    /// Editor backing the compose modal. Reset to `default()` each
128    /// time the modal opens so an old draft from a prior
129    /// invocation can't leak into a new send.
130    pub compose_editor: Editor,
131    /// Last error from a CLI-routed send call — surfaced inline
132    /// in the modal so the operator sees rate-limit / ACL-block
133    /// errors without leaving the UI.
134    pub compose_error: Option<String>,
135    /// Active main-view layout (PR-UI-6). Triptych is the default;
136    /// `Ctrl+W` toggles Wall, `Ctrl+M` toggles MailboxFirst.
137    pub layout: MainLayout,
138    /// Top-of-window agent index for the Wall view's vertical
139    /// scroll. SPEC §3 caps visible tiles at 4; this offsets which
140    /// 4-agent window is shown when the team has more.
141    pub wall_scroll: usize,
142    /// Selected channel index (into `team.channels`) for the
143    /// MailboxFirst layout's channel list and for the broadcast
144    /// picker. `None` until the operator picks one.
145    pub selected_channel: Option<usize>,
146    /// Splits within Triptych's detail pane (PR-UI-6). When
147    /// non-empty, the detail pane subdivides; each entry pairs an
148    /// agent id with the per-split orientation (PR-UI-7 lift of
149    /// the Q1 deferral). `selected_split` is the vim-window-motion
150    /// focus.
151    pub detail_splits: Vec<(String, SplitOrientation)>,
152    pub selected_split: usize,
153    /// Chord-prefix machine for `Ctrl+W` follow-ups (PR-UI-7 lift
154    /// of PR-UI-6's `Ctrl+Q` alias). When `Some(KeyCode::Char('w'))`,
155    /// the next key is interpreted as a `Ctrl+W` follow: `q` =
156    /// close split, `o` = close others. Cleared on any unrelated
157    /// keypress so a typo doesn't leave the editor stuck.
158    pub pending_chord: Option<KeyCode>,
159    /// `true` when the operator's first launch on this team has
160    /// not yet completed the tutorial — drives the auto-open after
161    /// splash. Reset to `false` on tutorial completion.
162    pub tutorial_pending_for_team: bool,
163    /// Brand-spinner frame counter (PR-UI-7). Bumped each refresh
164    /// tick so the statusline indicator shows the app is alive.
165    pub spinner_frame: usize,
166    /// Tutorial step cursor (PR-UI-7). Index into
167    /// `onboarding::STEPS`; reset to 0 when the tutorial reopens.
168    pub tutorial_step: usize,
169    /// Modal substage for the broadcast channel picker (PR-UI-6).
170    /// When `true` the compose modal renders a picker over the
171    /// editor; selecting a channel populates `compose_target` and
172    /// drops back to the editor.
173    pub compose_picker_open: bool,
174    /// Picker selection cursor — index into `team.channels`.
175    pub compose_picker_index: usize,
176}
177
178const MAX_DETAIL_LINES: usize = 2000;
179
180impl App {
181    /// Construct an empty App — no team snapshot loaded. Used by
182    /// tests and as the splash-stage default. Production launch
183    /// goes through `App::launch()` which immediately runs an
184    /// initial `refresh()` so the splash screen already shows the
185    /// real team name + agent count.
186    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    /// Per-tutorial-step cursor (used by Stage::Tutorial). Wraps
223    /// at the end so `t`-then-keys walks the full tour.
224    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        // First entry into MailboxFirst seeds the channel cursor
265        // so the feed pane has something to render.
266        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    /// Add a split for the focused agent (or current selection)
305    /// to the detail pane. Cap at 4 splits per the SPEC §3 cap.
306    /// Add a vertical split (PR-UI-7). `Ctrl+|` calls this.
307    pub fn add_detail_split_vertical(&mut self) {
308        self.add_detail_split_with_orientation(SplitOrientation::Vertical);
309    }
310    /// Add a horizontal split (PR-UI-7). `Ctrl+-` calls this.
311    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    /// Back-compat shim — earlier PRs called the unsuffixed name.
325    /// Defaults to vertical (matching the most-common chord
326    /// `Ctrl+|`). Kept so the test surface PR-UI-6 pinned doesn't
327    /// drift.
328    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    /// Open the broadcast compose flow — picker first when at
357    /// least one channel is declared, else fall back to the
358    /// project's `all` channel (PR-UI-5 behaviour) on the
359    /// assumption that `all` always exists in production composes.
360    pub fn enter_compose_broadcast_with_picker(&mut self) {
361        if self.team.channels.is_empty() {
362            // Fall back to the PR-UI-5 default if no channels
363            // are declared yet — should only happen with a
364            // half-loaded snapshot.
365            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    /// Replace the pending-approvals list. Closes the modal when
465    /// the queue empties (no row to act on); preserves the modal
466    /// otherwise but clamps `selected_approval` into range so an
467    /// approval resolved out-of-band doesn't leave us pointing at
468    /// a stale index.
469    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    /// Apply a decision to the focused approval via the injected
482    /// decider. The decider routes through `teamctl approve|deny`
483    /// in production; tests inject a recorder. On success the row
484    /// gets removed from the local `pending_approvals` snapshot
485    /// optimistically — the next `refresh_approvals` will reconcile
486    /// against the broker.
487    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    /// Open the compose modal for the focused agent (if any). The
508    /// `@` chord. No-op when no agent is focused.
509    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    /// Open the compose modal targeting the project's `all`
528    /// channel — the broadcast wire. The `!` chord. PR-UI-5 ships
529    /// with channel scoping limited to `all` (the Wire tab is the
530    /// only channel context the mailbox pane currently surfaces);
531    /// PR-UI-6's mailbox UI work will widen the scope to per-channel
532    /// targets when individual channels become first-class in the
533    /// pane.
534    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    /// Send the current compose body via the injected message
562    /// sender. Routes through `teamctl send|broadcast` in
563    /// production; tests inject a recorder. Closes the modal +
564    /// triggers a mailbox refresh on success; surfaces error
565    /// inline on failure.
566    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 the mailbox so the just-sent row appears
589                // in the relevant tab on the next paint.
590                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    /// Move roster selection up by one — wraps at the top. No-op
610    /// when the team is empty. Does not change `focused_pane`.
611    /// Resets mailbox buffers when the resulting agent id differs
612    /// from the prior selection — switching agents should start the
613    /// operator at the head of fresh traffic.
614    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    /// Move roster selection down by one — wraps at the bottom.
630    /// No-op when the team is empty.
631    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    /// `<project>:<agent>` of the currently selected agent, if any.
647    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    /// Replace the team snapshot. Preserves the current selection
667    /// when the agent at that index still exists; otherwise resets
668    /// to the first agent (or `None` for an empty team). Resets the
669    /// mailbox buffers when the resulting agent id differs from the
670    /// prior selection — same agent-changed contract as
671    /// `select_next` / `select_prev`.
672    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    /// Return the focused agent's tmux session name, if any. Used
686    /// by the run loop to know which session to capture.
687    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    /// Replace the detail buffer, clipped at the recent-line cap.
694    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
707/// Refresh the team snapshot + the focused agent's pane capture +
708/// the mailbox tabs (PR-UI-3). Pulled out so tests can drive a
709/// single tick deterministically against `MockPaneSource` and
710/// `MockMailboxSource` without going through the event loop.
711pub 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
732/// Approvals-only refresh. Extracted on the same shape as
733/// `refresh_mailbox` — PR-UI-5+ can call it on its own cadence
734/// (e.g. in response to a `notify` signal) without re-running the
735/// heavier paths. Errors degrade to "no pending" so the stripe
736/// just hides on a transient broker read failure.
737pub 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
742/// Mailbox-only refresh — extracted so PR-UI-4+ can call it on its
743/// own cadence (e.g. in response to a broker INSERT signal) without
744/// re-running the heavier compose + tmux capture path. PR-UI-3
745/// just calls it from the main `refresh` once per tick.
746pub fn refresh_mailbox<M: MailboxSource>(app: &mut App, mailbox_source: &M) {
747    let Some(agent_id) = app.selected_agent_id() else {
748        // No agent focused → nothing to fetch. Buffers were already
749        // reset on selection change so the empty-state hint shows.
750        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    // First refresh resolves the team root; only then can we
774    // bring up the file-watcher, which keys on `<root>/state/`.
775    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            // The mailbox source for handle_event mirrors the
781            // refresh path; the same db_path key avoids divergence
782            // between read + write fanout.
783            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        // Refresh on either (a) deadline elapsed or (b) the
792        // notify-watcher said the broker DB changed. The watcher
793        // shaves the typical refresh latency from ~1s to ~50ms when
794        // the platform supports it; on platforms without notify
795        // support `take_dirty` always returns false and the
796        // deadline path is the only trigger (PR-UI-3 behaviour).
797        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            // Team root drifted (operator launched in a different
802            // tree) → swap the watcher to the new state dir.
803            if app.team.root != prior_root {
804                watch = Watch::try_new(&app.team.root.join("state"));
805            }
806        }
807    }
808    Ok(())
809}
810
811/// Build the production `BrokerMailboxSource` + `BrokerApprovalSource`
812/// from the current team root and run a refresh with all three
813/// default sources. Lives here (rather than inline in `run`) so
814/// the team-root → DB-path derivation has one home.
815fn 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    // T-074 bug 5: tutorial bodies are prose paragraphs, not pre-
914    // formatted lines — clip-on-overflow leaves them looking truncated
915    // on common (≤80 col) terminals. Soft-wrap with `trim: true` so
916    // long step descriptions reflow into the modal width instead of
917    // dropping off the right edge.
918    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    // PR-UI-6: when the broadcast picker is open we render a
1016    // channel-list inside the modal instead of the editor; the
1017    // editor footer stays so operators see the same layout.
1018    if app.compose_picker_open {
1019        render_compose_picker_body(inner, buf, app);
1020        return;
1021    }
1022    // Reserve the bottom two rows: an error line (rendered when
1023    // present, blank otherwise) and the footer with key hints.
1024    let chunks = Layout::default()
1025        .direction(Direction::Vertical)
1026        .constraints([
1027            Constraint::Min(1),    // editor body
1028            Constraint::Length(1), // error / status
1029            Constraint::Length(1), // footer
1030        ])
1031        .split(inner);
1032
1033    // Body — render lines with a `▏` cursor marker on the active
1034    // row when in Insert. Skip cursor cell in Normal/Ex modes so
1035    // the operator's eye finds the row by row context, not a
1036    // blinking caret.
1037    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                // PR-UI-7 chord-prefix follow-ups MUST be tested
1174                // before unguarded `Char('q')` / `Char('o')` arms,
1175                // otherwise the no-modifier `q` quit would shadow
1176                // the `Ctrl+W q` close-split.
1177                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                // PR-UI-4: `a` opens the approvals modal when there's
1193                // at least one pending row. No-op otherwise so the
1194                // chord doesn't surprise anyone hammering keys.
1195                KeyCode::Char('a') => app.enter_approvals_modal(),
1196                // PR-UI-5: `@` opens DM compose to focused agent.
1197                // PR-UI-6: `!` now opens the broadcast picker so
1198                // operators choose which channel to broadcast to,
1199                // not just the project's `all` wire.
1200                KeyCode::Char('@') => app.enter_compose_dm_for_focused(),
1201                KeyCode::Char('!') => app.enter_compose_broadcast_with_picker(),
1202                // PR-UI-7 chord-prefix: when there's at least one
1203                // detail split, `Ctrl+W` arms the chord-prefix
1204                // (the next key dispatches `q` close-split, `o`
1205                // close-others). Tested BEFORE the wall-layout
1206                // toggle below so the chord-prefix wins when
1207                // relevant. Both casings accepted because CapsLock
1208                // / Shift+Ctrl produce `Char('W')`; armed value is
1209                // normalised to lowercase so the follow-up arms
1210                // above can match a single literal.
1211                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                // PR-UI-6: layout toggles. `Ctrl+W` for Wall when
1218                // there are no splits to chord on; `Ctrl+M` for
1219                // MailboxFirst (always). Both casings accepted —
1220                // see the chord-arm comment above.
1221                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                // PR-UI-7 splitscreen lift: `Ctrl+|` subdivides
1232                // vertically, `Ctrl+-` horizontally — vim/tmux
1233                // operators' muscle memory matches the visual.
1234                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                // Vim window-motion `Ctrl+H/J/K/L` cycles between
1241                // splits when there's more than one. Both casings
1242                // accepted — see the Ctrl+W chord-arm comment above
1243                // for the CapsLock + Shift+Ctrl rationale.
1244                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                // PR-UI-6 alias preserved for back-compat: `Ctrl+Q`
1261                // closes the focused split. PR-UI-7 also wires the
1262                // proper `Ctrl+W q` chord; both work. Both casings
1263                // accepted for the same reason as Ctrl+W/M.
1264                KeyCode::Char('q') | KeyCode::Char('Q')
1265                    if k.modifiers.contains(KeyModifiers::CONTROL) =>
1266                {
1267                    app.close_focused_split()
1268                }
1269                // (chord-prefix follow-ups handled at top of arm
1270                // before unguarded letter-arms — see top of
1271                // `Stage::Triptych` match.)
1272                // PR-UI-7 help + tutorial chords. `?` opens help
1273                // overlay; `t` reopens tutorial. Both no-op if a
1274                // modifier is in flight (so `Shift+?` and `Ctrl+T`
1275                // don't double-bind).
1276                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                // PR-UI-4: Shift+Tab cycles panes backward. Some
1283                // terminals send `BackTab`, others send `Tab` with
1284                // SHIFT — handle both.
1285                KeyCode::BackTab => app.cycle_focus_back(),
1286                KeyCode::Tab if k.modifiers.contains(KeyModifiers::SHIFT) => app.cycle_focus_back(),
1287                // T-074 bug 6: Tab always cycles pane focus, never
1288                // mailbox tabs. Previously Tab routed into mailbox
1289                // tab-cycling when the mailbox pane was focused —
1290                // this stranded operators inside the mailbox with no
1291                // discoverable way out (Alireza's exact report). The
1292                // vim/tmux convention is "Tab moves between panes";
1293                // honour it across every pane uniformly.
1294                KeyCode::Tab => app.cycle_focus(),
1295                // T-074 bug 6: mailbox sub-navigation moved to a
1296                // dedicated chord pair (`[`/`]`) gated on the
1297                // mailbox pane being focused. Vim's [t/]t mental
1298                // model — `]` walks forward, `[` walks back. Keys
1299                // do nothing when other panes are focused, so the
1300                // chord stays unsurprising in every other context.
1301                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                // PR-UI-6: in Wall layout, `j`/`k` (and arrows)
1306                // scroll the tile grid — same vim shape, different
1307                // surface. In Triptych roster focus they still
1308                // navigate the roster.
1309                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                // PR-UI-6: in MailboxFirst, `j`/`k` walk the
1316                // channel list.
1317                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                // Roster navigation — only when roster is the
1328                // focused pane. j/k mirror Vim; arrows mirror
1329                // every-day navigation.
1330                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                // Asymmetric chord shape (T-074 bug 4 fix): approve is
1345                // the common path so it accepts both `y` and `Y` —
1346                // matches QuitConfirm's loose convention and the
1347                // muscle-memory most TUI prompts build. Deny is the
1348                // destructive side, so it requires deliberate Shift
1349                // (`N` only); a stray lowercase `n` does nothing.
1350                // Trades cosmetic chord-symmetry for discoverability
1351                // on the load-bearing approve flow.
1352                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                // PR-UI-6: when the broadcast picker is open the
1363                // editor doesn't see keys yet — operator first
1364                // chooses a channel.
1365                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                        // PR-UI-6 fixup (Q6, dev2 review): Esc
1371                        // dismisses the picker overlay only and
1372                        // returns to the editor with whatever the
1373                        // operator already typed; the editor's own
1374                        // Esc-Esc cancel-the-modal flow handles
1375                        // bailing out of the whole compose. Mirrors
1376                        // the overlay-vs-modal symmetry vim users
1377                        // expect.
1378                        KeyCode::Esc => {
1379                            app.compose_picker_open = false;
1380                            app.compose_picker_index = 0;
1381                        }
1382                        _ => {}
1383                    }
1384                } else {
1385                    // Route every keypress through the editor; the
1386                    // editor returns Send / Cancel / Continue.
1387                    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            // ratatui redraws on the next loop iteration; nothing to do.
1406        }
1407        _ => {}
1408    }
1409}
1410
1411/// Render the entire UI into a `Buffer` at fixed size — used by the
1412/// snapshot tests. Mirrors `draw` exactly but doesn't require a
1413/// `Terminal`. Update both in lockstep when adding new stages.
1414pub 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    /// Noop decider for tests that don't exercise approve/deny.
1500    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    /// Noop sender for tests that don't exercise compose-send.
1514    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    /// Mailbox source that returns nothing — refresh_mailbox after
1535    /// a successful send becomes a no-op.
1536    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    /// Boilerplate-free dispatcher for tests not exercising the
1554    /// decision / send paths.
1555    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        // T-074 bug 6: Tab cycles pane focus only — Roster → Detail
1598        // → Mailbox → Roster — at every step. The previous "Tab
1599        // cycles tabs once focused on mailbox" shape stranded
1600        // operators inside the mailbox; this test pins the corrected
1601        // uniform cycle so a future refactor can't reintroduce the
1602        // dead-end.
1603        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        // T-074 bug 6: `[` / `]` is the new mailbox-tab walker
1631        // (vim-style [t/]t mental model). Gated on mailbox being
1632        // the focused pane so the chord stays unsurprising in
1633        // every other context.
1634        let mut app = App::new();
1635        app.dismiss_splash();
1636        // Walk into mailbox via Tab.
1637        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        // The chord should not surprise an operator scrolling the
1656        // roster — gate is load-bearing.
1657        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)); // wraps
1725    }
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), // same id, new state
1759        ]));
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        // The mailbox cursors are per-agent context; switching to a
1778        // new agent must clear them so we don't skip historical
1779        // rows that landed before the new agent's first refresh.
1780        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        // Move selection to p:b — different agent id, mailbox resets.
1798        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    /// Tiny single-call mailbox stub for the refresh-fanout test —
1805    /// keeps the assertion local without depending on
1806    /// `mailbox::tests::MockMailboxSource` (which lives behind a
1807    /// private `tests` module).
1808    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        // The selected agent is p:a (auto-set by replace_team to
1871        // index 0); the wire filter takes the project id `p`.
1872        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        // Optimistic local removal — approval id 7 dropped.
1952        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        // Queue empty after the only approval resolves → modal closes.
1975        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        // T-074 bug 4: discoverable approve. Most operators try
1991        // lowercase first; the modal must accept it on the
1992        // approve (low-risk) side. Deny stays Shift-gated.
1993        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        // Asymmetry guard: deny is destructive — `n` lowercase must
2014        // NOT fire the decider. A future "symmetric loose" refactor
2015        // would silently regress the destructive-deny Shift-gate;
2016        // this test pins it.
2017        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        // Shift+Tab from Roster → Mailbox (the "back out of mailbox"
2048        // direction's mirror).
2049        dispatch(&mut app, key(KeyCode::BackTab));
2050        assert_eq!(app.focused_pane, Pane::Mailbox);
2051        // Some terminals send Tab + SHIFT instead of BackTab.
2052        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        // managers-first sort would otherwise reorder; mark all as workers.
2255        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        // Past 10-1 = 9; cap blocks 12.
2267        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        // PR-UI-6 fixup (qa Gap 1a): with exactly WALL_TILE_CAP=4
2341        // agents the entire team fits in one window — scrolling
2342        // is a no-op in both directions. Pinning this catches a
2343        // future `<` → `<=` slip in `wall_scroll_down`.
2344        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        // PR-UI-6 fixup (qa Gap 1b): exactly 5 agents → 4 fit in
2361        // window-0, the 5th lives at window-4. One scroll
2362        // advances; the next caps. Pins the off-by-one between 4
2363        // and 5 agents.
2364        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        // PR-UI-6 fixup (Q6 dev2 review + qa Gap 3): Esc inside
2380        // the broadcast picker should close the picker overlay
2381        // and return to the editor in its current state — NOT
2382        // close the whole compose modal. Editor's Esc-Esc
2383        // already handles cancel-the-modal.
2384        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        // PR-UI-6 fixup (qa Gap 4): the broadcast path needs the
2404        // same MockMessageSender pin the DM path got in PR-UI-5.
2405        // Pins both per-channel-correct-id (picker selection
2406        // flows through to the send call) AND routes-through-
2407        // `broadcast()`-not-`send()` (no DM call recorded).
2408        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        // Open picker, walk to channel index 1 (`editorial`),
2422        // confirm, type a body, Ctrl+Enter to send.
2423        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        // Walk the picker.
2488        dispatch(&mut app, key(KeyCode::Char('j')));
2489        assert_eq!(app.compose_picker_index, 1);
2490        // Confirm pulls into compose target.
2491        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        // Any non-Esc/back key advances.
2535        dispatch(&mut app, key(KeyCode::Char(' ')));
2536        assert_eq!(app.tutorial_step, 1);
2537        // `k` walks back.
2538        dispatch(&mut app, key(KeyCode::Char('k')));
2539        assert_eq!(app.tutorial_step, 0);
2540        // Esc closes from any step.
2541        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        // qa Gap C fold: pin the chosen behaviour for `k`/`Up`/`p`
2548        // at step 0 — saturating decrement keeps `tutorial_step`
2549        // at 0 rather than wrapping. Any future shift to
2550        // wrap-to-end would break this test, which is the point.
2551        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        // The walk-back keypress must NOT close the tutorial
2558        // either — the Stage stays.
2559        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        // Two splits — `Ctrl+W` arms only when there's something
2588        // to close.
2589        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        // Plain `q` (no modifier) is now interpreted as the
2603        // chord-prefix follow-up — close split, NOT quit.
2604        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        // Focus the middle split.
2623        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        // qa Gap 2 fold: pin the cap explicitly. Reaching exactly
2638        // 4 must stick; the 5th call must be a no-op (not panic,
2639        // not silently grow). If `add_detail_split` ever returns
2640        // a Result, this test catches the silent-success regression.
2641        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        // Approval id 3 resolved out-of-band; new snapshot has 2 rows.
2658        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        // Focused pane is Roster → arrow cycles selection.
2671        app.selected_agent = Some(0);
2672        dispatch(&mut app, key(KeyCode::Down));
2673        assert_eq!(app.selected_agent, Some(1));
2674        // Cycle to Detail → arrow no longer touches selection.
2675        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}