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::keysender::{encode_key, KeySender, ScrollDirection, TmuxKeySender};
28use crate::layouts;
29use crate::mailbox::{BrokerMailboxSource, MailboxBuffers, MailboxSource, MailboxTab};
30use crate::pane::{PaneSource, TmuxPaneSource};
31use crate::splash;
32use crate::status_bar;
33use crate::statusline;
34use crate::theme::{detect_capabilities, Capabilities};
35use crate::triptych::{self, MainLayout, Pane};
36use crate::tutorial;
37use crate::watch::Watch;
38
39const SPLASH_AUTO_DISMISS: Duration = Duration::from_secs(3);
40const POLL_INTERVAL: Duration = Duration::from_millis(50);
41/// How often the team snapshot + detail-pane capture get refreshed.
42/// PR-UI-2 polls; PR-UI-3 may upgrade to event subscriptions.
43const REFRESH_INTERVAL: Duration = Duration::from_secs(1);
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum Stage {
47    Splash,
48    Triptych,
49    QuitConfirm,
50    /// Approvals modal — opens on `a` (only when there's a
51    /// pending approval), routes Approve/Deny via the existing
52    /// `teamctl approve|deny` CLI so T-031's `delivered_at`
53    /// contract stays honored.
54    ApprovalsModal,
55    /// Compose modal — opens on `@` (DM-to-focused-agent) or `!`
56    /// (broadcast-to-current-channel). Routes through `teamctl
57    /// send|broadcast` so the channel-ACL + ratelimit + delivery
58    /// hooks the CLI already runs through ride for free.
59    ComposeModal,
60    /// `?` help overlay — modal listing every chord registered in
61    /// `help::ALL_GROUPS`. Read-only; closes on Esc / `?`.
62    HelpOverlay,
63    /// Onboarding tutorial walkthrough. Auto-opens on first
64    /// launch (per-team sentinel at
65    /// `.team/state/ui-tutorial-completed`); reopenable via `t`
66    /// from any non-modal state.
67    Tutorial,
68    /// Stream-keys mode (T-108). Activated by `Ctrl+E` while the
69    /// detail pane is focused; every subsequent keystroke (except
70    /// `Esc`, the exit chord) is forwarded to the focused agent's
71    /// tmux pane via `tmux send-keys`. The Triptych keeps rendering
72    /// underneath — the 1s refresh tick still captures whatever the
73    /// agent prints in response — so the operator interacts with
74    /// the agent in real time without leaving the UI.
75    StreamKeys,
76}
77
78/// Splitscreen orientation per detail-pane split (PR-UI-7 lift
79/// of PR-UI-6's deferred Q1). `Vertical` subdivides side-by-side
80/// (Ctrl+|); `Horizontal` stacks top-to-bottom (Ctrl+-).
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum SplitOrientation {
83    Vertical,
84    Horizontal,
85}
86
87pub struct App {
88    pub stage: Stage,
89    /// Tracked so QuitConfirm can return to whichever stage opened it.
90    pub previous_stage: Stage,
91    pub focused_pane: Pane,
92    pub team: TeamSnapshot,
93    /// Index into `team.agents` of the agent the detail pane is
94    /// streaming. `None` when the team is empty or roster
95    /// navigation hasn't picked one yet.
96    pub selected_agent: Option<usize>,
97    /// Lines from the most recent pane capture. Bounded to the last
98    /// `MAX_DETAIL_LINES` so the buffer doesn't grow unboundedly
99    /// over a long-running session.
100    pub detail_buffer: Vec<String>,
101    pub version: &'static str,
102    pub capabilities: Capabilities,
103    pub splash_started: Instant,
104    /// Last time the snapshot + pane capture were refreshed. Used by
105    /// `tick()` to gate the next refresh.
106    pub last_refresh: Instant,
107    pub running: bool,
108    /// First-launch detection — when the marker file exists, future
109    /// stacked-PRs (PR-UI-7) skip the tutorial after splash. PR-UI-1
110    /// only reads the flag; nothing routes off it yet.
111    pub tutorial_completed: bool,
112    /// Active tab inside the mailbox pane (PR-UI-3). Walked with
113    /// `←` / `→` when `focused_pane == Mailbox` (T-124 hard-swapped
114    /// the prior `[` / `]` chord for arrow keys; T-074 bug 6 is
115    /// the gating-on-focus invariant). `Tab` always cycles pane
116    /// focus, never mailbox tabs — the previous "Tab cycles tabs
117    /// when mailbox is focused" shape stranded operators inside
118    /// the mailbox.
119    pub mailbox_tab: MailboxTab,
120    /// Per-tab buffers + cursors for the focused agent's mailbox
121    /// view. Reset whenever the focused agent changes — switching
122    /// agents starts the operator at the head of fresh traffic.
123    pub mailbox: MailboxBuffers,
124    /// Pending approvals snapshot (PR-UI-4). Drives the conditional
125    /// stripe at the top of Triptych and the modal opened by `a`.
126    pub pending_approvals: Vec<Approval>,
127    /// Index into `pending_approvals` of the row the modal is
128    /// currently showing. Reset to 0 each time the modal opens;
129    /// `j` / `k` (or `↑` / `↓`) cycle.
130    pub selected_approval: usize,
131    /// Last error from a CLI-routed Approve/Deny call — surfaced
132    /// inline in the modal so the operator sees why a decision
133    /// didn't take.
134    pub approval_error: Option<String>,
135    /// Open compose target — `Some` while `Stage::ComposeModal`
136    /// is the active stage, `None` otherwise. Stored on App so
137    /// the editor's contents survive rerenders.
138    pub compose_target: Option<ComposeTarget>,
139    /// Editor backing the compose modal. Reset to `default()` each
140    /// time the modal opens so an old draft from a prior
141    /// invocation can't leak into a new send.
142    pub compose_editor: Editor,
143    /// Last error from a CLI-routed send call — surfaced inline
144    /// in the modal so the operator sees rate-limit / ACL-block
145    /// errors without leaving the UI.
146    pub compose_error: Option<String>,
147    /// Active main-view layout (PR-UI-6). Triptych is the default;
148    /// `Ctrl+W` toggles Wall, `Ctrl+M` toggles MailboxFirst.
149    pub layout: MainLayout,
150    /// Top-of-window agent index for the Wall view's vertical
151    /// scroll. SPEC §3 caps visible tiles at 4; this offsets which
152    /// 4-agent window is shown when the team has more.
153    pub wall_scroll: usize,
154    /// Selected channel index (into `team.channels`) for the
155    /// MailboxFirst layout's channel list and for the broadcast
156    /// picker. `None` until the operator picks one.
157    pub selected_channel: Option<usize>,
158    /// Splits within Triptych's detail pane (PR-UI-6). When
159    /// non-empty, the detail pane subdivides; each entry pairs an
160    /// agent id with the per-split orientation (PR-UI-7 lift of
161    /// the Q1 deferral). `selected_split` is the vim-window-motion
162    /// focus.
163    pub detail_splits: Vec<(String, SplitOrientation)>,
164    pub selected_split: usize,
165    /// Chord-prefix machine for `Ctrl+W` follow-ups (PR-UI-7 lift
166    /// of PR-UI-6's `Ctrl+Q` alias). When `Some(KeyCode::Char('w'))`,
167    /// the next key is interpreted as a `Ctrl+W` follow: `q` =
168    /// close split, `o` = close others. Cleared on any unrelated
169    /// keypress so a typo doesn't leave the editor stuck.
170    pub pending_chord: Option<KeyCode>,
171    /// `true` when the operator's first launch on this team has
172    /// not yet completed the tutorial — drives the auto-open after
173    /// splash. Reset to `false` on tutorial completion.
174    pub tutorial_pending_for_team: bool,
175    /// Brand-spinner frame counter (PR-UI-7). Bumped each refresh
176    /// tick so the statusline indicator shows the app is alive.
177    pub spinner_frame: usize,
178    /// Tutorial step cursor (PR-UI-7). Index into
179    /// `onboarding::STEPS`; reset to 0 when the tutorial reopens.
180    pub tutorial_step: usize,
181    /// Modal substage for the broadcast channel picker (PR-UI-6).
182    /// When `true` the compose modal renders a picker over the
183    /// editor; selecting a channel populates `compose_target` and
184    /// drops back to the editor.
185    pub compose_picker_open: bool,
186    /// Picker selection cursor — index into `team.channels`.
187    pub compose_picker_index: usize,
188    /// T-32: when `true`, the compose modal renders a single-line
189    /// path-input overlay instead of the editor; Enter appends a
190    /// `📎 attachment: <path>` line to the editor body and closes the
191    /// overlay. Tab inside the editor opens it; Esc inside the
192    /// overlay cancels back to the editor (matches the picker
193    /// overlay's modal-vs-modal symmetry from PR-UI-6).
194    pub compose_attach_input_open: bool,
195    /// Single-line buffer for the path-input overlay. Reset on close
196    /// so a cancelled draft can't leak into the next attach attempt.
197    pub compose_attach_buffer: String,
198    /// T-199: per-session cache of the last Detail-pane size we
199    /// pushed to `tmux resize-pane`. The run loop diffs the current
200    /// Detail rect against this on every frame and only spawns the
201    /// tmux command when the size actually changed — common case
202    /// (no resize, no focus switch) is a HashMap lookup. Keyed by
203    /// `tmux_session` (e.g. `t-hello-manager`). See
204    /// `crate::pane_resize`.
205    pub last_synced_pane_sizes: std::collections::HashMap<String, (u16, u16)>,
206    /// T-209: live system handle for the bottom status bar's
207    /// CPU% + RAM% indicator. Refreshed in-place on the existing
208    /// 1-second App tick (see `refresh_with_default_sources` and the
209    /// run-loop tick at the top of `run()`); no background thread.
210    /// `default-features = false` + only the `system` feature is
211    /// enabled in the dep to keep the compile surface narrow. See
212    /// `crate::status_bar`.
213    pub sysinfo: sysinfo::System,
214    /// T-212 preview gate. `true` when `TEAMCTL_UI_RATE_LIMIT_INDICATOR`
215    /// was set at App::new(), `false` otherwise. The bottom status
216    /// bar's center slot only renders when this is true — opt-in
217    /// while the indicator's data shape (currently reset-time only)
218    /// stabilizes against the eventual usage-% data path. Tests can
219    /// flip the field directly to exercise both branches without
220    /// process-wide env-var racing.
221    pub rate_limit_indicator_enabled: bool,
222}
223
224const MAX_DETAIL_LINES: usize = 2000;
225
226impl App {
227    /// Construct an empty App — no team snapshot loaded. Used by
228    /// tests and as the splash-stage default. Production launch
229    /// goes through `App::launch()` which immediately runs an
230    /// initial `refresh()` so the splash screen already shows the
231    /// real team name + agent count.
232    pub fn new() -> Self {
233        Self {
234            stage: Stage::Splash,
235            previous_stage: Stage::Splash,
236            focused_pane: Pane::Roster,
237            team: TeamSnapshot::empty(std::path::PathBuf::new()),
238            selected_agent: None,
239            detail_buffer: Vec::new(),
240            version: env!("CARGO_PKG_VERSION"),
241            capabilities: detect_capabilities(),
242            splash_started: Instant::now(),
243            last_refresh: Instant::now() - REFRESH_INTERVAL,
244            running: true,
245            tutorial_completed: tutorial::is_completed(),
246            mailbox_tab: MailboxTab::Inbox,
247            mailbox: MailboxBuffers::default(),
248            pending_approvals: Vec::new(),
249            selected_approval: 0,
250            approval_error: None,
251            compose_target: None,
252            compose_editor: Editor::default(),
253            compose_error: None,
254            layout: MainLayout::Triptych,
255            wall_scroll: 0,
256            selected_channel: None,
257            detail_splits: Vec::new(),
258            selected_split: 0,
259            compose_picker_open: false,
260            compose_picker_index: 0,
261            compose_attach_input_open: false,
262            compose_attach_buffer: String::new(),
263            pending_chord: None,
264            tutorial_pending_for_team: false,
265            spinner_frame: 0,
266            tutorial_step: 0,
267            last_synced_pane_sizes: std::collections::HashMap::new(),
268            // sysinfo's `new()` allocates but doesn't read any metrics;
269            // the first values are populated by the first refresh tick
270            // in `refresh_with_default_sources`. Until then the status
271            // bar reads zeros — operator sees the bar shape but the
272            // numbers stabilize after ~1 second.
273            sysinfo: sysinfo::System::new(),
274            // T-212: per-agent rate-limit indicator is gated behind a
275            // preview env var so we can ship the indicator surface
276            // (reset-time only) without committing the operator-facing
277            // shape until the usage-% data path lands. Operators
278            // opt in by setting `TEAMCTL_UI_RATE_LIMIT_INDICATOR=1`
279            // (any non-empty value enables). Read once at App::new()
280            // — flipping the flag mid-session requires a TUI restart.
281            rate_limit_indicator_enabled: std::env::var_os("TEAMCTL_UI_RATE_LIMIT_INDICATOR")
282                .is_some(),
283        }
284    }
285
286    /// Per-tutorial-step cursor (used by Stage::Tutorial). Wraps
287    /// at the end so `t`-then-keys walks the full tour.
288    pub fn enter_help_overlay(&mut self) {
289        self.previous_stage = self.stage;
290        self.stage = Stage::HelpOverlay;
291    }
292    pub fn close_help_overlay(&mut self) {
293        self.stage = self.previous_stage;
294    }
295    pub fn enter_tutorial(&mut self) {
296        self.previous_stage = self.stage;
297        self.stage = Stage::Tutorial;
298        self.tutorial_step = 0;
299    }
300    pub fn close_tutorial(&mut self) {
301        self.stage = self.previous_stage;
302        self.tutorial_pending_for_team = false;
303        if !self.team.root.as_os_str().is_empty() {
304            let _ = crate::onboarding::mark_completed(&self.team.root);
305        }
306    }
307    pub fn tutorial_advance(&mut self) {
308        let len = crate::onboarding::STEPS.len();
309        if len == 0 {
310            self.close_tutorial();
311            return;
312        }
313        if self.tutorial_step + 1 >= len {
314            self.close_tutorial();
315        } else {
316            self.tutorial_step += 1;
317        }
318    }
319    pub fn tutorial_back(&mut self) {
320        self.tutorial_step = self.tutorial_step.saturating_sub(1);
321    }
322
323    pub fn toggle_wall_layout(&mut self) {
324        self.layout = self.layout.toggle_wall();
325    }
326    pub fn toggle_mailbox_first_layout(&mut self) {
327        self.layout = self.layout.toggle_mailbox_first();
328        // First entry into MailboxFirst seeds the channel cursor
329        // so the feed pane has something to render.
330        if matches!(self.layout, MainLayout::MailboxFirst) && self.selected_channel.is_none() {
331            self.selected_channel = if self.team.channels.is_empty() {
332                None
333            } else {
334                Some(0)
335            };
336        }
337    }
338    pub fn wall_scroll_up(&mut self) {
339        self.wall_scroll = self
340            .wall_scroll
341            .saturating_sub(crate::layouts::WALL_TILE_CAP);
342    }
343    pub fn wall_scroll_down(&mut self) {
344        let next = self.wall_scroll + crate::layouts::WALL_TILE_CAP;
345        if next < self.team.agents.len() {
346            self.wall_scroll = next;
347        }
348    }
349    pub fn select_next_channel(&mut self) {
350        if self.team.channels.is_empty() {
351            return;
352        }
353        self.selected_channel = Some(match self.selected_channel {
354            None => 0,
355            Some(i) => (i + 1) % self.team.channels.len(),
356        });
357    }
358    pub fn select_prev_channel(&mut self) {
359        if self.team.channels.is_empty() {
360            return;
361        }
362        self.selected_channel = Some(match self.selected_channel {
363            None | Some(0) => self.team.channels.len() - 1,
364            Some(i) => i - 1,
365        });
366    }
367
368    /// Add a split for the focused agent (or current selection)
369    /// to the detail pane. Cap at 4 splits per the SPEC §3 cap.
370    /// Add a vertical split (PR-UI-7). `Ctrl+|` calls this.
371    pub fn add_detail_split_vertical(&mut self) {
372        self.add_detail_split_with_orientation(SplitOrientation::Vertical);
373    }
374    /// Add a horizontal split (PR-UI-7). `Ctrl+-` calls this.
375    pub fn add_detail_split_horizontal(&mut self) {
376        self.add_detail_split_with_orientation(SplitOrientation::Horizontal);
377    }
378    fn add_detail_split_with_orientation(&mut self, orientation: SplitOrientation) {
379        let Some(id) = self.selected_agent_id() else {
380            return;
381        };
382        if self.detail_splits.len() >= 4 {
383            return;
384        }
385        self.detail_splits.push((id, orientation));
386        self.selected_split = self.detail_splits.len() - 1;
387    }
388    /// Back-compat shim — earlier PRs called the unsuffixed name.
389    /// Defaults to vertical (matching the most-common chord
390    /// `Ctrl+|`). Kept so the test surface PR-UI-6 pinned doesn't
391    /// drift.
392    pub fn add_detail_split(&mut self) {
393        self.add_detail_split_vertical();
394    }
395    pub fn close_focused_split(&mut self) {
396        if self.detail_splits.is_empty() {
397            return;
398        }
399        let i = self.selected_split.min(self.detail_splits.len() - 1);
400        self.detail_splits.remove(i);
401        self.selected_split = i.saturating_sub(1);
402    }
403    pub fn cycle_split_next(&mut self) {
404        if self.detail_splits.is_empty() {
405            return;
406        }
407        self.selected_split = (self.selected_split + 1) % self.detail_splits.len();
408    }
409    pub fn cycle_split_prev(&mut self) {
410        if self.detail_splits.is_empty() {
411            return;
412        }
413        self.selected_split = if self.selected_split == 0 {
414            self.detail_splits.len() - 1
415        } else {
416            self.selected_split - 1
417        };
418    }
419
420    /// Open the broadcast compose flow — picker first when at
421    /// least one channel is declared, else fall back to the
422    /// project's `all` channel (PR-UI-5 behaviour) on the
423    /// assumption that `all` always exists in production composes.
424    pub fn enter_compose_broadcast_with_picker(&mut self) {
425        if self.team.channels.is_empty() {
426            // Fall back to the PR-UI-5 default if no channels
427            // are declared yet — should only happen with a
428            // half-loaded snapshot.
429            self.enter_compose_broadcast();
430            return;
431        }
432        let project_id = self
433            .team
434            .channels
435            .first()
436            .map(|c| c.project_id.clone())
437            .unwrap_or_default();
438        self.previous_stage = self.stage;
439        self.stage = Stage::ComposeModal;
440        self.compose_target = Some(ComposeTarget::Broadcast {
441            channel_id: format!("{project_id}:all"),
442            project_id,
443        });
444        self.compose_editor = Editor::default();
445        self.compose_error = None;
446        self.compose_picker_open = true;
447        self.compose_picker_index = 0;
448    }
449    pub fn picker_next(&mut self) {
450        if self.team.channels.is_empty() {
451            return;
452        }
453        self.compose_picker_index = (self.compose_picker_index + 1) % self.team.channels.len();
454    }
455    pub fn picker_prev(&mut self) {
456        if self.team.channels.is_empty() {
457            return;
458        }
459        self.compose_picker_index = if self.compose_picker_index == 0 {
460            self.team.channels.len() - 1
461        } else {
462            self.compose_picker_index - 1
463        };
464    }
465    pub fn picker_confirm(&mut self) {
466        if let Some(ch) = self.team.channels.get(self.compose_picker_index) {
467            self.compose_target = Some(ComposeTarget::Broadcast {
468                channel_id: ch.id.clone(),
469                project_id: ch.project_id.clone(),
470            });
471        }
472        self.compose_picker_open = false;
473    }
474
475    /// T-32: open the path-input overlay. Resets the buffer so a
476    /// previously-cancelled draft can't carry over.
477    pub fn open_compose_attach_input(&mut self) {
478        self.compose_attach_input_open = true;
479        self.compose_attach_buffer.clear();
480    }
481
482    /// T-32: append a `📎 attachment: <path>` line to the compose
483    /// editor and close the overlay. The line lands as a fresh row
484    /// at the end of the body so the operator can edit it (or delete
485    /// it) before sending. Whitespace-only buffers are ignored — Tab
486    /// followed by Enter shouldn't insert an empty marker.
487    pub fn confirm_compose_attach_input(&mut self) {
488        let path = self.compose_attach_buffer.trim().to_string();
489        if !path.is_empty() {
490            let marker = format!("📎 attachment: {path}");
491            // The body's final-trailing-blank rule (Editor::body)
492            // strips empty trailing lines, so an empty last line
493            // doesn't matter — we always push the marker as a new
494            // line after current contents.
495            if let Some(last) = self.compose_editor.lines.last_mut() {
496                if !last.is_empty() {
497                    self.compose_editor.lines.push(marker);
498                } else {
499                    *last = marker;
500                }
501            } else {
502                self.compose_editor.lines.push(marker);
503            }
504            // Park the cursor at end of the new line so subsequent
505            // typing in Insert mode picks up after the marker.
506            self.compose_editor.cursor_row = self.compose_editor.lines.len() - 1;
507            self.compose_editor.cursor_col = self
508                .compose_editor
509                .lines
510                .last()
511                .map(|l| l.len())
512                .unwrap_or(0);
513        }
514        self.close_compose_attach_input();
515    }
516
517    pub fn close_compose_attach_input(&mut self) {
518        self.compose_attach_input_open = false;
519        self.compose_attach_buffer.clear();
520    }
521
522    pub fn cycle_mailbox_tab(&mut self) {
523        self.mailbox_tab = self.mailbox_tab.next();
524    }
525
526    pub fn cycle_mailbox_tab_back(&mut self) {
527        self.mailbox_tab = self.mailbox_tab.prev();
528    }
529
530    pub fn cycle_focus_back(&mut self) {
531        self.focused_pane = self.focused_pane.prev();
532    }
533
534    pub fn has_pending_approvals(&self) -> bool {
535        !self.pending_approvals.is_empty()
536    }
537
538    pub fn enter_approvals_modal(&mut self) {
539        if self.pending_approvals.is_empty() {
540            return;
541        }
542        self.previous_stage = self.stage;
543        self.stage = Stage::ApprovalsModal;
544        self.selected_approval = 0;
545        self.approval_error = None;
546    }
547
548    pub fn close_approvals_modal(&mut self) {
549        self.stage = self.previous_stage;
550        self.approval_error = None;
551    }
552
553    pub fn cycle_approval_next(&mut self) {
554        if self.pending_approvals.is_empty() {
555            return;
556        }
557        self.selected_approval = (self.selected_approval + 1) % self.pending_approvals.len();
558    }
559
560    pub fn cycle_approval_prev(&mut self) {
561        if self.pending_approvals.is_empty() {
562            return;
563        }
564        self.selected_approval = if self.selected_approval == 0 {
565            self.pending_approvals.len() - 1
566        } else {
567            self.selected_approval - 1
568        };
569    }
570
571    pub fn focused_approval(&self) -> Option<&Approval> {
572        self.pending_approvals.get(self.selected_approval)
573    }
574
575    /// Replace the pending-approvals list. Closes the modal when
576    /// the queue empties (no row to act on); preserves the modal
577    /// otherwise but clamps `selected_approval` into range so an
578    /// approval resolved out-of-band doesn't leave us pointing at
579    /// a stale index.
580    pub fn replace_approvals(&mut self, approvals: Vec<Approval>) {
581        self.pending_approvals = approvals;
582        if self.pending_approvals.is_empty() {
583            if matches!(self.stage, Stage::ApprovalsModal) {
584                self.close_approvals_modal();
585            }
586            self.selected_approval = 0;
587        } else if self.selected_approval >= self.pending_approvals.len() {
588            self.selected_approval = self.pending_approvals.len() - 1;
589        }
590    }
591
592    /// Apply a decision to the focused approval via the injected
593    /// decider. The decider routes through `teamctl approve|deny`
594    /// in production; tests inject a recorder. On success the row
595    /// gets removed from the local `pending_approvals` snapshot
596    /// optimistically — the next `refresh_approvals` will reconcile
597    /// against the broker.
598    pub fn apply_decision<D: ApprovalDecider>(&mut self, decider: &D, kind: Decision, note: &str) {
599        let Some(approval) = self.focused_approval().cloned() else {
600            return;
601        };
602        match decider.decide(&self.team.root, approval.id, kind, note) {
603            Ok(()) => {
604                self.pending_approvals.retain(|a| a.id != approval.id);
605                self.approval_error = None;
606                if self.pending_approvals.is_empty() {
607                    self.close_approvals_modal();
608                } else if self.selected_approval >= self.pending_approvals.len() {
609                    self.selected_approval = self.pending_approvals.len() - 1;
610                }
611            }
612            Err(err) => {
613                self.approval_error = Some(err.to_string());
614            }
615        }
616    }
617
618    /// Open the compose modal for the focused agent (if any). The
619    /// `@` chord. No-op when no agent is focused.
620    pub fn enter_compose_dm_for_focused(&mut self) {
621        let Some(info) = self
622            .selected_agent
623            .and_then(|i| self.team.agents.get(i))
624            .cloned()
625        else {
626            return;
627        };
628        self.previous_stage = self.stage;
629        self.stage = Stage::ComposeModal;
630        self.compose_target = Some(ComposeTarget::Dm {
631            agent_id: info.id.clone(),
632            project_id: info.project.clone(),
633        });
634        self.compose_editor = Editor::default();
635        self.compose_error = None;
636    }
637
638    /// Open the compose modal targeting the project's `all`
639    /// channel — the broadcast wire. The `!` chord. PR-UI-5 ships
640    /// with channel scoping limited to `all` (the Wire tab is the
641    /// only channel context the mailbox pane currently surfaces);
642    /// PR-UI-6's mailbox UI work will widen the scope to per-channel
643    /// targets when individual channels become first-class in the
644    /// pane.
645    pub fn enter_compose_broadcast(&mut self) {
646        let project_id = self
647            .selected_agent
648            .and_then(|i| self.team.agents.get(i))
649            .map(|a| a.project.clone())
650            .or_else(|| self.team.agents.first().map(|a| a.project.clone()));
651        let Some(project_id) = project_id else {
652            return;
653        };
654        let channel_id = format!("{project_id}:all");
655        self.previous_stage = self.stage;
656        self.stage = Stage::ComposeModal;
657        self.compose_target = Some(ComposeTarget::Broadcast {
658            channel_id,
659            project_id,
660        });
661        self.compose_editor = Editor::default();
662        self.compose_error = None;
663    }
664
665    pub fn close_compose_modal(&mut self) {
666        self.stage = self.previous_stage;
667        self.compose_target = None;
668        self.compose_editor = Editor::default();
669        self.compose_error = None;
670        // T-32: ensure the attach overlay state can't survive a
671        // close-and-reopen of the modal.
672        self.compose_attach_input_open = false;
673        self.compose_attach_buffer.clear();
674    }
675
676    /// Send the current compose body via the injected message
677    /// sender. Routes through `teamctl send|broadcast` in
678    /// production; tests inject a recorder. Closes the modal +
679    /// triggers a mailbox refresh on success; surfaces error
680    /// inline on failure.
681    pub fn apply_send<S: MessageSender, M: MailboxSource>(
682        &mut self,
683        sender: &S,
684        mailbox_source: &M,
685    ) {
686        let Some(target) = self.compose_target.clone() else {
687            return;
688        };
689        let body = self.compose_editor.body();
690        if body.is_empty() {
691            self.compose_error = Some("body is empty".into());
692            return;
693        }
694        let result = match &target {
695            ComposeTarget::Dm { agent_id, .. } => sender.send_dm(&self.team.root, agent_id, &body),
696            ComposeTarget::Broadcast { channel_id, .. } => {
697                sender.broadcast(&self.team.root, channel_id, &body)
698            }
699        };
700        match result {
701            Ok(()) => {
702                self.close_compose_modal();
703                // Refresh the mailbox so the just-sent row appears
704                // in the relevant tab on the next paint.
705                refresh_mailbox(self, mailbox_source);
706            }
707            Err(err) => {
708                self.compose_error = Some(err.to_string());
709            }
710        }
711    }
712
713    pub fn dismiss_splash(&mut self) {
714        if matches!(self.stage, Stage::Splash) {
715            self.stage = Stage::Triptych;
716            self.previous_stage = Stage::Triptych;
717        }
718    }
719
720    pub fn cycle_focus(&mut self) {
721        self.focused_pane = self.focused_pane.next();
722    }
723
724    /// Move roster selection up by one — wraps at the top. No-op
725    /// when the team is empty. Does not change `focused_pane`.
726    /// Resets mailbox buffers when the resulting agent id differs
727    /// from the prior selection — switching agents should start the
728    /// operator at the head of fresh traffic.
729    pub fn select_prev(&mut self) {
730        if self.team.agents.is_empty() {
731            self.selected_agent = None;
732            return;
733        }
734        let prior = self.selected_agent_id();
735        self.selected_agent = Some(match self.selected_agent {
736            None | Some(0) => self.team.agents.len() - 1,
737            Some(i) => i - 1,
738        });
739        if prior != self.selected_agent_id() {
740            self.mailbox.reset();
741        }
742    }
743
744    /// Move roster selection down by one — wraps at the bottom.
745    /// No-op when the team is empty.
746    pub fn select_next(&mut self) {
747        if self.team.agents.is_empty() {
748            self.selected_agent = None;
749            return;
750        }
751        let prior = self.selected_agent_id();
752        self.selected_agent = Some(match self.selected_agent {
753            None => 0,
754            Some(i) => (i + 1) % self.team.agents.len(),
755        });
756        if prior != self.selected_agent_id() {
757            self.mailbox.reset();
758        }
759    }
760
761    /// `<project>:<agent>` of the currently selected agent, if any.
762    pub fn selected_agent_id(&self) -> Option<String> {
763        self.selected_agent
764            .and_then(|i| self.team.agents.get(i))
765            .map(|a| a.id.clone())
766    }
767
768    pub fn enter_quit_confirm(&mut self) {
769        self.previous_stage = self.stage;
770        self.stage = Stage::QuitConfirm;
771    }
772
773    pub fn cancel_quit(&mut self) {
774        self.stage = self.previous_stage;
775    }
776
777    pub fn confirm_quit(&mut self) {
778        self.running = false;
779    }
780
781    /// Replace the team snapshot. Preserves the current selection
782    /// when the agent at that index still exists; otherwise resets
783    /// to the first agent (or `None` for an empty team). Resets the
784    /// mailbox buffers when the resulting agent id differs from the
785    /// prior selection — same agent-changed contract as
786    /// `select_next` / `select_prev`.
787    pub fn replace_team(&mut self, team: TeamSnapshot) {
788        let prior_id = self.selected_agent_id();
789        self.team = team;
790        self.selected_agent = match (prior_id.clone(), self.team.agents.is_empty()) {
791            (_, true) => None,
792            (Some(id), false) => self.team.agents.iter().position(|a| a.id == id).or(Some(0)),
793            (None, false) => Some(0),
794        };
795        if prior_id != self.selected_agent_id() {
796            self.mailbox.reset();
797        }
798    }
799
800    /// Return the focused agent's tmux session name, if any. Used
801    /// by the run loop to know which session to capture.
802    pub fn focused_session(&self) -> Option<&str> {
803        self.selected_agent
804            .and_then(|i| self.team.agents.get(i))
805            .map(|a| a.tmux_session.as_str())
806    }
807
808    /// Tmux session that stream-keys mode should target. Cell 0 of
809    /// the detail-pane split layout is always the focused agent;
810    /// cells 1..N are the entries in `detail_splits`. When the
811    /// operator has focused a non-zero split, route stream-keys to
812    /// that split's agent — that's the cell visually showing as the
813    /// focus ring, so it's the one the operator expects to type into.
814    pub fn stream_target_session(&self) -> Option<String> {
815        if self.detail_splits.is_empty() || self.selected_split == 0 {
816            return self.focused_session().map(|s| s.to_string());
817        }
818        let split_idx = self.selected_split - 1;
819        let agent_id = self.detail_splits.get(split_idx).map(|(id, _)| id)?;
820        self.team
821            .agents
822            .iter()
823            .find(|a| &a.id == agent_id)
824            .map(|a| a.tmux_session.clone())
825    }
826
827    /// Enter stream-keys mode. No-op unless an agent is selected —
828    /// without a target session there's nothing to forward to.
829    /// Caller is responsible for the focused-pane gate (entry chord
830    /// only fires from `focused_pane == Pane::Detail`).
831    pub fn enter_stream_keys(&mut self) {
832        if self.stream_target_session().is_none() {
833            return;
834        }
835        self.previous_stage = self.stage;
836        self.stage = Stage::StreamKeys;
837    }
838
839    /// Exit stream-keys mode and return to whichever stage opened it.
840    /// `Esc` is the only exit chord per the issue's recommendation —
841    /// every other key (including `Ctrl+C`) forwards to the agent.
842    pub fn exit_stream_keys(&mut self) {
843        self.stage = self.previous_stage;
844    }
845
846    /// Replace the detail buffer, clipped at the recent-line cap.
847    pub fn set_detail_buffer(&mut self, lines: Vec<String>) {
848        let len = lines.len();
849        let start = len.saturating_sub(MAX_DETAIL_LINES);
850        self.detail_buffer = lines[start..].to_vec();
851    }
852}
853
854impl Default for App {
855    fn default() -> Self {
856        Self::new()
857    }
858}
859
860/// Refresh the team snapshot + the focused agent's pane capture +
861/// the mailbox tabs (PR-UI-3). Pulled out so tests can drive a
862/// single tick deterministically against `MockPaneSource` and
863/// `MockMailboxSource` without going through the event loop.
864pub fn refresh<P: PaneSource, M: MailboxSource, A: ApprovalSource>(
865    app: &mut App,
866    pane_source: &P,
867    mailbox_source: &M,
868    approval_source: &A,
869) {
870    if let Ok(Some(snapshot)) = TeamSnapshot::discover_and_load() {
871        app.replace_team(snapshot);
872    }
873    if let Some(session) = app.focused_session().map(|s| s.to_string()) {
874        if let Ok(lines) = pane_source.capture(&session) {
875            app.set_detail_buffer(lines);
876        }
877    } else {
878        app.detail_buffer.clear();
879    }
880    refresh_mailbox(app, mailbox_source);
881    refresh_approvals(app, approval_source);
882    app.last_refresh = Instant::now();
883}
884
885/// Approvals-only refresh. Extracted on the same shape as
886/// `refresh_mailbox` — PR-UI-5+ can call it on its own cadence
887/// (e.g. in response to a `notify` signal) without re-running the
888/// heavier paths. Errors degrade to "no pending" so the stripe
889/// just hides on a transient broker read failure.
890pub fn refresh_approvals<A: ApprovalSource>(app: &mut App, approval_source: &A) {
891    let approvals = approval_source.pending().unwrap_or_default();
892    app.replace_approvals(approvals);
893}
894
895/// Mailbox-only refresh — extracted so PR-UI-4+ can call it on its
896/// own cadence (e.g. in response to a broker INSERT signal) without
897/// re-running the heavier compose + tmux capture path. PR-UI-3
898/// just calls it from the main `refresh` once per tick.
899pub fn refresh_mailbox<M: MailboxSource>(app: &mut App, mailbox_source: &M) {
900    let Some(agent_id) = app.selected_agent_id() else {
901        // No agent focused → nothing to fetch. Buffers were already
902        // reset on selection change so the empty-state hint shows.
903        return;
904    };
905    let project_id = app
906        .selected_agent
907        .and_then(|i| app.team.agents.get(i))
908        .map(|a| a.project.clone())
909        .unwrap_or_default();
910    if let Ok(batch) = mailbox_source.inbox(&agent_id, app.mailbox.inbox_after) {
911        app.mailbox.extend(MailboxTab::Inbox, batch);
912    }
913    if let Ok(batch) = mailbox_source.sent(&agent_id, app.mailbox.sent_after) {
914        app.mailbox.extend(MailboxTab::Sent, batch);
915    }
916    if let Ok(batch) = mailbox_source.channel_feed(&agent_id, app.mailbox.channel_after) {
917        app.mailbox.extend(MailboxTab::Channel, batch);
918    }
919    if let Ok(batch) = mailbox_source.wire(&project_id, app.mailbox.wire_after) {
920        app.mailbox.extend(MailboxTab::Wire, batch);
921    }
922}
923
924pub fn run<B: Backend>(terminal: &mut Terminal<B>) -> Result<()> {
925    let mut app = App::new();
926    let pane_source = TmuxPaneSource;
927    let decider = CliApprovalDecider;
928    let sender = CliMessageSender;
929    let key_sender = TmuxKeySender;
930    let pane_resizer = crate::pane_resize::TmuxPaneResizer;
931    // First refresh resolves the team root; only then can we
932    // bring up the file-watcher, which keys on `<root>/state/`.
933    refresh_with_default_sources(&mut app, &pane_source);
934    let mut watch = Watch::try_new(&app.team.root.join("state"));
935    while app.running {
936        terminal.draw(|f| draw(f, &app))?;
937        // T-199: after every frame, push the focused agent's inner
938        // tmux pane to match teamctl-ui's Detail rect so claude
939        // reflows when the operator resizes the host terminal (or
940        // when focus switches to a different agent whose pane was
941        // last sized for a different layout). The cache inside
942        // `sync_focused_pane_size_to` keeps this to one HashMap
943        // lookup per frame in the no-op case.
944        let term_sz = terminal.size()?;
945        let term_area = ratatui::layout::Rect::new(0, 0, term_sz.width, term_sz.height);
946        sync_focused_pane_size_to(&mut app, term_area, &pane_resizer);
947        if event::poll(POLL_INTERVAL)? {
948            // The mailbox source for handle_event mirrors the
949            // refresh path; the same db_path key avoids divergence
950            // between read + write fanout.
951            let db_path = app.team.root.join("state/mailbox.db");
952            let mailbox_source = BrokerMailboxSource::new(db_path);
953            handle_event(
954                &mut app,
955                event::read()?,
956                &decider,
957                &sender,
958                &mailbox_source,
959                &key_sender,
960            );
961        }
962        if matches!(app.stage, Stage::Splash) && app.splash_started.elapsed() >= SPLASH_AUTO_DISMISS
963        {
964            app.dismiss_splash();
965        }
966        // Refresh on either (a) deadline elapsed or (b) the
967        // notify-watcher said the broker DB changed. The watcher
968        // shaves the typical refresh latency from ~1s to ~50ms when
969        // the platform supports it; on platforms without notify
970        // support `take_dirty` always returns false and the
971        // deadline path is the only trigger (PR-UI-3 behaviour).
972        let dirty = watch.take_dirty();
973        if dirty || app.last_refresh.elapsed() >= REFRESH_INTERVAL {
974            let prior_root = app.team.root.clone();
975            refresh_with_default_sources(&mut app, &pane_source);
976            // Team root drifted (operator launched in a different
977            // tree) → swap the watcher to the new state dir.
978            if app.team.root != prior_root {
979                watch = Watch::try_new(&app.team.root.join("state"));
980            }
981        }
982    }
983    Ok(())
984}
985
986/// T-199: push the focused agent's inner tmux pane to match the
987/// Detail rect teamctl-ui will draw into. No-op when:
988///
989/// - no agent is focused (nothing to size);
990/// - the active main layout isn't Triptych (Wall / MailboxFirst
991///   render differently and aren't in scope for this fix; flagged
992///   as follow-up surfaces in #199);
993/// - the Detail rect is degenerate (zero width or height — the
994///   helper returns `None` and we leave the pane alone);
995/// - the cached size for this session already matches.
996///
997/// The cache lives on `App` (`last_synced_pane_sizes`) so the
998/// common case (no resize, focused on same agent) is a HashMap
999/// lookup, not a subprocess spawn.
1000pub fn sync_focused_pane_size_to<R: crate::pane_resize::PaneResizer>(
1001    app: &mut App,
1002    total_area: ratatui::layout::Rect,
1003    resizer: &R,
1004) {
1005    if !matches!(app.layout, MainLayout::Triptych) {
1006        return;
1007    }
1008    let Some(detail) =
1009        crate::pane_resize::triptych_detail_area(total_area, app.has_pending_approvals())
1010    else {
1011        return;
1012    };
1013    let Some(session) = app.focused_session().map(|s| s.to_string()) else {
1014        return;
1015    };
1016    let target = (detail.width, detail.height);
1017    if !crate::pane_resize::should_sync(&app.last_synced_pane_sizes, &session, target) {
1018        return;
1019    }
1020    resizer.resize(&session, target.0, target.1);
1021    app.last_synced_pane_sizes.insert(session, target);
1022}
1023
1024/// Build the production `BrokerMailboxSource` + `BrokerApprovalSource`
1025/// from the current team root and run a refresh with all three
1026/// default sources. Lives here (rather than inline in `run`) so
1027/// the team-root → DB-path derivation has one home.
1028fn refresh_with_default_sources<P: PaneSource>(app: &mut App, pane_source: &P) {
1029    if let Ok(Some(snapshot)) = TeamSnapshot::discover_and_load() {
1030        app.replace_team(snapshot);
1031    }
1032    let db_path = app.team.root.join("state/mailbox.db");
1033    let mailbox_source = BrokerMailboxSource::new(db_path.clone());
1034    let approval_source = BrokerApprovalSource::new(db_path);
1035    if let Some(session) = app.focused_session().map(|s| s.to_string()) {
1036        if let Ok(lines) = pane_source.capture(&session) {
1037            app.set_detail_buffer(lines);
1038        }
1039    } else {
1040        app.detail_buffer.clear();
1041    }
1042    refresh_mailbox(app, &mailbox_source);
1043    refresh_approvals(app, &approval_source);
1044    // T-209: refresh the live CPU/RAM numbers on the same 1-second
1045    // cadence as the rest of App. `refresh_cpu_usage` + `refresh_memory`
1046    // are the minimal pair — `refresh_all` would also probe disks,
1047    // networks, processes, and components, none of which the status
1048    // bar shows. Sub-millisecond cost on modern hardware.
1049    app.sysinfo.refresh_cpu_usage();
1050    app.sysinfo.refresh_memory();
1051    app.last_refresh = Instant::now();
1052}
1053
1054pub fn draw(f: &mut Frame<'_>, app: &App) {
1055    let area = f.area();
1056    match app.stage {
1057        Stage::Splash => splash::draw(f, app),
1058        Stage::Triptych => draw_main(f, area, app),
1059        // T-108: stream-keys reuses the Triptych render path — the
1060        // detail pane carries the visual indicator (border + title +
1061        // statusline shift) via the `app.stage == StreamKeys` branch
1062        // in those widgets. No separate modal draw.
1063        Stage::StreamKeys => draw_main(f, area, app),
1064        Stage::QuitConfirm => {
1065            draw_main(f, area, app);
1066            draw_quit_confirm(f, area);
1067        }
1068        Stage::ApprovalsModal => {
1069            draw_main(f, area, app);
1070            draw_approvals_modal(f, area, app);
1071        }
1072        Stage::ComposeModal => {
1073            draw_main(f, area, app);
1074            draw_compose_modal(f, area, app);
1075        }
1076        Stage::HelpOverlay => {
1077            draw_main(f, area, app);
1078            let buf = f.buffer_mut();
1079            render_help_overlay(area, buf, app);
1080        }
1081        Stage::Tutorial => {
1082            draw_main(f, area, app);
1083            let buf = f.buffer_mut();
1084            render_tutorial(area, buf, app);
1085        }
1086    }
1087}
1088
1089fn render_help_overlay(area: Rect, buf: &mut Buffer, app: &App) {
1090    let popup_w = 70u16.min(area.width.saturating_sub(4));
1091    let popup_h = 24u16.min(area.height.saturating_sub(2));
1092    let popup = centered_rect(popup_w, popup_h, area);
1093    Clear.render(popup, buf);
1094    let block = Block::default()
1095        .title("help · ? to close")
1096        .borders(Borders::ALL)
1097        .border_style(Style::default().fg(app.capabilities.accent()));
1098    let inner = block.inner(popup);
1099    block.render(popup, buf);
1100    let muted = Style::default().fg(app.capabilities.muted());
1101    let bold = Style::default().add_modifier(Modifier::BOLD);
1102    let mut lines: Vec<ratatui::text::Line<'_>> = Vec::new();
1103    for group in crate::help::ALL_GROUPS {
1104        lines.push(ratatui::text::Line::styled(group.title, bold));
1105        for b in group.bindings {
1106            lines.push(ratatui::text::Line::raw(format!(
1107                "  {:<22}  {}",
1108                b.chord, b.description
1109            )));
1110        }
1111        lines.push(ratatui::text::Line::styled("", muted));
1112    }
1113    Paragraph::new(lines).render(inner, buf);
1114}
1115
1116fn render_tutorial(area: Rect, buf: &mut Buffer, app: &App) {
1117    let popup_w = 64u16.min(area.width.saturating_sub(4));
1118    let popup_h = 14u16.min(area.height.saturating_sub(2));
1119    let popup = centered_rect(popup_w, popup_h, area);
1120    Clear.render(popup, buf);
1121    let total = crate::onboarding::STEPS.len();
1122    let i = app.tutorial_step.min(total.saturating_sub(1));
1123    let step = &crate::onboarding::STEPS[i];
1124    let block = Block::default()
1125        .title(format!("tutorial · {}/{total}", i + 1))
1126        .borders(Borders::ALL)
1127        .border_style(Style::default().fg(app.capabilities.accent()));
1128    let inner = block.inner(popup);
1129    block.render(popup, buf);
1130    let muted = Style::default().fg(app.capabilities.muted());
1131    let lines = vec![
1132        ratatui::text::Line::styled(step.heading, Style::default().add_modifier(Modifier::BOLD)),
1133        ratatui::text::Line::raw(""),
1134        ratatui::text::Line::raw(step.body),
1135        ratatui::text::Line::raw(""),
1136        ratatui::text::Line::styled("any key next  ·  k / ↑ / p back  ·  Esc skip", muted),
1137    ];
1138    // T-074 bug 5: tutorial bodies are prose paragraphs, not pre-
1139    // formatted lines — clip-on-overflow leaves them looking truncated
1140    // on common (≤80 col) terminals. Soft-wrap with `trim: true` so
1141    // long step descriptions reflow into the modal width instead of
1142    // dropping off the right edge.
1143    Paragraph::new(lines)
1144        .wrap(ratatui::widgets::Wrap { trim: true })
1145        .render(inner, buf);
1146}
1147
1148fn draw_main(f: &mut Frame<'_>, area: Rect, app: &App) {
1149    // T-209: bottom of the screen is now a two-row footer —
1150    // existing keybindings statusline on top, new status bar
1151    // (cwd-left + CPU/RAM-right; T-212 will fill the center slot
1152    // per coordination with kian) below. Both 1 row tall.
1153    let chunks = Layout::default()
1154        .direction(Direction::Vertical)
1155        .constraints([
1156            Constraint::Min(3),
1157            Constraint::Length(1), // existing keybindings statusline
1158            Constraint::Length(1), // T-209 bottom status bar
1159        ])
1160        .split(area);
1161    let buf = f.buffer_mut();
1162    match app.layout {
1163        crate::triptych::MainLayout::Triptych => {
1164            triptych::Triptych { app }.render(chunks[0], buf);
1165        }
1166        crate::triptych::MainLayout::Wall => {
1167            layouts::Wall { app }.render(chunks[0], buf);
1168        }
1169        crate::triptych::MainLayout::MailboxFirst => {
1170            layouts::MailboxFirst { app }.render(chunks[0], buf);
1171        }
1172    }
1173    statusline::Statusline { app }.render(chunks[1], buf);
1174    status_bar::StatusBar { app }.render(chunks[2], buf);
1175}
1176
1177fn draw_approvals_modal(f: &mut Frame<'_>, area: Rect, app: &App) {
1178    let buf = f.buffer_mut();
1179    render_approvals_modal(area, buf, app);
1180}
1181
1182fn draw_compose_modal(f: &mut Frame<'_>, area: Rect, app: &App) {
1183    let buf = f.buffer_mut();
1184    render_compose_modal(area, buf, app);
1185}
1186
1187fn render_compose_picker_body(inner: Rect, buf: &mut Buffer, app: &App) {
1188    let muted = Style::default().fg(app.capabilities.muted());
1189    let chunks = Layout::default()
1190        .direction(Direction::Vertical)
1191        .constraints([
1192            Constraint::Min(1),
1193            Constraint::Length(1),
1194            Constraint::Length(1),
1195        ])
1196        .split(inner);
1197    let lines: Vec<ratatui::text::Line<'_>> = if app.team.channels.is_empty() {
1198        vec![ratatui::text::Line::styled(
1199            "(no channels declared in team-compose)",
1200            muted,
1201        )]
1202    } else {
1203        app.team
1204            .channels
1205            .iter()
1206            .enumerate()
1207            .map(|(i, ch)| {
1208                let label = format!("  #{}  ({})", ch.name, ch.project_id);
1209                let style = if i == app.compose_picker_index {
1210                    Style::default()
1211                        .fg(app.capabilities.accent())
1212                        .add_modifier(Modifier::REVERSED)
1213                } else {
1214                    Style::default()
1215                };
1216                ratatui::text::Line::styled(label, style)
1217            })
1218            .collect()
1219    };
1220    Paragraph::new(lines).render(chunks[0], buf);
1221    Paragraph::new("pick a channel to broadcast to")
1222        .style(muted)
1223        .render(chunks[1], buf);
1224    Paragraph::new("Enter pick · j/k navigate · Esc cancel")
1225        .style(muted)
1226        .render(chunks[2], buf);
1227}
1228
1229fn render_compose_modal(area: Rect, buf: &mut Buffer, app: &App) {
1230    let popup_w = 80u16.min(area.width.saturating_sub(4));
1231    let popup_h = 16u16.min(area.height.saturating_sub(2));
1232    let popup = centered_rect(popup_w, popup_h, area);
1233    Clear.render(popup, buf);
1234    let title = app
1235        .compose_target
1236        .as_ref()
1237        .map(|t| t.title(&app.team))
1238        .unwrap_or_else(|| "→ ?".into());
1239    let block = Block::default()
1240        .title(title)
1241        .borders(Borders::ALL)
1242        .border_style(Style::default().fg(app.capabilities.accent()));
1243    let inner = block.inner(popup);
1244    block.render(popup, buf);
1245
1246    if inner.height < 3 {
1247        return;
1248    }
1249    // PR-UI-6: when the broadcast picker is open we render a
1250    // channel-list inside the modal instead of the editor; the
1251    // editor footer stays so operators see the same layout.
1252    if app.compose_picker_open {
1253        render_compose_picker_body(inner, buf, app);
1254        return;
1255    }
1256    if app.compose_attach_input_open {
1257        render_compose_attach_input(inner, buf, app);
1258        return;
1259    }
1260    // Reserve the bottom two rows: an error line (rendered when
1261    // present, blank otherwise) and the footer with key hints.
1262    let chunks = Layout::default()
1263        .direction(Direction::Vertical)
1264        .constraints([
1265            Constraint::Min(1),    // editor body
1266            Constraint::Length(1), // error / status
1267            Constraint::Length(1), // footer
1268        ])
1269        .split(inner);
1270
1271    // Body — render lines with a `▏` cursor marker on the active
1272    // row when in Insert. Skip cursor cell in Normal/Ex modes so
1273    // the operator's eye finds the row by row context, not a
1274    // blinking caret.
1275    let muted = Style::default().fg(app.capabilities.muted());
1276    let body_lines: Vec<ratatui::text::Line<'_>> = app
1277        .compose_editor
1278        .lines
1279        .iter()
1280        .enumerate()
1281        .map(|(row, line)| {
1282            if row == app.compose_editor.cursor_row
1283                && app.compose_editor.mode == crate::compose::VimMode::Insert
1284            {
1285                let col = app.compose_editor.cursor_col.min(line.len());
1286                let (head, tail) = line.split_at(col);
1287                ratatui::text::Line::from(vec![
1288                    ratatui::text::Span::raw(head.to_string()),
1289                    ratatui::text::Span::styled(
1290                        "▏",
1291                        Style::default().fg(app.capabilities.accent()),
1292                    ),
1293                    ratatui::text::Span::raw(tail.to_string()),
1294                ])
1295            } else {
1296                ratatui::text::Line::raw(line.clone())
1297            }
1298        })
1299        .collect();
1300    Paragraph::new(body_lines).render(chunks[0], buf);
1301
1302    let error_line = match (&app.compose_error, app.compose_editor.mode) {
1303        (Some(e), _) => format!("error: {e}"),
1304        (None, crate::compose::VimMode::Ex) => format!(":{}", app.compose_editor.ex_buffer),
1305        (None, crate::compose::VimMode::Normal) => "-- NORMAL --".into(),
1306        (None, crate::compose::VimMode::Insert) => "-- INSERT --".into(),
1307    };
1308    let style = if app.compose_error.is_some() {
1309        Style::default().fg(app.capabilities.accent())
1310    } else {
1311        muted
1312    };
1313    Paragraph::new(error_line)
1314        .style(style)
1315        .render(chunks[1], buf);
1316
1317    Paragraph::new("Alt+Enter send · Esc Esc cancel · Tab attach")
1318        .style(muted)
1319        .render(chunks[2], buf);
1320}
1321
1322/// T-32: render the path-input overlay. Single-line buffer + a
1323/// caret marker, with hints for confirm/cancel. Mirrors the picker
1324/// overlay's layout (body / status line / footer) so the modal's
1325/// vertical rhythm doesn't shift between the two overlays.
1326fn render_compose_attach_input(inner: Rect, buf: &mut Buffer, app: &App) {
1327    let muted = Style::default().fg(app.capabilities.muted());
1328    let chunks = Layout::default()
1329        .direction(Direction::Vertical)
1330        .constraints([
1331            Constraint::Min(1),
1332            Constraint::Length(1),
1333            Constraint::Length(1),
1334        ])
1335        .split(inner);
1336    let line = ratatui::text::Line::from(vec![
1337        ratatui::text::Span::raw(format!("path: {}", app.compose_attach_buffer)),
1338        ratatui::text::Span::styled("▏", Style::default().fg(app.capabilities.accent())),
1339    ]);
1340    Paragraph::new(line).render(chunks[0], buf);
1341    Paragraph::new("type or paste an absolute path; the agent reads it via the broker")
1342        .style(muted)
1343        .render(chunks[1], buf);
1344    Paragraph::new("Enter confirm · Esc cancel")
1345        .style(muted)
1346        .render(chunks[2], buf);
1347}
1348
1349fn render_approvals_modal(area: Rect, buf: &mut Buffer, app: &App) {
1350    let popup_w = 80u16.min(area.width.saturating_sub(4));
1351    let popup_h = 18u16.min(area.height.saturating_sub(2));
1352    let popup = centered_rect(popup_w, popup_h, area);
1353    Clear.render(popup, buf);
1354    let n = app.pending_approvals.len();
1355    let i = app.selected_approval.min(n.saturating_sub(1));
1356    let title = format!("approvals · {}/{n}", i + 1);
1357    let block = Block::default()
1358        .title(title)
1359        .borders(Borders::ALL)
1360        .border_style(Style::default().fg(app.capabilities.accent()));
1361    let inner = block.inner(popup);
1362    block.render(popup, buf);
1363
1364    let muted = Style::default().fg(app.capabilities.muted());
1365    let bold = Style::default().add_modifier(Modifier::BOLD);
1366
1367    let Some(a) = app.focused_approval() else {
1368        Paragraph::new("(no pending approvals)")
1369            .style(muted)
1370            .alignment(Alignment::Center)
1371            .render(inner, buf);
1372        return;
1373    };
1374
1375    let mut lines: Vec<ratatui::text::Line<'_>> = vec![
1376        ratatui::text::Line::styled(format!("#{}  {}", a.id, a.action), bold),
1377        ratatui::text::Line::styled(
1378            format!("from: {}", crate::data::agent_label(&app.team, &a.agent_id)),
1379            muted,
1380        ),
1381        ratatui::text::Line::raw(""),
1382        ratatui::text::Line::raw(a.summary.clone()),
1383    ];
1384    if !a.payload_json.is_empty() && a.payload_json != "{}" {
1385        lines.push(ratatui::text::Line::raw(""));
1386        lines.push(ratatui::text::Line::styled("payload:", muted));
1387        for chunk in a.payload_json.lines().take(4) {
1388            lines.push(ratatui::text::Line::raw(chunk.to_string()));
1389        }
1390    }
1391    if let Some(err) = &app.approval_error {
1392        lines.push(ratatui::text::Line::raw(""));
1393        lines.push(ratatui::text::Line::styled(
1394            format!("error: {err}"),
1395            Style::default().fg(app.capabilities.accent()),
1396        ));
1397    }
1398    lines.push(ratatui::text::Line::raw(""));
1399    lines.push(ratatui::text::Line::styled(
1400        "[y] approve  ·  [Shift-N] deny  ·  [j/k] cycle  ·  [Esc] close",
1401        muted,
1402    ));
1403    Paragraph::new(lines).render(inner, buf);
1404}
1405
1406fn draw_quit_confirm(f: &mut Frame<'_>, area: Rect) {
1407    let popup_w = 36u16.min(area.width.saturating_sub(2));
1408    let popup_h = 5u16.min(area.height.saturating_sub(2));
1409    let popup = centered_rect(popup_w, popup_h, area);
1410    let buf = f.buffer_mut();
1411    Clear.render(popup, buf);
1412    Paragraph::new("Quit teamctl-ui?  [y / n]")
1413        .alignment(Alignment::Center)
1414        .block(Block::default().borders(Borders::ALL).title("confirm"))
1415        .render(popup, buf);
1416}
1417
1418fn centered_rect(w: u16, h: u16, area: Rect) -> Rect {
1419    let x = area.x + area.width.saturating_sub(w) / 2;
1420    let y = area.y + area.height.saturating_sub(h) / 2;
1421    Rect {
1422        x,
1423        y,
1424        width: w,
1425        height: h,
1426    }
1427}
1428
1429pub fn handle_event<D: ApprovalDecider, S: MessageSender, M: MailboxSource, K: KeySender>(
1430    app: &mut App,
1431    ev: Event,
1432    decider: &D,
1433    sender: &S,
1434    mailbox_source: &M,
1435    key_sender: &K,
1436) {
1437    use crossterm::event::KeyModifiers;
1438    match ev {
1439        Event::Key(k) if k.kind == KeyEventKind::Press => match app.stage {
1440            Stage::Splash => app.dismiss_splash(),
1441            Stage::Triptych => match k.code {
1442                // PR-UI-7 chord-prefix follow-ups MUST be tested
1443                // before unguarded `Char('q')` / `Char('o')` arms,
1444                // otherwise the no-modifier `q` quit would shadow
1445                // the `Ctrl+W q` close-split.
1446                KeyCode::Char('q') if app.pending_chord == Some(KeyCode::Char('w')) => {
1447                    app.pending_chord = None;
1448                    app.close_focused_split();
1449                }
1450                KeyCode::Char('o') if app.pending_chord == Some(KeyCode::Char('w')) => {
1451                    app.pending_chord = None;
1452                    if !app.detail_splits.is_empty() {
1453                        let keep = app.selected_split.min(app.detail_splits.len() - 1);
1454                        let kept = app.detail_splits.remove(keep);
1455                        app.detail_splits.clear();
1456                        app.detail_splits.push(kept);
1457                        app.selected_split = 0;
1458                    }
1459                }
1460                KeyCode::Char('q') if k.modifiers.is_empty() => app.enter_quit_confirm(),
1461                // PR-UI-4: `a` opens the approvals modal when there's
1462                // at least one pending row. No-op otherwise so the
1463                // chord doesn't surprise anyone hammering keys.
1464                KeyCode::Char('a') => app.enter_approvals_modal(),
1465                // PR-UI-5: `@` opens DM compose to focused agent.
1466                // PR-UI-6: `!` now opens the broadcast picker so
1467                // operators choose which channel to broadcast to,
1468                // not just the project's `all` wire.
1469                KeyCode::Char('@') => app.enter_compose_dm_for_focused(),
1470                KeyCode::Char('!') => app.enter_compose_broadcast_with_picker(),
1471                // PR-UI-7 chord-prefix: when there's at least one
1472                // detail split, `Ctrl+W` arms the chord-prefix
1473                // (the next key dispatches `q` close-split, `o`
1474                // close-others). Tested BEFORE the wall-layout
1475                // toggle below so the chord-prefix wins when
1476                // relevant. Both casings accepted because CapsLock
1477                // / Shift+Ctrl produce `Char('W')`; armed value is
1478                // normalised to lowercase so the follow-up arms
1479                // above can match a single literal.
1480                KeyCode::Char('w') | KeyCode::Char('W')
1481                    if k.modifiers.contains(KeyModifiers::CONTROL)
1482                        && !app.detail_splits.is_empty() =>
1483                {
1484                    app.pending_chord = Some(KeyCode::Char('w'))
1485                }
1486                // PR-UI-6: layout toggles. `Ctrl+W` for Wall when
1487                // there are no splits to chord on; `Ctrl+M` for
1488                // MailboxFirst (always). Both casings accepted —
1489                // see the chord-arm comment above.
1490                KeyCode::Char('w') | KeyCode::Char('W')
1491                    if k.modifiers.contains(KeyModifiers::CONTROL) =>
1492                {
1493                    app.toggle_wall_layout()
1494                }
1495                KeyCode::Char('m') | KeyCode::Char('M')
1496                    if k.modifiers.contains(KeyModifiers::CONTROL) =>
1497                {
1498                    app.toggle_mailbox_first_layout()
1499                }
1500                // PR-UI-7 splitscreen lift: `Ctrl+|` subdivides
1501                // vertically, `Ctrl+-` horizontally — vim/tmux
1502                // operators' muscle memory matches the visual.
1503                KeyCode::Char('|') if k.modifiers.contains(KeyModifiers::CONTROL) => {
1504                    app.add_detail_split_vertical()
1505                }
1506                KeyCode::Char('-') if k.modifiers.contains(KeyModifiers::CONTROL) => {
1507                    app.add_detail_split_horizontal()
1508                }
1509                // Vim window-motion `Ctrl+H/J/K/L` cycles between
1510                // splits when there's more than one. Both casings
1511                // accepted — see the Ctrl+W chord-arm comment above
1512                // for the CapsLock + Shift+Ctrl rationale.
1513                KeyCode::Char('h')
1514                | KeyCode::Char('H')
1515                | KeyCode::Char('k')
1516                | KeyCode::Char('K')
1517                    if k.modifiers.contains(KeyModifiers::CONTROL) =>
1518                {
1519                    app.cycle_split_prev()
1520                }
1521                KeyCode::Char('l')
1522                | KeyCode::Char('L')
1523                | KeyCode::Char('j')
1524                | KeyCode::Char('J')
1525                    if k.modifiers.contains(KeyModifiers::CONTROL) =>
1526                {
1527                    app.cycle_split_next()
1528                }
1529                // PR-UI-6 alias preserved for back-compat: `Ctrl+Q`
1530                // closes the focused split. PR-UI-7 also wires the
1531                // proper `Ctrl+W q` chord; both work. Both casings
1532                // accepted for the same reason as Ctrl+W/M.
1533                KeyCode::Char('q') | KeyCode::Char('Q')
1534                    if k.modifiers.contains(KeyModifiers::CONTROL) =>
1535                {
1536                    app.close_focused_split()
1537                }
1538                // T-108: `Ctrl+E` activates stream-keys mode when the
1539                // detail pane is focused — every subsequent keystroke
1540                // forwards to the agent's tmux pane. Gated on detail
1541                // focus so operators in the roster / mailbox don't
1542                // get pulled into stream-mode by a stray chord. Both
1543                // casings accepted for the CapsLock/Shift+Ctrl case
1544                // (same rationale as Ctrl+W/M arms above).
1545                KeyCode::Char('e') | KeyCode::Char('E')
1546                    if k.modifiers.contains(KeyModifiers::CONTROL)
1547                        && app.focused_pane == Pane::Detail =>
1548                {
1549                    app.enter_stream_keys()
1550                }
1551                // (chord-prefix follow-ups handled at top of arm
1552                // before unguarded letter-arms — see top of
1553                // `Stage::Triptych` match.)
1554                // PR-UI-7 help + tutorial chords. `?` opens help
1555                // overlay; `t` reopens tutorial. Both no-op if a
1556                // modifier is in flight (so `Shift+?` and `Ctrl+T`
1557                // don't double-bind).
1558                KeyCode::Char('?')
1559                    if k.modifiers.is_empty() || k.modifiers == KeyModifiers::SHIFT =>
1560                {
1561                    app.enter_help_overlay()
1562                }
1563                KeyCode::Char('t') if k.modifiers.is_empty() => app.enter_tutorial(),
1564                // PR-UI-4: Shift+Tab cycles panes backward. Some
1565                // terminals send `BackTab`, others send `Tab` with
1566                // SHIFT — handle both.
1567                KeyCode::BackTab => app.cycle_focus_back(),
1568                KeyCode::Tab if k.modifiers.contains(KeyModifiers::SHIFT) => app.cycle_focus_back(),
1569                // T-074 bug 6: Tab always cycles pane focus, never
1570                // mailbox tabs. Previously Tab routed into mailbox
1571                // tab-cycling when the mailbox pane was focused —
1572                // this stranded operators inside the mailbox with no
1573                // discoverable way out (Alireza's exact report). The
1574                // vim/tmux convention is "Tab moves between panes";
1575                // honour it across every pane uniformly.
1576                KeyCode::Tab => app.cycle_focus(),
1577                // T-124: mailbox sub-navigation uses Left/Right
1578                // arrows (more discoverable than the prior `[`/`]`
1579                // chord). Gated on the mailbox pane being focused
1580                // so the keys stay unsurprising elsewhere — Up/Down
1581                // remain free to scroll layout-specific lists, and
1582                // Left/Right have no other binding today.
1583                KeyCode::Right if app.focused_pane == Pane::Mailbox => app.cycle_mailbox_tab(),
1584                KeyCode::Left if app.focused_pane == Pane::Mailbox => app.cycle_mailbox_tab_back(),
1585                // PR-UI-6: in Wall layout, `j`/`k` (and arrows)
1586                // scroll the tile grid — same vim shape, different
1587                // surface. In Triptych roster focus they still
1588                // navigate the roster.
1589                KeyCode::Up | KeyCode::Char('k') if matches!(app.layout, MainLayout::Wall) => {
1590                    app.wall_scroll_up()
1591                }
1592                KeyCode::Down | KeyCode::Char('j') if matches!(app.layout, MainLayout::Wall) => {
1593                    app.wall_scroll_down()
1594                }
1595                // PR-UI-6: in MailboxFirst, `j`/`k` walk the
1596                // channel list.
1597                KeyCode::Up | KeyCode::Char('k')
1598                    if matches!(app.layout, MainLayout::MailboxFirst) =>
1599                {
1600                    app.select_prev_channel()
1601                }
1602                KeyCode::Down | KeyCode::Char('j')
1603                    if matches!(app.layout, MainLayout::MailboxFirst) =>
1604                {
1605                    app.select_next_channel()
1606                }
1607                // Roster navigation — only when roster is the
1608                // focused pane. j/k mirror Vim; arrows mirror
1609                // every-day navigation.
1610                KeyCode::Up | KeyCode::Char('k') if app.focused_pane == Pane::Roster => {
1611                    app.select_prev()
1612                }
1613                KeyCode::Down | KeyCode::Char('j') if app.focused_pane == Pane::Roster => {
1614                    app.select_next()
1615                }
1616                _ => {}
1617            },
1618            Stage::QuitConfirm => match k.code {
1619                KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => app.confirm_quit(),
1620                KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => app.cancel_quit(),
1621                _ => {}
1622            },
1623            Stage::ApprovalsModal => match k.code {
1624                // Asymmetric chord shape (T-074 bug 4 fix): approve is
1625                // the common path so it accepts both `y` and `Y` —
1626                // matches QuitConfirm's loose convention and the
1627                // muscle-memory most TUI prompts build. Deny is the
1628                // destructive side, so it requires deliberate Shift
1629                // (`N` only); a stray lowercase `n` does nothing.
1630                // Trades cosmetic chord-symmetry for discoverability
1631                // on the load-bearing approve flow.
1632                KeyCode::Char('y') | KeyCode::Char('Y') => {
1633                    app.apply_decision(decider, Decision::Approve, "")
1634                }
1635                KeyCode::Char('N') => app.apply_decision(decider, Decision::Deny, ""),
1636                KeyCode::Char('j') | KeyCode::Down => app.cycle_approval_next(),
1637                KeyCode::Char('k') | KeyCode::Up => app.cycle_approval_prev(),
1638                KeyCode::Esc | KeyCode::Char('q') => app.close_approvals_modal(),
1639                _ => {}
1640            },
1641            Stage::ComposeModal => {
1642                // PR-UI-6: when the broadcast picker is open the
1643                // editor doesn't see keys yet — operator first
1644                // chooses a channel.
1645                if app.compose_picker_open {
1646                    match k.code {
1647                        KeyCode::Down | KeyCode::Char('j') => app.picker_next(),
1648                        KeyCode::Up | KeyCode::Char('k') => app.picker_prev(),
1649                        KeyCode::Enter => app.picker_confirm(),
1650                        // PR-UI-6 fixup (Q6, dev2 review): Esc
1651                        // dismisses the picker overlay only and
1652                        // returns to the editor with whatever the
1653                        // operator already typed; the editor's own
1654                        // Esc-Esc cancel-the-modal flow handles
1655                        // bailing out of the whole compose. Mirrors
1656                        // the overlay-vs-modal symmetry vim users
1657                        // expect.
1658                        KeyCode::Esc => {
1659                            app.compose_picker_open = false;
1660                            app.compose_picker_index = 0;
1661                        }
1662                        _ => {}
1663                    }
1664                } else if app.compose_attach_input_open {
1665                    // T-32: path-input overlay. Keys edit the buffer
1666                    // directly; Enter confirms (appends marker line
1667                    // to the editor body); Esc cancels back to the
1668                    // editor. Same overlay-vs-modal symmetry as the
1669                    // picker — Esc dismisses *the overlay*, not the
1670                    // whole compose.
1671                    match k.code {
1672                        KeyCode::Char(c) => app.compose_attach_buffer.push(c),
1673                        KeyCode::Backspace => {
1674                            app.compose_attach_buffer.pop();
1675                        }
1676                        KeyCode::Enter => app.confirm_compose_attach_input(),
1677                        KeyCode::Esc => app.close_compose_attach_input(),
1678                        _ => {}
1679                    }
1680                } else if k.code == KeyCode::Tab {
1681                    // T-32: Tab opens the path-input overlay. The
1682                    // editor never sees Tab today (apply_insert
1683                    // ignores it), so intercepting here doesn't
1684                    // change the editor's surface.
1685                    app.open_compose_attach_input();
1686                } else {
1687                    // Route every keypress through the editor; the
1688                    // editor returns Send / Cancel / Continue.
1689                    match app.compose_editor.apply_key(k) {
1690                        EditorAction::Continue => {}
1691                        EditorAction::Send => app.apply_send(sender, mailbox_source),
1692                        EditorAction::Cancel => app.close_compose_modal(),
1693                    }
1694                }
1695            }
1696            Stage::HelpOverlay => match k.code {
1697                KeyCode::Esc | KeyCode::Char('?') | KeyCode::Char('q') => app.close_help_overlay(),
1698                _ => {}
1699            },
1700            Stage::Tutorial => match k.code {
1701                KeyCode::Esc => app.close_tutorial(),
1702                KeyCode::Char('k') | KeyCode::Up | KeyCode::Char('p') => app.tutorial_back(),
1703                _ => app.tutorial_advance(),
1704            },
1705            // T-108 stream-keys mode. Esc is the only chord we
1706            // intercept — every other key (including `Ctrl+C`,
1707            // `Ctrl+E`, arrow keys, `Enter`) forwards to the agent's
1708            // tmux pane. The pass-through behaviour is intentional:
1709            // the operator is "effectively attached," and a shell-
1710            // user's Ctrl+C should send SIGINT to the agent, not
1711            // bail them out of the mode they just entered.
1712            Stage::StreamKeys => {
1713                if matches!(k.code, KeyCode::Esc) {
1714                    app.exit_stream_keys();
1715                } else if let Some(session) = app.stream_target_session() {
1716                    if let Some(encoded) = encode_key(k) {
1717                        // Best-effort: a tmux failure (session
1718                        // vanished, target pane gone) is silent in
1719                        // v1; the next refresh tick reflects whatever
1720                        // the agent's pane actually shows.
1721                        let _ = key_sender.send(&session, &encoded);
1722                    }
1723                } else {
1724                    // Target session disappeared mid-stream (agent
1725                    // restarted, team reloaded). Drop back to
1726                    // Triptych so the operator isn't typing into the
1727                    // void with no feedback.
1728                    app.exit_stream_keys();
1729                }
1730            }
1731        },
1732        Event::Resize(_, _) => {
1733            // ratatui redraws on the next loop iteration; nothing to do.
1734        }
1735        // T-158: mouse-wheel routes by focused pane. Detail forwards
1736        // each tick to the agent's tmux pane as a copy-mode scroll —
1737        // wheel-up enters copy-mode and walks history, wheel-down
1738        // walks back toward live. Roster steps the agent selection
1739        // (same step as `j`/`k`). Mailbox is a no-op until T-131
1740        // lands the row-cursor state for the rows to scroll; the
1741        // routing scaffold is in place so that fill-in is local.
1742        // Stages other than Triptych ignore mouse input — modal
1743        // overlays (compose, approvals, picker, help) own the screen
1744        // and shouldn't get a surprise scroll routed past them.
1745        Event::Mouse(m) if matches!(app.stage, Stage::Triptych) => {
1746            use crossterm::event::MouseEventKind;
1747            let direction = match m.kind {
1748                MouseEventKind::ScrollUp => Some(ScrollDirection::Up),
1749                MouseEventKind::ScrollDown => Some(ScrollDirection::Down),
1750                _ => None,
1751            };
1752            if let Some(dir) = direction {
1753                match app.focused_pane {
1754                    Pane::Detail => {
1755                        if let Some(session) = app.focused_session().map(|s| s.to_string()) {
1756                            // Best-effort, same convention as
1757                            // stream-keys: tmux failure (session
1758                            // vanished) is silent; the next refresh
1759                            // reflects reality.
1760                            let _ = key_sender.scroll(&session, dir);
1761                        }
1762                    }
1763                    Pane::Roster => match dir {
1764                        ScrollDirection::Up => app.select_prev(),
1765                        ScrollDirection::Down => app.select_next(),
1766                    },
1767                    Pane::Mailbox => {
1768                        // T-131 will wire row-level scroll state
1769                        // here. v1 ships the routing only.
1770                    }
1771                }
1772            }
1773        }
1774        _ => {}
1775    }
1776}
1777
1778/// Render the entire UI into a `Buffer` at fixed size — used by the
1779/// snapshot tests. Mirrors `draw` exactly but doesn't require a
1780/// `Terminal`. Update both in lockstep when adding new stages.
1781pub fn render_to_buffer(app: &App, width: u16, height: u16) -> Buffer {
1782    let area = Rect::new(0, 0, width, height);
1783    let mut buf = Buffer::empty(area);
1784    match app.stage {
1785        Stage::Splash => splash::Splash { app }.render(area, &mut buf),
1786        Stage::Triptych => render_main(app, area, &mut buf),
1787        Stage::StreamKeys => render_main(app, area, &mut buf),
1788        Stage::QuitConfirm => {
1789            render_main(app, area, &mut buf);
1790            render_quit_confirm(area, &mut buf);
1791        }
1792        Stage::ApprovalsModal => {
1793            render_main(app, area, &mut buf);
1794            render_approvals_modal(area, &mut buf, app);
1795        }
1796        Stage::ComposeModal => {
1797            render_main(app, area, &mut buf);
1798            render_compose_modal(area, &mut buf, app);
1799        }
1800        Stage::HelpOverlay => {
1801            render_main(app, area, &mut buf);
1802            render_help_overlay(area, &mut buf, app);
1803        }
1804        Stage::Tutorial => {
1805            render_main(app, area, &mut buf);
1806            render_tutorial(area, &mut buf, app);
1807        }
1808    }
1809    buf
1810}
1811
1812fn render_main(app: &App, area: Rect, buf: &mut Buffer) {
1813    // T-209: two-row footer — keep this in lockstep with `draw_main`
1814    // (snapshot tests render via this fn, the runtime via the other).
1815    let chunks = Layout::default()
1816        .direction(Direction::Vertical)
1817        .constraints([
1818            Constraint::Min(3),
1819            Constraint::Length(1), // existing keybindings statusline
1820            Constraint::Length(1), // T-209 bottom status bar
1821        ])
1822        .split(area);
1823    match app.layout {
1824        crate::triptych::MainLayout::Triptych => {
1825            triptych::Triptych { app }.render(chunks[0], buf);
1826        }
1827        crate::triptych::MainLayout::Wall => {
1828            layouts::Wall { app }.render(chunks[0], buf);
1829        }
1830        crate::triptych::MainLayout::MailboxFirst => {
1831            layouts::MailboxFirst { app }.render(chunks[0], buf);
1832        }
1833    }
1834    statusline::Statusline { app }.render(chunks[1], buf);
1835    status_bar::StatusBar { app }.render(chunks[2], buf);
1836}
1837
1838fn render_quit_confirm(area: Rect, buf: &mut Buffer) {
1839    let popup_w = 36u16.min(area.width.saturating_sub(2));
1840    let popup_h = 5u16.min(area.height.saturating_sub(2));
1841    let popup = centered_rect(popup_w, popup_h, area);
1842    Clear.render(popup, buf);
1843    Paragraph::new("Quit teamctl-ui?  [y / n]")
1844        .alignment(Alignment::Center)
1845        .block(Block::default().borders(Borders::ALL).title("confirm"))
1846        .render(popup, buf);
1847}
1848
1849#[cfg(test)]
1850mod tests {
1851    use super::*;
1852    use crate::data::AgentInfo;
1853    use crossterm::event::{KeyEvent, KeyEventState, KeyModifiers};
1854    use team_core::supervisor::AgentState;
1855
1856    fn key(code: KeyCode) -> Event {
1857        Event::Key(KeyEvent {
1858            code,
1859            modifiers: KeyModifiers::NONE,
1860            kind: KeyEventKind::Press,
1861            state: KeyEventState::NONE,
1862        })
1863    }
1864
1865    fn key_with(code: KeyCode, modifiers: KeyModifiers) -> Event {
1866        Event::Key(KeyEvent {
1867            code,
1868            modifiers,
1869            kind: KeyEventKind::Press,
1870            state: KeyEventState::NONE,
1871        })
1872    }
1873
1874    /// Noop decider for tests that don't exercise approve/deny.
1875    struct NoopDecider;
1876    impl crate::approvals::ApprovalDecider for NoopDecider {
1877        fn decide(
1878            &self,
1879            _root: &std::path::Path,
1880            _id: i64,
1881            _kind: crate::approvals::Decision,
1882            _note: &str,
1883        ) -> anyhow::Result<()> {
1884            Ok(())
1885        }
1886    }
1887
1888    /// Noop sender for tests that don't exercise compose-send.
1889    struct NoopSender;
1890    impl crate::compose::MessageSender for NoopSender {
1891        fn send_dm(
1892            &self,
1893            _root: &std::path::Path,
1894            _agent: &str,
1895            _body: &str,
1896        ) -> anyhow::Result<()> {
1897            Ok(())
1898        }
1899        fn broadcast(
1900            &self,
1901            _root: &std::path::Path,
1902            _channel: &str,
1903            _body: &str,
1904        ) -> anyhow::Result<()> {
1905            Ok(())
1906        }
1907    }
1908
1909    /// Mailbox source that returns nothing — refresh_mailbox after
1910    /// a successful send becomes a no-op.
1911    struct EmptyMailbox;
1912    impl crate::mailbox::MailboxSource for EmptyMailbox {
1913        fn inbox(&self, _id: &str, _after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
1914            Ok(Vec::new())
1915        }
1916        fn sent(&self, _id: &str, _after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
1917            Ok(Vec::new())
1918        }
1919        fn channel_feed(
1920            &self,
1921            _id: &str,
1922            _after: i64,
1923        ) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
1924            Ok(Vec::new())
1925        }
1926        fn wire(&self, _id: &str, _after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
1927            Ok(Vec::new())
1928        }
1929    }
1930
1931    /// Boilerplate-free dispatcher for tests not exercising the
1932    /// decision / send paths.
1933    fn dispatch(app: &mut App, ev: Event) {
1934        super::handle_event(
1935            app,
1936            ev,
1937            &NoopDecider,
1938            &NoopSender,
1939            &EmptyMailbox,
1940            &crate::keysender::test_support::MockKeySender::default(),
1941        );
1942    }
1943
1944    fn agent(id: &str, state: AgentState) -> AgentInfo {
1945        AgentInfo {
1946            id: id.into(),
1947            agent: id
1948                .split_once(':')
1949                .map(|(_, a)| a.to_string())
1950                .unwrap_or_default(),
1951            project: id
1952                .split_once(':')
1953                .map(|(p, _)| p.to_string())
1954                .unwrap_or_default(),
1955            tmux_session: format!("t-{}", id.replace(':', "-")),
1956            state,
1957            unread_mail: 0,
1958            pending_approvals: 0,
1959            is_manager: false,
1960            display_name: None,
1961            rate_limit_resets_at: None,
1962        }
1963    }
1964
1965    pub fn fixture_team(agents: Vec<AgentInfo>) -> TeamSnapshot {
1966        TeamSnapshot {
1967            root: std::path::PathBuf::from("/fixture"),
1968            team_name: "fixture".into(),
1969            agents,
1970            channels: Vec::new(),
1971        }
1972    }
1973
1974    #[test]
1975    fn splash_dismissed_by_any_key() {
1976        let mut app = App::new();
1977        assert_eq!(app.stage, Stage::Splash);
1978        dispatch(&mut app, key(KeyCode::Char(' ')));
1979        assert_eq!(app.stage, Stage::Triptych);
1980    }
1981
1982    #[test]
1983    fn tab_cycles_panes_uniformly_and_wraps_through_mailbox() {
1984        // T-074 bug 6: Tab cycles pane focus only — Roster → Detail
1985        // → Mailbox → Roster — at every step. The previous "Tab
1986        // cycles tabs once focused on mailbox" shape stranded
1987        // operators inside the mailbox; this test pins the corrected
1988        // uniform cycle so a future refactor can't reintroduce the
1989        // dead-end.
1990        let mut app = App::new();
1991        app.dismiss_splash();
1992        assert_eq!(app.focused_pane, Pane::Roster);
1993        dispatch(&mut app, key(KeyCode::Tab));
1994        assert_eq!(app.focused_pane, Pane::Detail);
1995        dispatch(&mut app, key(KeyCode::Tab));
1996        assert_eq!(app.focused_pane, Pane::Mailbox);
1997        assert_eq!(
1998            app.mailbox_tab,
1999            MailboxTab::Inbox,
2000            "Tab into mailbox does NOT touch the active mailbox tab"
2001        );
2002        dispatch(&mut app, key(KeyCode::Tab));
2003        assert_eq!(
2004            app.focused_pane,
2005            Pane::Roster,
2006            "Tab from mailbox wraps to roster, not into mailbox subtabs"
2007        );
2008        assert_eq!(
2009            app.mailbox_tab,
2010            MailboxTab::Inbox,
2011            "mailbox tab still untouched"
2012        );
2013    }
2014
2015    #[test]
2016    fn arrow_keys_walk_mailbox_tabs_when_mailbox_focused() {
2017        // T-124: Right/Left arrows are the mailbox-tab walker
2018        // (more discoverable than the prior `[`/`]` chord). Gated
2019        // on mailbox being the focused pane so the arrows stay
2020        // unsurprising in every other context.
2021        let mut app = App::new();
2022        app.dismiss_splash();
2023        // Walk into mailbox via Tab.
2024        dispatch(&mut app, key(KeyCode::Tab));
2025        dispatch(&mut app, key(KeyCode::Tab));
2026        assert_eq!(app.focused_pane, Pane::Mailbox);
2027        assert_eq!(app.mailbox_tab, MailboxTab::Inbox);
2028
2029        dispatch(&mut app, key(KeyCode::Right));
2030        assert_eq!(app.mailbox_tab, MailboxTab::Sent);
2031        dispatch(&mut app, key(KeyCode::Right));
2032        assert_eq!(app.mailbox_tab, MailboxTab::Channel);
2033        dispatch(&mut app, key(KeyCode::Right));
2034        assert_eq!(app.mailbox_tab, MailboxTab::Wire);
2035        dispatch(&mut app, key(KeyCode::Right));
2036        assert_eq!(app.mailbox_tab, MailboxTab::Inbox, "→ wraps");
2037
2038        dispatch(&mut app, key(KeyCode::Left));
2039        assert_eq!(app.mailbox_tab, MailboxTab::Wire, "← walks back");
2040    }
2041
2042    #[test]
2043    fn arrow_keys_no_op_when_mailbox_not_focused() {
2044        // The arrows must not surprise an operator scrolling the
2045        // roster — gate is load-bearing.
2046        let mut app = App::new();
2047        app.dismiss_splash();
2048        assert_eq!(app.focused_pane, Pane::Roster);
2049        let initial = app.mailbox_tab;
2050        dispatch(&mut app, key(KeyCode::Right));
2051        dispatch(&mut app, key(KeyCode::Left));
2052        assert_eq!(
2053            app.mailbox_tab, initial,
2054            "←/→ from non-mailbox panes must not flip the active tab"
2055        );
2056    }
2057
2058    #[test]
2059    fn brackets_no_longer_cycle_mailbox_tabs() {
2060        // T-124 regression: `[` / `]` were the previous binding;
2061        // hard-swap means they are now fully inert in the mailbox
2062        // pane. Pin the no-op so a future binding can't quietly
2063        // re-introduce the old chord.
2064        let mut app = App::new();
2065        app.dismiss_splash();
2066        dispatch(&mut app, key(KeyCode::Tab));
2067        dispatch(&mut app, key(KeyCode::Tab));
2068        assert_eq!(app.focused_pane, Pane::Mailbox);
2069        let initial = app.mailbox_tab;
2070
2071        dispatch(&mut app, key(KeyCode::Char(']')));
2072        dispatch(&mut app, key(KeyCode::Char('[')));
2073        assert_eq!(
2074            app.mailbox_tab, initial,
2075            "`[` / `]` must no longer cycle mailbox tabs (T-124 hard-swap)",
2076        );
2077    }
2078
2079    #[test]
2080    fn q_opens_confirm_then_n_cancels() {
2081        let mut app = App::new();
2082        app.dismiss_splash();
2083        dispatch(&mut app, key(KeyCode::Char('q')));
2084        assert_eq!(app.stage, Stage::QuitConfirm);
2085        dispatch(&mut app, key(KeyCode::Char('n')));
2086        assert_eq!(app.stage, Stage::Triptych);
2087        assert!(app.running, "n must not exit");
2088    }
2089
2090    #[test]
2091    fn q_then_y_exits() {
2092        let mut app = App::new();
2093        app.dismiss_splash();
2094        dispatch(&mut app, key(KeyCode::Char('q')));
2095        dispatch(&mut app, key(KeyCode::Char('y')));
2096        assert!(!app.running);
2097    }
2098
2099    #[test]
2100    fn esc_cancels_quit_confirm() {
2101        let mut app = App::new();
2102        app.dismiss_splash();
2103        app.enter_quit_confirm();
2104        dispatch(&mut app, key(KeyCode::Esc));
2105        assert_eq!(app.stage, Stage::Triptych);
2106    }
2107
2108    #[test]
2109    fn render_does_not_panic_at_minimal_size() {
2110        let app = App::new();
2111        let _ = render_to_buffer(&app, 20, 8);
2112    }
2113
2114    #[test]
2115    fn render_does_not_panic_at_huge_size() {
2116        let app = App::new();
2117        let _ = render_to_buffer(&app, 240, 80);
2118    }
2119
2120    #[test]
2121    fn select_next_wraps_through_team() {
2122        let mut app = App::new();
2123        app.replace_team(fixture_team(vec![
2124            agent("p:a", AgentState::Running),
2125            agent("p:b", AgentState::Running),
2126            agent("p:c", AgentState::Running),
2127        ]));
2128        assert_eq!(app.selected_agent, Some(0));
2129        app.select_next();
2130        assert_eq!(app.selected_agent, Some(1));
2131        app.select_next();
2132        assert_eq!(app.selected_agent, Some(2));
2133        app.select_next();
2134        assert_eq!(app.selected_agent, Some(0)); // wraps
2135    }
2136
2137    #[test]
2138    fn select_prev_wraps_at_top() {
2139        let mut app = App::new();
2140        app.replace_team(fixture_team(vec![
2141            agent("p:a", AgentState::Running),
2142            agent("p:b", AgentState::Running),
2143        ]));
2144        app.selected_agent = Some(0);
2145        app.select_prev();
2146        assert_eq!(app.selected_agent, Some(1));
2147    }
2148
2149    #[test]
2150    fn select_no_op_on_empty_team() {
2151        let mut app = App::new();
2152        app.select_next();
2153        assert_eq!(app.selected_agent, None);
2154        app.select_prev();
2155        assert_eq!(app.selected_agent, None);
2156    }
2157
2158    #[test]
2159    fn replace_team_preserves_selection_when_agent_still_present() {
2160        let mut app = App::new();
2161        app.replace_team(fixture_team(vec![
2162            agent("p:a", AgentState::Running),
2163            agent("p:b", AgentState::Running),
2164        ]));
2165        app.selected_agent = Some(1);
2166        app.replace_team(fixture_team(vec![
2167            agent("p:a", AgentState::Running),
2168            agent("p:b", AgentState::Stopped), // same id, new state
2169        ]));
2170        assert_eq!(app.selected_agent, Some(1), "selection follows the id");
2171    }
2172
2173    #[test]
2174    fn replace_team_resets_selection_when_agent_disappears() {
2175        let mut app = App::new();
2176        app.replace_team(fixture_team(vec![
2177            agent("p:a", AgentState::Running),
2178            agent("p:gone", AgentState::Running),
2179        ]));
2180        app.selected_agent = Some(1);
2181        app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
2182        assert_eq!(app.selected_agent, Some(0), "falls back to first agent");
2183    }
2184
2185    #[test]
2186    fn switching_agent_resets_mailbox_buffers() {
2187        // The mailbox cursors are per-agent context; switching to a
2188        // new agent must clear them so we don't skip historical
2189        // rows that landed before the new agent's first refresh.
2190        let mut app = App::new();
2191        app.replace_team(fixture_team(vec![
2192            agent("p:a", AgentState::Running),
2193            agent("p:b", AgentState::Running),
2194        ]));
2195        app.mailbox.extend(
2196            crate::mailbox::MailboxTab::Inbox,
2197            vec![crate::mailbox::MessageRow {
2198                id: 7,
2199                sender: "p:b".into(),
2200                recipient: "p:a".into(),
2201                text: "hi".into(),
2202                sent_at: 0.0,
2203            }],
2204        );
2205        assert_eq!(app.mailbox.inbox.len(), 1);
2206        assert_eq!(app.mailbox.inbox_after, 7);
2207        // Move selection to p:b — different agent id, mailbox resets.
2208        app.select_next();
2209        assert_eq!(app.selected_agent_id().as_deref(), Some("p:b"));
2210        assert!(app.mailbox.inbox.is_empty());
2211        assert_eq!(app.mailbox.inbox_after, 0);
2212    }
2213
2214    /// Tiny single-call mailbox stub for the refresh-fanout test —
2215    /// keeps the assertion local without depending on
2216    /// `mailbox::tests::MockMailboxSource` (which lives behind a
2217    /// private `tests` module).
2218    struct TripleFilterMock {
2219        inbox: Vec<crate::mailbox::MessageRow>,
2220        sent: Vec<crate::mailbox::MessageRow>,
2221        channel: Vec<crate::mailbox::MessageRow>,
2222        wire: Vec<crate::mailbox::MessageRow>,
2223        calls: std::sync::Mutex<Vec<(&'static str, String, i64)>>,
2224    }
2225    impl crate::mailbox::MailboxSource for TripleFilterMock {
2226        fn inbox(&self, id: &str, after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
2227            self.calls.lock().unwrap().push(("inbox", id.into(), after));
2228            Ok(self.inbox.clone())
2229        }
2230        fn sent(&self, id: &str, after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
2231            self.calls.lock().unwrap().push(("sent", id.into(), after));
2232            Ok(self.sent.clone())
2233        }
2234        fn channel_feed(
2235            &self,
2236            id: &str,
2237            after: i64,
2238        ) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
2239            self.calls
2240                .lock()
2241                .unwrap()
2242                .push(("channel", id.into(), after));
2243            Ok(self.channel.clone())
2244        }
2245        fn wire(&self, id: &str, after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
2246            self.calls.lock().unwrap().push(("wire", id.into(), after));
2247            Ok(self.wire.clone())
2248        }
2249    }
2250
2251    #[test]
2252    fn refresh_mailbox_fans_out_to_four_filters() {
2253        use crate::mailbox::MessageRow;
2254        let mut app = App::new();
2255        app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
2256        let mock = TripleFilterMock {
2257            inbox: vec![MessageRow {
2258                id: 1,
2259                sender: "p:b".into(),
2260                recipient: "p:a".into(),
2261                text: "dm".into(),
2262                sent_at: 0.0,
2263            }],
2264            sent: vec![MessageRow {
2265                id: 4,
2266                sender: "p:a".into(),
2267                recipient: "p:b".into(),
2268                text: "outgoing dm".into(),
2269                sent_at: 0.0,
2270            }],
2271            channel: vec![MessageRow {
2272                id: 2,
2273                sender: "p:b".into(),
2274                recipient: "channel:p:editorial".into(),
2275                text: "ch".into(),
2276                sent_at: 0.0,
2277            }],
2278            wire: vec![MessageRow {
2279                id: 3,
2280                sender: "p:b".into(),
2281                recipient: "channel:p:all".into(),
2282                text: "wire".into(),
2283                sent_at: 0.0,
2284            }],
2285            calls: std::sync::Mutex::new(Vec::new()),
2286        };
2287        super::refresh_mailbox(&mut app, &mock);
2288        assert_eq!(app.mailbox.inbox.len(), 1);
2289        assert_eq!(app.mailbox.sent.len(), 1);
2290        assert_eq!(app.mailbox.channel.len(), 1);
2291        assert_eq!(app.mailbox.wire.len(), 1);
2292        let calls = mock.calls.lock().unwrap();
2293        // The selected agent is p:a (auto-set by replace_team to
2294        // index 0); the wire filter takes the project id `p`.
2295        assert!(calls.contains(&("inbox", "p:a".into(), 0)));
2296        assert!(calls.contains(&("sent", "p:a".into(), 0)));
2297        assert!(calls.contains(&("channel", "p:a".into(), 0)));
2298        assert!(calls.contains(&("wire", "p".into(), 0)));
2299    }
2300
2301    fn ap(id: i64) -> crate::approvals::Approval {
2302        crate::approvals::Approval {
2303            id,
2304            project_id: "p".into(),
2305            agent_id: "p:m".into(),
2306            action: "publish".into(),
2307            summary: format!("approval #{id}"),
2308            payload_json: String::new(),
2309        }
2310    }
2311
2312    #[test]
2313    fn has_pending_approvals_tracks_replace_calls() {
2314        let mut app = App::new();
2315        assert!(!app.has_pending_approvals());
2316        app.replace_approvals(vec![ap(1), ap(2)]);
2317        assert!(app.has_pending_approvals());
2318        app.replace_approvals(vec![]);
2319        assert!(!app.has_pending_approvals());
2320    }
2321
2322    #[test]
2323    fn enter_approvals_modal_no_op_when_queue_empty() {
2324        let mut app = App::new();
2325        app.dismiss_splash();
2326        app.enter_approvals_modal();
2327        assert_eq!(app.stage, Stage::Triptych, "no pending → no modal");
2328    }
2329
2330    #[test]
2331    fn a_chord_opens_modal_when_pending() {
2332        let mut app = App::new();
2333        app.dismiss_splash();
2334        app.replace_approvals(vec![ap(1), ap(2)]);
2335        dispatch(&mut app, key(KeyCode::Char('a')));
2336        assert_eq!(app.stage, Stage::ApprovalsModal);
2337        assert_eq!(app.selected_approval, 0);
2338    }
2339
2340    #[test]
2341    fn modal_cycle_jk_walks_approvals() {
2342        let mut app = App::new();
2343        app.dismiss_splash();
2344        app.replace_approvals(vec![ap(1), ap(2), ap(3)]);
2345        app.enter_approvals_modal();
2346        dispatch(&mut app, key(KeyCode::Char('j')));
2347        assert_eq!(app.selected_approval, 1);
2348        dispatch(&mut app, key(KeyCode::Char('j')));
2349        assert_eq!(app.selected_approval, 2);
2350        dispatch(&mut app, key(KeyCode::Char('j')));
2351        assert_eq!(app.selected_approval, 0, "wraps");
2352        dispatch(&mut app, key(KeyCode::Char('k')));
2353        assert_eq!(app.selected_approval, 2, "k wraps too");
2354    }
2355
2356    #[test]
2357    fn capital_y_routes_approve_through_decider() {
2358        use crate::approvals::test_support::MockApprovalDecider;
2359        let dec = MockApprovalDecider::default();
2360        let mut app = App::new();
2361        app.dismiss_splash();
2362        app.replace_approvals(vec![ap(7), ap(8)]);
2363        app.enter_approvals_modal();
2364        super::handle_event(
2365            &mut app,
2366            key(KeyCode::Char('Y')),
2367            &dec,
2368            &NoopSender,
2369            &EmptyMailbox,
2370            &crate::keysender::test_support::MockKeySender::default(),
2371        );
2372        let calls = dec.calls.lock().unwrap().clone();
2373        assert_eq!(calls.len(), 1);
2374        assert_eq!(calls[0].0, 7);
2375        assert_eq!(calls[0].1, crate::approvals::Decision::Approve);
2376        // Optimistic local removal — approval id 7 dropped.
2377        assert_eq!(app.pending_approvals.len(), 1);
2378        assert_eq!(app.pending_approvals[0].id, 8);
2379    }
2380
2381    #[test]
2382    fn capital_n_routes_deny_through_decider() {
2383        use crate::approvals::test_support::MockApprovalDecider;
2384        let dec = MockApprovalDecider::default();
2385        let mut app = App::new();
2386        app.dismiss_splash();
2387        app.replace_approvals(vec![ap(7)]);
2388        app.enter_approvals_modal();
2389        super::handle_event(
2390            &mut app,
2391            key(KeyCode::Char('N')),
2392            &dec,
2393            &NoopSender,
2394            &EmptyMailbox,
2395            &crate::keysender::test_support::MockKeySender::default(),
2396        );
2397        let calls = dec.calls.lock().unwrap().clone();
2398        assert_eq!(calls.len(), 1);
2399        assert_eq!(calls[0].1, crate::approvals::Decision::Deny);
2400        // Queue empty after the only approval resolves → modal closes.
2401        assert_eq!(app.stage, Stage::Triptych);
2402    }
2403
2404    #[test]
2405    fn esc_closes_approvals_modal() {
2406        let mut app = App::new();
2407        app.dismiss_splash();
2408        app.replace_approvals(vec![ap(1)]);
2409        app.enter_approvals_modal();
2410        dispatch(&mut app, key(KeyCode::Esc));
2411        assert_eq!(app.stage, Stage::Triptych);
2412    }
2413
2414    #[test]
2415    fn lowercase_y_routes_approve_through_decider() {
2416        // T-074 bug 4: discoverable approve. Most operators try
2417        // lowercase first; the modal must accept it on the
2418        // approve (low-risk) side. Deny stays Shift-gated.
2419        use crate::approvals::test_support::MockApprovalDecider;
2420        let dec = MockApprovalDecider::default();
2421        let mut app = App::new();
2422        app.dismiss_splash();
2423        app.replace_approvals(vec![ap(7)]);
2424        app.enter_approvals_modal();
2425        super::handle_event(
2426            &mut app,
2427            key(KeyCode::Char('y')),
2428            &dec,
2429            &NoopSender,
2430            &EmptyMailbox,
2431            &crate::keysender::test_support::MockKeySender::default(),
2432        );
2433        let calls = dec.calls.lock().unwrap().clone();
2434        assert_eq!(calls.len(), 1);
2435        assert_eq!(calls[0].1, crate::approvals::Decision::Approve);
2436    }
2437
2438    #[test]
2439    fn lowercase_n_does_not_deny() {
2440        // Asymmetry guard: deny is destructive — `n` lowercase must
2441        // NOT fire the decider. A future "symmetric loose" refactor
2442        // would silently regress the destructive-deny Shift-gate;
2443        // this test pins it.
2444        use crate::approvals::test_support::MockApprovalDecider;
2445        let dec = MockApprovalDecider::default();
2446        let mut app = App::new();
2447        app.dismiss_splash();
2448        app.replace_approvals(vec![ap(7)]);
2449        app.enter_approvals_modal();
2450        super::handle_event(
2451            &mut app,
2452            key(KeyCode::Char('n')),
2453            &dec,
2454            &NoopSender,
2455            &EmptyMailbox,
2456            &crate::keysender::test_support::MockKeySender::default(),
2457        );
2458        assert!(
2459            dec.calls.lock().unwrap().is_empty(),
2460            "lowercase n must not route through the decider"
2461        );
2462        assert_eq!(
2463            app.stage,
2464            Stage::ApprovalsModal,
2465            "stale lowercase n leaves the modal open"
2466        );
2467    }
2468
2469    #[test]
2470    fn shift_tab_cycles_panes_backward() {
2471        use crossterm::event::KeyModifiers;
2472        let mut app = App::new();
2473        app.dismiss_splash();
2474        assert_eq!(app.focused_pane, Pane::Roster);
2475        // Shift+Tab from Roster → Mailbox (the "back out of mailbox"
2476        // direction's mirror).
2477        dispatch(&mut app, key(KeyCode::BackTab));
2478        assert_eq!(app.focused_pane, Pane::Mailbox);
2479        // Some terminals send Tab + SHIFT instead of BackTab.
2480        dispatch(&mut app, key_with(KeyCode::Tab, KeyModifiers::SHIFT));
2481        assert_eq!(app.focused_pane, Pane::Detail);
2482    }
2483
2484    #[test]
2485    fn at_chord_opens_compose_dm_to_focused_agent() {
2486        let mut app = App::new();
2487        app.replace_team(fixture_team(vec![
2488            agent("writing:manager", AgentState::Running),
2489            agent("writing:dev1", AgentState::Running),
2490        ]));
2491        app.dismiss_splash();
2492        app.select_next();
2493        dispatch(&mut app, key(KeyCode::Char('@')));
2494        assert_eq!(app.stage, Stage::ComposeModal);
2495        match app.compose_target.as_ref() {
2496            Some(crate::compose::ComposeTarget::Dm { agent_id, .. }) => {
2497                assert_eq!(agent_id, "writing:dev1");
2498            }
2499            other => panic!("expected DM target, got {other:?}"),
2500        }
2501    }
2502
2503    #[test]
2504    fn bang_chord_opens_compose_broadcast_to_all_channel() {
2505        let mut app = App::new();
2506        app.replace_team(fixture_team(vec![agent(
2507            "writing:manager",
2508            AgentState::Running,
2509        )]));
2510        app.dismiss_splash();
2511        dispatch(&mut app, key(KeyCode::Char('!')));
2512        assert_eq!(app.stage, Stage::ComposeModal);
2513        match app.compose_target.as_ref() {
2514            Some(crate::compose::ComposeTarget::Broadcast { channel_id, .. }) => {
2515                assert_eq!(channel_id, "writing:all");
2516            }
2517            other => panic!("expected Broadcast target, got {other:?}"),
2518        }
2519    }
2520
2521    #[test]
2522    fn send_routes_dm_through_mock_sender() {
2523        use crate::compose::test_support::MockMessageSender;
2524        let sender = MockMessageSender::default();
2525        let mailbox = EmptyMailbox;
2526        let mut app = App::new();
2527        app.replace_team(fixture_team(vec![agent(
2528            "writing:dev1",
2529            AgentState::Running,
2530        )]));
2531        app.dismiss_splash();
2532        app.enter_compose_dm_for_focused();
2533        for c in "ship it".chars() {
2534            super::handle_event(
2535                &mut app,
2536                key(KeyCode::Char(c)),
2537                &NoopDecider,
2538                &sender,
2539                &mailbox,
2540                &crate::keysender::test_support::MockKeySender::default(),
2541            );
2542        }
2543        super::handle_event(
2544            &mut app,
2545            key_with(KeyCode::Enter, crossterm::event::KeyModifiers::CONTROL),
2546            &NoopDecider,
2547            &sender,
2548            &mailbox,
2549            &crate::keysender::test_support::MockKeySender::default(),
2550        );
2551        let calls = sender.dm_calls.lock().unwrap().clone();
2552        assert_eq!(calls.len(), 1);
2553        assert_eq!(calls[0].0, "writing:dev1");
2554        assert_eq!(calls[0].1, "ship it");
2555        assert_eq!(app.stage, Stage::Triptych, "modal closes on send");
2556    }
2557
2558    #[test]
2559    fn esc_esc_cancels_compose_without_send() {
2560        use crate::compose::test_support::MockMessageSender;
2561        let sender = MockMessageSender::default();
2562        let mailbox = EmptyMailbox;
2563        let mut app = App::new();
2564        app.replace_team(fixture_team(vec![agent(
2565            "writing:dev1",
2566            AgentState::Running,
2567        )]));
2568        app.dismiss_splash();
2569        app.enter_compose_dm_for_focused();
2570        for c in "draft".chars() {
2571            super::handle_event(
2572                &mut app,
2573                key(KeyCode::Char(c)),
2574                &NoopDecider,
2575                &sender,
2576                &mailbox,
2577                &crate::keysender::test_support::MockKeySender::default(),
2578            );
2579        }
2580        super::handle_event(
2581            &mut app,
2582            key(KeyCode::Esc),
2583            &NoopDecider,
2584            &sender,
2585            &mailbox,
2586            &crate::keysender::test_support::MockKeySender::default(),
2587        );
2588        super::handle_event(
2589            &mut app,
2590            key(KeyCode::Esc),
2591            &NoopDecider,
2592            &sender,
2593            &mailbox,
2594            &crate::keysender::test_support::MockKeySender::default(),
2595        );
2596        assert_eq!(app.stage, Stage::Triptych);
2597        assert!(sender.dm_calls.lock().unwrap().is_empty());
2598    }
2599
2600    #[test]
2601    fn send_failure_surfaces_error_inline_keeps_modal_open() {
2602        use crate::compose::test_support::MockMessageSender;
2603        let sender = MockMessageSender::default();
2604        *sender.fail_next.lock().unwrap() = Some("rate limit".into());
2605        let mailbox = EmptyMailbox;
2606        let mut app = App::new();
2607        app.replace_team(fixture_team(vec![agent(
2608            "writing:dev1",
2609            AgentState::Running,
2610        )]));
2611        app.dismiss_splash();
2612        app.enter_compose_dm_for_focused();
2613        super::handle_event(
2614            &mut app,
2615            key(KeyCode::Char('x')),
2616            &NoopDecider,
2617            &sender,
2618            &mailbox,
2619            &crate::keysender::test_support::MockKeySender::default(),
2620        );
2621        super::handle_event(
2622            &mut app,
2623            key_with(KeyCode::Enter, crossterm::event::KeyModifiers::CONTROL),
2624            &NoopDecider,
2625            &sender,
2626            &mailbox,
2627            &crate::keysender::test_support::MockKeySender::default(),
2628        );
2629        assert_eq!(app.stage, Stage::ComposeModal, "modal stays open on err");
2630        assert!(app
2631            .compose_error
2632            .as_deref()
2633            .unwrap_or_default()
2634            .contains("rate limit"));
2635    }
2636
2637    fn channel(id: &str, project: &str) -> crate::data::ChannelInfo {
2638        crate::data::ChannelInfo {
2639            id: id.into(),
2640            name: id
2641                .rsplit_once(':')
2642                .map(|(_, n)| n.to_string())
2643                .unwrap_or_default(),
2644            project_id: project.into(),
2645        }
2646    }
2647
2648    fn fixture_team_with_channels(
2649        agents: Vec<AgentInfo>,
2650        channels: Vec<crate::data::ChannelInfo>,
2651    ) -> TeamSnapshot {
2652        TeamSnapshot {
2653            root: std::path::PathBuf::from("/fixture"),
2654            team_name: "fixture".into(),
2655            agents,
2656            channels,
2657        }
2658    }
2659
2660    #[test]
2661    fn ctrl_w_toggles_wall_layout() {
2662        use crossterm::event::KeyModifiers;
2663        let mut app = App::new();
2664        app.dismiss_splash();
2665        assert_eq!(app.layout, MainLayout::Triptych);
2666        dispatch(
2667            &mut app,
2668            key_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
2669        );
2670        assert_eq!(app.layout, MainLayout::Wall);
2671        dispatch(
2672            &mut app,
2673            key_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
2674        );
2675        assert_eq!(app.layout, MainLayout::Triptych);
2676    }
2677
2678    #[test]
2679    fn ctrl_m_toggles_mailbox_first_layout() {
2680        use crossterm::event::KeyModifiers;
2681        let mut app = App::new();
2682        app.dismiss_splash();
2683        dispatch(
2684            &mut app,
2685            key_with(KeyCode::Char('m'), KeyModifiers::CONTROL),
2686        );
2687        assert_eq!(app.layout, MainLayout::MailboxFirst);
2688        dispatch(
2689            &mut app,
2690            key_with(KeyCode::Char('m'), KeyModifiers::CONTROL),
2691        );
2692        assert_eq!(app.layout, MainLayout::Triptych);
2693    }
2694
2695    #[test]
2696    fn wall_scroll_pages_through_overflow_agents() {
2697        let mut app = App::new();
2698        let mut agents: Vec<_> = (1..=10)
2699            .map(|i| agent(&format!("p:agent-{i:02}"), AgentState::Running))
2700            .collect();
2701        // managers-first sort would otherwise reorder; mark all as workers.
2702        for a in agents.iter_mut() {
2703            a.is_manager = false;
2704        }
2705        app.replace_team(fixture_team(agents));
2706        app.dismiss_splash();
2707        app.toggle_wall_layout();
2708        assert_eq!(app.wall_scroll, 0);
2709        app.wall_scroll_down();
2710        assert_eq!(app.wall_scroll, 4);
2711        app.wall_scroll_down();
2712        assert_eq!(app.wall_scroll, 8);
2713        // Past 10-1 = 9; cap blocks 12.
2714        app.wall_scroll_down();
2715        assert_eq!(app.wall_scroll, 8, "scroll capped at last full window");
2716        app.wall_scroll_up();
2717        assert_eq!(app.wall_scroll, 4);
2718    }
2719
2720    #[test]
2721    fn ctrl_pipe_adds_detail_split_capped_at_four() {
2722        use crossterm::event::KeyModifiers;
2723        let mut app = App::new();
2724        app.replace_team(fixture_team(vec![
2725            agent("p:a", AgentState::Running),
2726            agent("p:b", AgentState::Running),
2727        ]));
2728        app.dismiss_splash();
2729        for _ in 0..6 {
2730            dispatch(
2731                &mut app,
2732                key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
2733            );
2734        }
2735        assert_eq!(app.detail_splits.len(), 4, "split count capped at 4");
2736    }
2737
2738    #[test]
2739    fn ctrl_q_closes_focused_split() {
2740        use crossterm::event::KeyModifiers;
2741        let mut app = App::new();
2742        app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
2743        app.dismiss_splash();
2744        dispatch(
2745            &mut app,
2746            key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
2747        );
2748        dispatch(
2749            &mut app,
2750            key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
2751        );
2752        assert_eq!(app.detail_splits.len(), 2);
2753        dispatch(
2754            &mut app,
2755            key_with(KeyCode::Char('Q'), KeyModifiers::CONTROL),
2756        );
2757        assert_eq!(app.detail_splits.len(), 1);
2758    }
2759
2760    #[test]
2761    fn ctrl_hjkl_cycles_splits() {
2762        use crossterm::event::KeyModifiers;
2763        let mut app = App::new();
2764        app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
2765        app.dismiss_splash();
2766        for _ in 0..3 {
2767            dispatch(
2768                &mut app,
2769                key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
2770            );
2771        }
2772        assert_eq!(app.selected_split, 2);
2773        dispatch(
2774            &mut app,
2775            key_with(KeyCode::Char('l'), KeyModifiers::CONTROL),
2776        );
2777        assert_eq!(app.selected_split, 0, "wraps");
2778        dispatch(
2779            &mut app,
2780            key_with(KeyCode::Char('h'), KeyModifiers::CONTROL),
2781        );
2782        assert_eq!(app.selected_split, 2);
2783    }
2784
2785    #[test]
2786    fn wall_scroll_at_exactly_cap_agents_does_not_scroll() {
2787        // PR-UI-6 fixup (qa Gap 1a): with exactly WALL_TILE_CAP=4
2788        // agents the entire team fits in one window — scrolling
2789        // is a no-op in both directions. Pinning this catches a
2790        // future `<` → `<=` slip in `wall_scroll_down`.
2791        let mut app = App::new();
2792        let agents: Vec<_> = (1..=4)
2793            .map(|i| agent(&format!("p:agent-{i}"), AgentState::Running))
2794            .collect();
2795        app.replace_team(fixture_team(agents));
2796        app.dismiss_splash();
2797        app.toggle_wall_layout();
2798        assert_eq!(app.wall_scroll, 0);
2799        app.wall_scroll_down();
2800        assert_eq!(app.wall_scroll, 0, "exactly-cap should not advance");
2801        app.wall_scroll_up();
2802        assert_eq!(app.wall_scroll, 0);
2803    }
2804
2805    #[test]
2806    fn wall_scroll_at_cap_plus_one_advances_then_stops() {
2807        // PR-UI-6 fixup (qa Gap 1b): exactly 5 agents → 4 fit in
2808        // window-0, the 5th lives at window-4. One scroll
2809        // advances; the next caps. Pins the off-by-one between 4
2810        // and 5 agents.
2811        let mut app = App::new();
2812        let agents: Vec<_> = (1..=5)
2813            .map(|i| agent(&format!("p:agent-{i}"), AgentState::Running))
2814            .collect();
2815        app.replace_team(fixture_team(agents));
2816        app.dismiss_splash();
2817        app.toggle_wall_layout();
2818        app.wall_scroll_down();
2819        assert_eq!(app.wall_scroll, 4, "first scroll exposes agent 5");
2820        app.wall_scroll_down();
2821        assert_eq!(app.wall_scroll, 4, "second scroll caps; nothing past");
2822    }
2823
2824    #[test]
2825    fn esc_in_picker_dismisses_overlay_only_keeps_modal_open() {
2826        // PR-UI-6 fixup (Q6 dev2 review + qa Gap 3): Esc inside
2827        // the broadcast picker should close the picker overlay
2828        // and return to the editor in its current state — NOT
2829        // close the whole compose modal. Editor's Esc-Esc
2830        // already handles cancel-the-modal.
2831        let mut app = App::new();
2832        app.replace_team(fixture_team_with_channels(
2833            vec![agent("writing:manager", AgentState::Running)],
2834            vec![
2835                channel("writing:all", "writing"),
2836                channel("writing:editorial", "writing"),
2837            ],
2838        ));
2839        app.dismiss_splash();
2840        dispatch(&mut app, key(KeyCode::Char('!')));
2841        assert!(app.compose_picker_open);
2842        assert_eq!(app.stage, Stage::ComposeModal);
2843        dispatch(&mut app, key(KeyCode::Esc));
2844        assert!(!app.compose_picker_open, "picker dismissed");
2845        assert_eq!(app.stage, Stage::ComposeModal, "compose modal stays open");
2846    }
2847
2848    #[test]
2849    fn send_routes_broadcast_through_mock_sender_via_picker() {
2850        // PR-UI-6 fixup (qa Gap 4): the broadcast path needs the
2851        // same MockMessageSender pin the DM path got in PR-UI-5.
2852        // Pins both per-channel-correct-id (picker selection
2853        // flows through to the send call) AND routes-through-
2854        // `broadcast()`-not-`send()` (no DM call recorded).
2855        use crate::compose::test_support::MockMessageSender;
2856        let sender = MockMessageSender::default();
2857        let mailbox = EmptyMailbox;
2858        let mut app = App::new();
2859        app.replace_team(fixture_team_with_channels(
2860            vec![agent("writing:manager", AgentState::Running)],
2861            vec![
2862                channel("writing:all", "writing"),
2863                channel("writing:editorial", "writing"),
2864                channel("writing:critique", "writing"),
2865            ],
2866        ));
2867        app.dismiss_splash();
2868        // Open picker, walk to channel index 1 (`editorial`),
2869        // confirm, type a body, Ctrl+Enter to send.
2870        super::handle_event(
2871            &mut app,
2872            key(KeyCode::Char('!')),
2873            &NoopDecider,
2874            &sender,
2875            &mailbox,
2876            &crate::keysender::test_support::MockKeySender::default(),
2877        );
2878        super::handle_event(
2879            &mut app,
2880            key(KeyCode::Char('j')),
2881            &NoopDecider,
2882            &sender,
2883            &mailbox,
2884            &crate::keysender::test_support::MockKeySender::default(),
2885        );
2886        super::handle_event(
2887            &mut app,
2888            key(KeyCode::Enter),
2889            &NoopDecider,
2890            &sender,
2891            &mailbox,
2892            &crate::keysender::test_support::MockKeySender::default(),
2893        );
2894        for c in "ship docs".chars() {
2895            super::handle_event(
2896                &mut app,
2897                key(KeyCode::Char(c)),
2898                &NoopDecider,
2899                &sender,
2900                &mailbox,
2901                &crate::keysender::test_support::MockKeySender::default(),
2902            );
2903        }
2904        super::handle_event(
2905            &mut app,
2906            key_with(KeyCode::Enter, crossterm::event::KeyModifiers::CONTROL),
2907            &NoopDecider,
2908            &sender,
2909            &mailbox,
2910            &crate::keysender::test_support::MockKeySender::default(),
2911        );
2912        let dm_calls = sender.dm_calls.lock().unwrap().clone();
2913        let bcast_calls = sender.broadcast_calls.lock().unwrap().clone();
2914        assert!(dm_calls.is_empty(), "broadcast must not route via send_dm");
2915        assert_eq!(bcast_calls.len(), 1);
2916        assert_eq!(
2917            bcast_calls[0].0, "writing:editorial",
2918            "channel id from picker selection"
2919        );
2920        assert_eq!(bcast_calls[0].1, "ship docs");
2921        assert_eq!(app.stage, Stage::Triptych, "modal closes on send");
2922    }
2923
2924    #[test]
2925    fn bang_chord_opens_picker_when_channels_available() {
2926        let mut app = App::new();
2927        app.replace_team(fixture_team_with_channels(
2928            vec![agent("writing:manager", AgentState::Running)],
2929            vec![
2930                channel("writing:all", "writing"),
2931                channel("writing:editorial", "writing"),
2932                channel("writing:critique", "writing"),
2933            ],
2934        ));
2935        app.dismiss_splash();
2936        dispatch(&mut app, key(KeyCode::Char('!')));
2937        assert_eq!(app.stage, Stage::ComposeModal);
2938        assert!(app.compose_picker_open);
2939        // Walk the picker.
2940        dispatch(&mut app, key(KeyCode::Char('j')));
2941        assert_eq!(app.compose_picker_index, 1);
2942        // Confirm pulls into compose target.
2943        dispatch(&mut app, key(KeyCode::Enter));
2944        assert!(!app.compose_picker_open, "picker closes on confirm");
2945        match app.compose_target.as_ref() {
2946            Some(crate::compose::ComposeTarget::Broadcast { channel_id, .. }) => {
2947                assert_eq!(channel_id, "writing:editorial");
2948            }
2949            other => panic!("expected Broadcast target, got {other:?}"),
2950        }
2951    }
2952
2953    #[test]
2954    fn mailbox_first_layout_seeds_channel_selection_on_entry() {
2955        let mut app = App::new();
2956        app.replace_team(fixture_team_with_channels(
2957            vec![agent("writing:manager", AgentState::Running)],
2958            vec![
2959                channel("writing:all", "writing"),
2960                channel("writing:editorial", "writing"),
2961            ],
2962        ));
2963        app.dismiss_splash();
2964        assert!(app.selected_channel.is_none());
2965        app.toggle_mailbox_first_layout();
2966        assert_eq!(app.selected_channel, Some(0));
2967    }
2968
2969    #[test]
2970    fn help_overlay_opens_on_question_mark_closes_on_esc() {
2971        let mut app = App::new();
2972        app.dismiss_splash();
2973        dispatch(&mut app, key(KeyCode::Char('?')));
2974        assert_eq!(app.stage, Stage::HelpOverlay);
2975        dispatch(&mut app, key(KeyCode::Esc));
2976        assert_eq!(app.stage, Stage::Triptych);
2977    }
2978
2979    #[test]
2980    fn tutorial_opens_on_t_advances_and_closes() {
2981        let mut app = App::new();
2982        app.dismiss_splash();
2983        dispatch(&mut app, key(KeyCode::Char('t')));
2984        assert_eq!(app.stage, Stage::Tutorial);
2985        assert_eq!(app.tutorial_step, 0);
2986        // Any non-Esc/back key advances.
2987        dispatch(&mut app, key(KeyCode::Char(' ')));
2988        assert_eq!(app.tutorial_step, 1);
2989        // `k` walks back.
2990        dispatch(&mut app, key(KeyCode::Char('k')));
2991        assert_eq!(app.tutorial_step, 0);
2992        // Esc closes from any step.
2993        dispatch(&mut app, key(KeyCode::Esc));
2994        assert_eq!(app.stage, Stage::Triptych);
2995    }
2996
2997    #[test]
2998    fn tutorial_walk_back_at_step_zero_is_no_op() {
2999        // qa Gap C fold: pin the chosen behaviour for `k`/`Up`/`p`
3000        // at step 0 — saturating decrement keeps `tutorial_step`
3001        // at 0 rather than wrapping. Any future shift to
3002        // wrap-to-end would break this test, which is the point.
3003        let mut app = App::new();
3004        app.dismiss_splash();
3005        app.enter_tutorial();
3006        assert_eq!(app.tutorial_step, 0);
3007        dispatch(&mut app, key(KeyCode::Char('k')));
3008        assert_eq!(app.tutorial_step, 0, "step-0 walk-back is no-op");
3009        // The walk-back keypress must NOT close the tutorial
3010        // either — the Stage stays.
3011        assert_eq!(app.stage, Stage::Tutorial);
3012    }
3013
3014    #[test]
3015    fn ctrl_pipe_adds_vertical_split_ctrl_minus_adds_horizontal() {
3016        use crossterm::event::KeyModifiers;
3017        let mut app = App::new();
3018        app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3019        app.dismiss_splash();
3020        dispatch(
3021            &mut app,
3022            key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
3023        );
3024        dispatch(
3025            &mut app,
3026            key_with(KeyCode::Char('-'), KeyModifiers::CONTROL),
3027        );
3028        assert_eq!(app.detail_splits.len(), 2);
3029        assert_eq!(app.detail_splits[0].1, SplitOrientation::Vertical);
3030        assert_eq!(app.detail_splits[1].1, SplitOrientation::Horizontal);
3031    }
3032
3033    #[test]
3034    fn ctrl_w_q_chord_prefix_closes_focused_split() {
3035        use crossterm::event::KeyModifiers;
3036        let mut app = App::new();
3037        app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3038        app.dismiss_splash();
3039        // Two splits — `Ctrl+W` arms only when there's something
3040        // to close.
3041        dispatch(
3042            &mut app,
3043            key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
3044        );
3045        dispatch(
3046            &mut app,
3047            key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
3048        );
3049        dispatch(
3050            &mut app,
3051            key_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
3052        );
3053        assert_eq!(app.pending_chord, Some(KeyCode::Char('w')));
3054        // Plain `q` (no modifier) is now interpreted as the
3055        // chord-prefix follow-up — close split, NOT quit.
3056        dispatch(&mut app, key(KeyCode::Char('q')));
3057        assert_eq!(app.detail_splits.len(), 1);
3058        assert_eq!(app.stage, Stage::Triptych, "must not enter quit confirm");
3059        assert_eq!(app.pending_chord, None, "chord cleared");
3060    }
3061
3062    #[test]
3063    fn ctrl_w_o_chord_keeps_only_focused_split() {
3064        use crossterm::event::KeyModifiers;
3065        let mut app = App::new();
3066        app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3067        app.dismiss_splash();
3068        for _ in 0..3 {
3069            dispatch(
3070                &mut app,
3071                key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
3072            );
3073        }
3074        // Focus the middle split.
3075        app.selected_split = 1;
3076        let kept_id = app.detail_splits[1].0.clone();
3077        dispatch(
3078            &mut app,
3079            key_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
3080        );
3081        dispatch(&mut app, key(KeyCode::Char('o')));
3082        assert_eq!(app.detail_splits.len(), 1);
3083        assert_eq!(app.detail_splits[0].0, kept_id);
3084        assert_eq!(app.selected_split, 0);
3085    }
3086
3087    #[test]
3088    fn add_detail_split_saturates_at_four_with_explicit_4_and_5_calls() {
3089        // qa Gap 2 fold: pin the cap explicitly. Reaching exactly
3090        // 4 must stick; the 5th call must be a no-op (not panic,
3091        // not silently grow). If `add_detail_split` ever returns
3092        // a Result, this test catches the silent-success regression.
3093        let mut app = App::new();
3094        app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3095        for _ in 0..4 {
3096            app.add_detail_split();
3097        }
3098        assert_eq!(app.detail_splits.len(), 4);
3099        let snapshot_len = app.detail_splits.len();
3100        app.add_detail_split();
3101        assert_eq!(app.detail_splits.len(), snapshot_len, "5th call rejected");
3102    }
3103
3104    #[test]
3105    fn replace_approvals_clamps_selection_in_range() {
3106        let mut app = App::new();
3107        app.replace_approvals(vec![ap(1), ap(2), ap(3)]);
3108        app.selected_approval = 2;
3109        // Approval id 3 resolved out-of-band; new snapshot has 2 rows.
3110        app.replace_approvals(vec![ap(1), ap(2)]);
3111        assert_eq!(app.selected_approval, 1, "clamps to last index");
3112    }
3113
3114    #[test]
3115    fn arrow_keys_navigate_only_when_roster_focused() {
3116        let mut app = App::new();
3117        app.replace_team(fixture_team(vec![
3118            agent("p:a", AgentState::Running),
3119            agent("p:b", AgentState::Running),
3120        ]));
3121        app.dismiss_splash();
3122        // Focused pane is Roster → arrow cycles selection.
3123        app.selected_agent = Some(0);
3124        dispatch(&mut app, key(KeyCode::Down));
3125        assert_eq!(app.selected_agent, Some(1));
3126        // Cycle to Detail → arrow no longer touches selection.
3127        app.cycle_focus();
3128        dispatch(&mut app, key(KeyCode::Down));
3129        assert_eq!(
3130            app.selected_agent,
3131            Some(1),
3132            "non-roster focus ignores arrows"
3133        );
3134    }
3135
3136    // ---- T-108 stream-keys mode -------------------------------------------
3137
3138    /// Spin up a Triptych-stage app with one agent selected and the
3139    /// detail pane focused — the standard precondition for entering
3140    /// stream-keys mode.
3141    fn stream_keys_fixture() -> App {
3142        let mut app = App::new();
3143        app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3144        app.dismiss_splash();
3145        app.cycle_focus(); // Roster → Detail
3146        assert_eq!(app.focused_pane, Pane::Detail);
3147        assert_eq!(app.selected_agent, Some(0));
3148        app
3149    }
3150
3151    fn stream_dispatch(
3152        app: &mut App,
3153        ev: Event,
3154        key_sender: &crate::keysender::test_support::MockKeySender,
3155    ) {
3156        super::handle_event(
3157            app,
3158            ev,
3159            &NoopDecider,
3160            &NoopSender,
3161            &EmptyMailbox,
3162            key_sender,
3163        );
3164    }
3165
3166    #[test]
3167    fn ctrl_e_enters_stream_keys_when_detail_focused() {
3168        use crate::keysender::test_support::MockKeySender;
3169        use crossterm::event::KeyModifiers;
3170        let mut app = stream_keys_fixture();
3171        let ks = MockKeySender::default();
3172        stream_dispatch(
3173            &mut app,
3174            key_with(KeyCode::Char('e'), KeyModifiers::CONTROL),
3175            &ks,
3176        );
3177        assert_eq!(app.stage, Stage::StreamKeys);
3178        assert!(
3179            ks.calls.lock().unwrap().is_empty(),
3180            "the activation chord itself never forwards a keystroke"
3181        );
3182    }
3183
3184    #[test]
3185    fn ctrl_e_no_op_when_detail_not_focused() {
3186        // Activation gate: stream-mode never triggers from Roster /
3187        // Mailbox focus, so a stray `Ctrl+E` while scrolling the
3188        // roster doesn't yank the operator into a modal they didn't
3189        // ask for.
3190        use crate::keysender::test_support::MockKeySender;
3191        use crossterm::event::KeyModifiers;
3192        let mut app = App::new();
3193        app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3194        app.dismiss_splash();
3195        assert_eq!(app.focused_pane, Pane::Roster);
3196        let ks = MockKeySender::default();
3197        stream_dispatch(
3198            &mut app,
3199            key_with(KeyCode::Char('e'), KeyModifiers::CONTROL),
3200            &ks,
3201        );
3202        assert_eq!(app.stage, Stage::Triptych);
3203    }
3204
3205    #[test]
3206    fn ctrl_e_no_op_when_no_agent_selected() {
3207        // No target session → entering stream-mode would type into
3208        // the void. The guard short-circuits.
3209        use crate::keysender::test_support::MockKeySender;
3210        use crossterm::event::KeyModifiers;
3211        let mut app = App::new();
3212        app.dismiss_splash();
3213        app.cycle_focus(); // Detail
3214        assert_eq!(app.selected_agent, None);
3215        let ks = MockKeySender::default();
3216        stream_dispatch(
3217            &mut app,
3218            key_with(KeyCode::Char('e'), KeyModifiers::CONTROL),
3219            &ks,
3220        );
3221        assert_eq!(app.stage, Stage::Triptych);
3222    }
3223
3224    #[test]
3225    fn esc_exits_stream_keys() {
3226        use crate::keysender::test_support::MockKeySender;
3227        let mut app = stream_keys_fixture();
3228        app.enter_stream_keys();
3229        assert_eq!(app.stage, Stage::StreamKeys);
3230        let ks = MockKeySender::default();
3231        stream_dispatch(&mut app, key(KeyCode::Esc), &ks);
3232        assert_eq!(app.stage, Stage::Triptych);
3233        assert!(
3234            ks.calls.lock().unwrap().is_empty(),
3235            "Esc is the exit chord — it must not forward as a keystroke"
3236        );
3237    }
3238
3239    #[test]
3240    fn stream_mode_forwards_printable_chars_to_target_session() {
3241        use crate::keysender::test_support::MockKeySender;
3242        let mut app = stream_keys_fixture();
3243        app.enter_stream_keys();
3244        let ks = MockKeySender::default();
3245        for c in "hi".chars() {
3246            stream_dispatch(&mut app, key(KeyCode::Char(c)), &ks);
3247        }
3248        let calls = ks.calls.lock().unwrap();
3249        assert_eq!(calls.len(), 2, "one tmux send-keys per keystroke");
3250        // Target session = the focused agent's tmux_session (set by
3251        // the fixture to `t-p-a`).
3252        assert_eq!(calls[0].0, "t-p-a");
3253        assert_eq!(calls[0].1.args, vec!["-l".to_string(), "h".to_string()]);
3254        assert_eq!(calls[1].1.args, vec!["-l".to_string(), "i".to_string()]);
3255    }
3256
3257    #[test]
3258    fn stream_mode_passes_ctrl_c_through_to_agent() {
3259        // Issue #108 design point: Ctrl+C is shell-SIGINT semantics,
3260        // not a stream-mode escape. Pin the contract so a future
3261        // "intercept Ctrl+C as bail" refactor doesn't regress it.
3262        use crate::keysender::test_support::MockKeySender;
3263        use crossterm::event::KeyModifiers;
3264        let mut app = stream_keys_fixture();
3265        app.enter_stream_keys();
3266        let ks = MockKeySender::default();
3267        stream_dispatch(
3268            &mut app,
3269            key_with(KeyCode::Char('c'), KeyModifiers::CONTROL),
3270            &ks,
3271        );
3272        assert_eq!(app.stage, Stage::StreamKeys, "Ctrl+C does NOT exit");
3273        let calls = ks.calls.lock().unwrap();
3274        assert_eq!(calls.len(), 1);
3275        assert_eq!(calls[0].1.args, vec!["C-c".to_string()]);
3276    }
3277
3278    #[test]
3279    fn stream_mode_forwards_enter_and_arrows() {
3280        use crate::keysender::test_support::MockKeySender;
3281        let mut app = stream_keys_fixture();
3282        app.enter_stream_keys();
3283        let ks = MockKeySender::default();
3284        stream_dispatch(&mut app, key(KeyCode::Enter), &ks);
3285        stream_dispatch(&mut app, key(KeyCode::Up), &ks);
3286        let calls = ks.calls.lock().unwrap();
3287        assert_eq!(calls[0].1.args, vec!["Enter".to_string()]);
3288        assert_eq!(calls[1].1.args, vec!["Up".to_string()]);
3289    }
3290
3291    #[test]
3292    fn stream_target_session_uses_focused_split_when_present() {
3293        // Splits change which agent the operator is "looking at."
3294        // The selected_split index drives the focus ring in
3295        // render_detail_splits; stream_target_session must mirror
3296        // that so typing lands in the right pane.
3297        let mut app = App::new();
3298        app.replace_team(fixture_team(vec![
3299            agent("p:a", AgentState::Running),
3300            agent("p:b", AgentState::Running),
3301        ]));
3302        app.dismiss_splash();
3303        app.cycle_focus(); // Detail
3304        app.selected_agent = Some(0);
3305        // Manually push a split for `p:b` and focus it.
3306        app.detail_splits
3307            .push(("p:b".into(), crate::app::SplitOrientation::Vertical));
3308        app.selected_split = 1; // cell index 0 = focused agent, 1 = first split
3309        let target = app.stream_target_session();
3310        assert_eq!(
3311            target.as_deref(),
3312            Some("t-p-b"),
3313            "selected split's agent drives the target"
3314        );
3315    }
3316
3317    #[test]
3318    fn stream_mode_drops_back_when_target_session_disappears() {
3319        // If the team gets reloaded mid-stream and the focused
3320        // agent's index points off the end, the next keystroke
3321        // can't resolve a session. Drop back to Triptych so the
3322        // operator isn't silently typing into the void.
3323        use crate::keysender::test_support::MockKeySender;
3324        let mut app = stream_keys_fixture();
3325        app.enter_stream_keys();
3326        // Simulate the agent disappearing.
3327        app.selected_agent = None;
3328        app.team.agents.clear();
3329        let ks = MockKeySender::default();
3330        stream_dispatch(&mut app, key(KeyCode::Char('a')), &ks);
3331        assert_eq!(app.stage, Stage::Triptych);
3332        assert!(ks.calls.lock().unwrap().is_empty());
3333    }
3334
3335    // ── T-199: detail-pane → inner-tmux size sync ───────────────────
3336
3337    fn pane_sync_fixture() -> App {
3338        let mut app = App::new();
3339        app.team = fixture_team(vec![
3340            agent("hello:mgr", AgentState::Running),
3341            agent("hello:dev", AgentState::Running),
3342        ]);
3343        app.selected_agent = Some(0);
3344        app.stage = Stage::Triptych;
3345        app.layout = MainLayout::Triptych;
3346        app
3347    }
3348
3349    #[test]
3350    fn sync_fires_resize_on_first_frame() {
3351        let mut app = pane_sync_fixture();
3352        let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
3353        sync_focused_pane_size_to(
3354            &mut app,
3355            ratatui::layout::Rect::new(0, 0, 120, 40),
3356            &resizer,
3357        );
3358        let calls = resizer.calls.lock().unwrap();
3359        // First frame: cache empty, expect one call for the focused
3360        // session (mgr) at the typical 120×40 Triptych Detail rect.
3361        assert_eq!(calls.len(), 1);
3362        assert_eq!(calls[0].0, "t-hello-mgr");
3363        assert_eq!(calls[0].1, 92); // Detail width = 120 - 28 sidebar
3364        assert_eq!(calls[0].2, 24); // Detail height = 3/5 of 40
3365    }
3366
3367    #[test]
3368    fn sync_skips_when_size_unchanged() {
3369        let mut app = pane_sync_fixture();
3370        let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
3371        // Two frames at identical size → only the first should fire.
3372        sync_focused_pane_size_to(
3373            &mut app,
3374            ratatui::layout::Rect::new(0, 0, 120, 40),
3375            &resizer,
3376        );
3377        sync_focused_pane_size_to(
3378            &mut app,
3379            ratatui::layout::Rect::new(0, 0, 120, 40),
3380            &resizer,
3381        );
3382        assert_eq!(resizer.calls.lock().unwrap().len(), 1);
3383    }
3384
3385    #[test]
3386    fn sync_fires_again_when_terminal_resizes() {
3387        let mut app = pane_sync_fixture();
3388        let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
3389        sync_focused_pane_size_to(
3390            &mut app,
3391            ratatui::layout::Rect::new(0, 0, 120, 40),
3392            &resizer,
3393        );
3394        // Operator resized the host terminal.
3395        sync_focused_pane_size_to(
3396            &mut app,
3397            ratatui::layout::Rect::new(0, 0, 200, 60),
3398            &resizer,
3399        );
3400        let calls = resizer.calls.lock().unwrap();
3401        assert_eq!(calls.len(), 2);
3402        assert_eq!(calls[0].1, 92);
3403        assert_eq!(calls[0].2, 24);
3404        assert_eq!(calls[1].1, 172); // 200 - 28
3405                                     // Height = 3/5 of 60 = 36.
3406        assert_eq!(calls[1].2, 36);
3407    }
3408
3409    #[test]
3410    fn sync_fires_on_focus_switch_to_unsynced_session() {
3411        let mut app = pane_sync_fixture();
3412        let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
3413        sync_focused_pane_size_to(
3414            &mut app,
3415            ratatui::layout::Rect::new(0, 0, 120, 40),
3416            &resizer,
3417        );
3418        // Operator switched focus to the dev agent.
3419        app.selected_agent = Some(1);
3420        sync_focused_pane_size_to(
3421            &mut app,
3422            ratatui::layout::Rect::new(0, 0, 120, 40),
3423            &resizer,
3424        );
3425        let calls = resizer.calls.lock().unwrap();
3426        assert_eq!(calls.len(), 2);
3427        assert_eq!(calls[0].0, "t-hello-mgr");
3428        assert_eq!(calls[1].0, "t-hello-dev");
3429    }
3430
3431    #[test]
3432    fn sync_is_noop_when_no_agent_focused() {
3433        let mut app = pane_sync_fixture();
3434        app.selected_agent = None;
3435        let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
3436        sync_focused_pane_size_to(
3437            &mut app,
3438            ratatui::layout::Rect::new(0, 0, 120, 40),
3439            &resizer,
3440        );
3441        assert!(resizer.calls.lock().unwrap().is_empty());
3442    }
3443
3444    #[test]
3445    fn sync_is_noop_when_layout_is_not_triptych() {
3446        let mut app = pane_sync_fixture();
3447        app.layout = MainLayout::Wall;
3448        let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
3449        sync_focused_pane_size_to(
3450            &mut app,
3451            ratatui::layout::Rect::new(0, 0, 120, 40),
3452            &resizer,
3453        );
3454        // Wall / MailboxFirst use different geometry; out of scope for
3455        // T-199. No tmux resize-pane should fire from this path.
3456        assert!(resizer.calls.lock().unwrap().is_empty());
3457    }
3458
3459    #[test]
3460    fn sync_is_noop_on_degenerate_terminal_area() {
3461        let mut app = pane_sync_fixture();
3462        let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
3463        // Width is exactly the sidebar (28) → Detail rect is zero.
3464        sync_focused_pane_size_to(&mut app, ratatui::layout::Rect::new(0, 0, 28, 40), &resizer);
3465        assert!(resizer.calls.lock().unwrap().is_empty());
3466    }
3467
3468    #[test]
3469    fn sync_accounts_for_approvals_stripe_when_present() {
3470        let mut app = pane_sync_fixture();
3471        // Force the approvals-stripe path: one pending approval.
3472        app.pending_approvals = vec![crate::approvals::Approval {
3473            id: 1,
3474            project_id: "hello".into(),
3475            agent_id: "hello:dev".into(),
3476            action: "test".into(),
3477            summary: "test approval".into(),
3478            payload_json: String::new(),
3479        }];
3480        assert!(app.has_pending_approvals());
3481        let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
3482        sync_focused_pane_size_to(
3483            &mut app,
3484            ratatui::layout::Rect::new(0, 0, 120, 40),
3485            &resizer,
3486        );
3487        let calls = resizer.calls.lock().unwrap();
3488        // Stripe consumes one row → Detail height is 3/5 of 39 = 23.
3489        assert_eq!(calls.len(), 1);
3490        assert_eq!(calls[0].2, 23);
3491    }
3492}