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