//! App state and the top-level run loop.
//!
//! Three stages today: `Splash` (figlet logo for ~3s or until first
//! key), `Triptych` (the default read view, now backed by a live
//! team snapshot from PR-UI-2), and `QuitConfirm` (a modal asking
//! "really?"). Subsequent stacked PRs bolt on more modals and the
//! layout variants from SPEC §3 — those wire in by adding `Stage`
//! variants and dispatching from `draw`/`handle_event`, no
//! rearchitecting.

use std::time::{Duration, Instant};

use anyhow::Result;
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use ratatui::backend::Backend;
use ratatui::buffer::Buffer;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget, Wrap};
use ratatui::{Frame, Terminal};

use crate::approvals::{
    Approval, ApprovalDecider, ApprovalSource, BrokerApprovalSource, CliApprovalDecider, Decision,
};
use crate::compose::{CliMessageSender, ComposeTarget, Editor, EditorAction, MessageSender};
use crate::data::TeamSnapshot;
use crate::keysender::{encode_key, AsyncKeySender, KeySender, ScrollDirection, TmuxKeySender};
use crate::layouts;
use crate::mailbox::{
    BrokerMailboxSource, MailboxBuffers, MailboxInputKind, MailboxSource, MailboxTab, MessageRow,
};
use crate::pane::{PaneSource, TmuxPaneSource};
use crate::splash;
use crate::status_bar;
use crate::statusline;
use crate::theme::{detect_capabilities, Capabilities};
use crate::triptych::{self, MainLayout, Pane};
use crate::tutorial;
use crate::watch::Watch;

const SPLASH_AUTO_DISMISS: Duration = Duration::from_secs(3);
/// Idle ceiling for the event loop's `event::poll` — also the loop's
/// redraw cadence when nothing is typed (each iteration redraws, then
/// blocks here until a key arrives or this elapses). `event::poll` is
/// fd-event-driven, so a keypress wakes it immediately regardless of
/// this value (it is NOT the input-latency source, #386); lowering it
/// only makes the idle redraw + file-watcher dirty-detect snappier. At
/// ~30 Hz the per-iteration work is a sub-millisecond ratatui diff
/// (no subprocess unless a capture/refresh is due), so the idle CPU
/// cost stays negligible.
const POLL_INTERVAL: Duration = Duration::from_millis(33);
/// How often the team snapshot + detail-pane capture get refreshed.
/// PR-UI-2 polls; PR-UI-3 may upgrade to event subscriptions.
const REFRESH_INTERVAL: Duration = Duration::from_secs(1);
/// How often the *focused* agent's pane is re-captured on its own. The
/// full 1s refresh is too slow for the detail view to feel live, so we
/// re-capture just the one focused pane this often between refreshes —
/// a single `tmux capture-pane`, cheap enough to run ~10×/s.
const PANE_REFRESH_INTERVAL: Duration = Duration::from_millis(100);

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Stage {
    Splash,
    Triptych,
    QuitConfirm,
    /// Approvals modal — opens on `a` (only when there's a
    /// pending approval), routes Approve/Deny via the existing
    /// `teamctl approve|deny` CLI so T-031's `delivered_at`
    /// contract stays honored.
    ApprovalsModal,
    /// Compose modal — opens on `@` (DM-to-focused-agent) or `!`
    /// (broadcast-to-current-channel). Routes through `teamctl
    /// send|broadcast` so the channel-ACL + ratelimit + delivery
    /// hooks the CLI already runs through ride for free.
    ComposeModal,
    /// `?` help overlay — modal listing every chord registered in
    /// `help::ALL_GROUPS`. Read-only; closes on Esc / `?`.
    HelpOverlay,
    /// Onboarding tutorial walkthrough. Auto-opens on first
    /// launch (per-team sentinel at
    /// `.team/state/ui-tutorial-completed`); reopenable via `t`
    /// from any non-modal state.
    Tutorial,
    /// Stream-keys mode (T-108). Activated by `Ctrl+E` while the
    /// detail pane is focused; every subsequent keystroke (except
    /// `Esc`, the exit chord) is forwarded to the focused agent's
    /// tmux pane via `tmux send-keys`. The Triptych keeps rendering
    /// underneath — the 1s refresh tick still captures whatever the
    /// agent prints in response — so the operator interacts with
    /// the agent in real time without leaving the UI.
    StreamKeys,
    /// Mailbox detail modal (T-131 PR-3). `Enter` on a selected
    /// mailbox row snapshots that row into `mailbox_detail_modal`
    /// and flips here; the modal renders the full message body
    /// (wrapped, j/k-scrollable) plus sender / recipient / kind /
    /// absolute timestamp / transport / message id. The snapshot is
    /// captured AT open-time so the rendered content is stable
    /// across any subsequent underlying-buffer drain (PR-3 variant
    /// (a) locked: snapshot-at-open, not id-tracking — resolves the
    /// PR-1 kian-#1 identity question at the point it actually
    /// matters). `Esc` or `q` closes.
    MailboxDetailModal,
}

/// Splitscreen orientation per detail-pane split (PR-UI-7 lift
/// of PR-UI-6's deferred Q1). `Vertical` subdivides side-by-side
/// (Ctrl+|); `Horizontal` stacks top-to-bottom (Ctrl+-).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SplitOrientation {
    Vertical,
    Horizontal,
}

pub struct App {
    pub stage: Stage,
    /// Tracked so QuitConfirm can return to whichever stage opened it.
    pub previous_stage: Stage,
    pub focused_pane: Pane,
    pub team: TeamSnapshot,
    /// Index into `team.agents` of the agent the detail pane is
    /// streaming. `None` when the team is empty or roster
    /// navigation hasn't picked one yet.
    pub selected_agent: Option<usize>,
    /// Lines from the most recent pane capture. Bounded to the last
    /// `MAX_DETAIL_LINES` so the buffer doesn't grow unboundedly
    /// over a long-running session.
    pub detail_buffer: Vec<String>,
    pub version: &'static str,
    pub capabilities: Capabilities,
    pub splash_started: Instant,
    /// Last time the snapshot + pane capture were refreshed. Used by
    /// `tick()` to gate the next refresh.
    pub last_refresh: Instant,
    /// Last time the focused agent's pane was re-captured on the fast
    /// `PANE_REFRESH_INTERVAL` cadence (between full refreshes).
    pub last_pane_refresh: Instant,
    pub running: bool,
    /// First-launch detection — when the marker file exists, future
    /// stacked-PRs (PR-UI-7) skip the tutorial after splash. PR-UI-1
    /// only reads the flag; nothing routes off it yet.
    pub tutorial_completed: bool,
    /// Active tab inside the mailbox pane (PR-UI-3). Walked with
    /// `←` / `→` when `focused_pane == Mailbox` (T-124 hard-swapped
    /// the prior `[` / `]` chord for arrow keys; T-074 bug 6 is
    /// the gating-on-focus invariant). `Tab` always cycles pane
    /// focus, never mailbox tabs — the previous "Tab cycles tabs
    /// when mailbox is focused" shape stranded operators inside
    /// the mailbox.
    pub mailbox_tab: MailboxTab,
    /// Per-tab buffers + cursors for the focused agent's mailbox
    /// view. Reset whenever the focused agent changes — switching
    /// agents starts the operator at the head of fresh traffic.
    pub mailbox: MailboxBuffers,
    /// T-131 PR-2: which mailbox input the operator is currently
    /// editing, if any. Singleton — only one input open at a time
    /// across all tabs. When `Some`, `Pane::Mailbox` keystrokes route
    /// to the per-tab `filter_text` / `search_text` buffer on
    /// [`MailboxBuffers`] (the data lives there, per-tab; this is
    /// just the editing-UI flag).
    pub mailbox_input_mode: Option<MailboxInputKind>,
    /// Pre-open snapshot of the active input buffer — restored on
    /// `Esc` (cancel-revert) so the operator can back out without
    /// losing the prior filter/search. Empty between sessions.
    pub mailbox_input_snapshot: String,
    /// T-131 PR-3: mailbox detail modal — the row content the
    /// operator opened. Captured AT open-time and rendered from
    /// here independent of the underlying mailbox buffer: any
    /// subsequent `extend()` drain that would shift indices on the
    /// row cursor leaves this snapshot intact, so the operator
    /// sees the message they clicked, not whatever now happens to
    /// sit at the same index (variant (a) locked). `None` when no
    /// modal is open.
    pub mailbox_detail_modal: Option<MessageRow>,
    /// T-131 PR-3: vertical scroll offset (in wrapped body lines)
    /// within an open detail modal. Reset to 0 when the modal
    /// opens; bumped by `j` / `Down` / `k` / `Up` while the modal
    /// is the active stage. Ignored when no modal is open.
    pub mailbox_detail_scroll: u16,
    /// T-131 PR-4: wall-clock seconds at the last render tick. The
    /// mailbox-row relative-time indicator (`2m` / `1h` / `3d`)
    /// reads from here so render is a pure function of `App` —
    /// snapshot tests can pin time deterministically by setting
    /// this field (otherwise wall-clock would diff snapshots every
    /// run). The `run` loop refreshes this before each
    /// `terminal.draw`; defaults to 0 in `App::new` so a freshly
    /// constructed test app + sent_at=0 fixture rows render `now`
    /// stably.
    pub now_secs: f64,
    /// Pending approvals snapshot (PR-UI-4). Drives the conditional
    /// stripe at the top of Triptych and the modal opened by `a`.
    pub pending_approvals: Vec<Approval>,
    /// Index into `pending_approvals` of the row the modal is
    /// currently showing. Reset to 0 each time the modal opens;
    /// `j` / `k` (or `↑` / `↓`) cycle.
    pub selected_approval: usize,
    /// Last error from a CLI-routed Approve/Deny call — surfaced
    /// inline in the modal so the operator sees why a decision
    /// didn't take.
    pub approval_error: Option<String>,
    /// Open compose target — `Some` while `Stage::ComposeModal`
    /// is the active stage, `None` otherwise. Stored on App so
    /// the editor's contents survive rerenders.
    pub compose_target: Option<ComposeTarget>,
    /// Editor backing the compose modal. Reset to `default()` each
    /// time the modal opens so an old draft from a prior
    /// invocation can't leak into a new send.
    pub compose_editor: Editor,
    /// Last error from a CLI-routed send call — surfaced inline
    /// in the modal so the operator sees rate-limit / ACL-block
    /// errors without leaving the UI.
    pub compose_error: Option<String>,
    /// Active main-view layout (PR-UI-6). Triptych is the default;
    /// `Ctrl+W` toggles Wall, `Ctrl+M` toggles MailboxFirst.
    pub layout: MainLayout,
    /// Top-of-window agent index for the Wall view's vertical
    /// scroll. SPEC §3 caps visible tiles at 4; this offsets which
    /// 4-agent window is shown when the team has more.
    pub wall_scroll: usize,
    /// Selected channel index (into `team.channels`) for the
    /// MailboxFirst layout's channel list and for the broadcast
    /// picker. `None` until the operator picks one.
    pub selected_channel: Option<usize>,
    /// Splits within Triptych's detail pane (PR-UI-6). When
    /// non-empty, the detail pane subdivides; each entry pairs an
    /// agent id with the per-split orientation (PR-UI-7 lift of
    /// the Q1 deferral). `selected_split` is the vim-window-motion
    /// focus.
    pub detail_splits: Vec<(String, SplitOrientation)>,
    pub selected_split: usize,
    /// Chord-prefix machine for `Ctrl+W` follow-ups (PR-UI-7 lift
    /// of PR-UI-6's `Ctrl+Q` alias). When `Some(KeyCode::Char('w'))`,
    /// the next key is interpreted as a `Ctrl+W` follow: `q` =
    /// close split, `o` = close others. Cleared on any unrelated
    /// keypress so a typo doesn't leave the editor stuck.
    pub pending_chord: Option<KeyCode>,
    /// `true` when the operator's first launch on this team has
    /// not yet completed the tutorial — drives the auto-open after
    /// splash. Reset to `false` on tutorial completion.
    pub tutorial_pending_for_team: bool,
    /// Brand-spinner frame counter (PR-UI-7). Bumped each refresh
    /// tick so the statusline indicator shows the app is alive.
    pub spinner_frame: usize,
    /// Tutorial step cursor (PR-UI-7). Index into
    /// `onboarding::STEPS`; reset to 0 when the tutorial reopens.
    pub tutorial_step: usize,
    /// Modal substage for the broadcast channel picker (PR-UI-6).
    /// When `true` the compose modal renders a picker over the
    /// editor; selecting a channel populates `compose_target` and
    /// drops back to the editor.
    pub compose_picker_open: bool,
    /// Picker selection cursor — index into `team.channels`.
    pub compose_picker_index: usize,
    /// T-32: when `true`, the compose modal renders a single-line
    /// path-input overlay instead of the editor; Enter appends a
    /// `📎 attachment: <path>` line to the editor body and closes the
    /// overlay. Tab inside the editor opens it; Esc inside the
    /// overlay cancels back to the editor (matches the picker
    /// overlay's modal-vs-modal symmetry from PR-UI-6).
    pub compose_attach_input_open: bool,
    /// Single-line buffer for the path-input overlay. Reset on close
    /// so a cancelled draft can't leak into the next attach attempt.
    pub compose_attach_buffer: String,
    /// T-199: per-session cache of the last Detail-pane size we
    /// pushed to `tmux resize-pane`. The run loop diffs the current
    /// Detail rect against this on every frame and only spawns the
    /// tmux command when the size actually changed — common case
    /// (no resize, no focus switch) is a HashMap lookup. Keyed by
    /// `tmux_session` (e.g. `t-hello-manager`). See
    /// `crate::pane_resize`.
    pub last_synced_pane_sizes: std::collections::HashMap<String, (u16, u16)>,
    /// T-209: live system handle for the bottom status bar's
    /// CPU% + RAM% indicator. Refreshed in-place on the existing
    /// 1-second App tick (see `refresh_with_default_sources` and the
    /// run-loop tick at the top of `run()`); no background thread.
    /// `default-features = false` + only the `system` feature is
    /// enabled in the dep to keep the compile surface narrow. See
    /// `crate::status_bar`.
    pub sysinfo: sysinfo::System,
    /// T-212 / #431 rate-limit indicator gate. `true` by default; `false`
    /// only when `TEAMCTL_UI_RATE_LIMIT_INDICATOR=0` was set at App::new().
    /// The bottom status bar's center slot only renders when this is true.
    /// Tests can flip the field directly to exercise both branches without
    /// process-wide env-var racing.
    pub rate_limit_indicator_enabled: bool,
}

const MAX_DETAIL_LINES: usize = 2000;

impl App {
    /// Construct an empty App — no team snapshot loaded. Used by
    /// tests and as the splash-stage default. Production launch
    /// goes through `App::launch()` which immediately runs an
    /// initial `refresh()` so the splash screen already shows the
    /// real team name + agent count.
    pub fn new() -> Self {
        Self {
            stage: Stage::Splash,
            previous_stage: Stage::Splash,
            focused_pane: Pane::Roster,
            team: TeamSnapshot::empty(std::path::PathBuf::new()),
            selected_agent: None,
            detail_buffer: Vec::new(),
            version: env!("CARGO_PKG_VERSION"),
            capabilities: detect_capabilities(),
            splash_started: Instant::now(),
            last_refresh: Instant::now() - REFRESH_INTERVAL,
            last_pane_refresh: Instant::now(),
            running: true,
            tutorial_completed: tutorial::is_completed(),
            mailbox_tab: MailboxTab::Inbox,
            mailbox: MailboxBuffers::default(),
            mailbox_input_mode: None,
            mailbox_input_snapshot: String::new(),
            mailbox_detail_modal: None,
            mailbox_detail_scroll: 0,
            now_secs: 0.0,
            pending_approvals: Vec::new(),
            selected_approval: 0,
            approval_error: None,
            compose_target: None,
            compose_editor: Editor::default(),
            compose_error: None,
            layout: MainLayout::Triptych,
            wall_scroll: 0,
            selected_channel: None,
            detail_splits: Vec::new(),
            selected_split: 0,
            compose_picker_open: false,
            compose_picker_index: 0,
            compose_attach_input_open: false,
            compose_attach_buffer: String::new(),
            pending_chord: None,
            tutorial_pending_for_team: false,
            spinner_frame: 0,
            tutorial_step: 0,
            last_synced_pane_sizes: std::collections::HashMap::new(),
            // sysinfo's `new()` allocates but doesn't read any metrics;
            // the first values are populated by the first refresh tick
            // in `refresh_with_default_sources`. Until then the status
            // bar reads zeros — operator sees the bar shape but the
            // numbers stabilize after ~1 second.
            sysinfo: sysinfo::System::new(),
            // T-212 / #431: the per-agent rate-limit indicator is on by
            // default now that the #431 hook feeds it hit rows. Operators
            // opt OUT by setting `TEAMCTL_UI_RATE_LIMIT_INDICATOR=0`; any
            // other value (or the var unset) leaves it enabled. Read once at
            // App::new(); flipping the var mid-session requires a TUI restart.
            rate_limit_indicator_enabled: std::env::var_os("TEAMCTL_UI_RATE_LIMIT_INDICATOR")
                .is_none_or(|v| v != "0"),
        }
    }

    /// Per-tutorial-step cursor (used by Stage::Tutorial). Wraps
    /// at the end so `t`-then-keys walks the full tour.
    pub fn enter_help_overlay(&mut self) {
        self.previous_stage = self.stage;
        self.stage = Stage::HelpOverlay;
    }
    pub fn close_help_overlay(&mut self) {
        self.stage = self.previous_stage;
    }
    pub fn enter_tutorial(&mut self) {
        self.previous_stage = self.stage;
        self.stage = Stage::Tutorial;
        self.tutorial_step = 0;
    }
    pub fn close_tutorial(&mut self) {
        self.stage = self.previous_stage;
        self.tutorial_pending_for_team = false;
        if !self.team.root.as_os_str().is_empty() {
            let _ = crate::onboarding::mark_completed(&self.team.root);
        }
    }
    pub fn tutorial_advance(&mut self) {
        let len = crate::onboarding::STEPS.len();
        if len == 0 {
            self.close_tutorial();
            return;
        }
        if self.tutorial_step + 1 >= len {
            self.close_tutorial();
        } else {
            self.tutorial_step += 1;
        }
    }
    pub fn tutorial_back(&mut self) {
        self.tutorial_step = self.tutorial_step.saturating_sub(1);
    }

    pub fn toggle_wall_layout(&mut self) {
        self.layout = self.layout.toggle_wall();
    }
    pub fn toggle_mailbox_first_layout(&mut self) {
        self.layout = self.layout.toggle_mailbox_first();
        // First entry into MailboxFirst seeds the channel cursor
        // so the feed pane has something to render.
        if matches!(self.layout, MainLayout::MailboxFirst) && self.selected_channel.is_none() {
            self.selected_channel = if self.team.channels.is_empty() {
                None
            } else {
                Some(0)
            };
        }
    }
    pub fn wall_scroll_up(&mut self) {
        self.wall_scroll = self
            .wall_scroll
            .saturating_sub(crate::layouts::WALL_TILE_CAP);
    }
    pub fn wall_scroll_down(&mut self) {
        let next = self.wall_scroll + crate::layouts::WALL_TILE_CAP;
        if next < self.team.agents.len() {
            self.wall_scroll = next;
        }
    }
    pub fn select_next_channel(&mut self) {
        if self.team.channels.is_empty() {
            return;
        }
        self.selected_channel = Some(match self.selected_channel {
            None => 0,
            Some(i) => (i + 1) % self.team.channels.len(),
        });
    }
    pub fn select_prev_channel(&mut self) {
        if self.team.channels.is_empty() {
            return;
        }
        self.selected_channel = Some(match self.selected_channel {
            None | Some(0) => self.team.channels.len() - 1,
            Some(i) => i - 1,
        });
    }

    /// Add a split for the focused agent (or current selection)
    /// to the detail pane. Cap at 4 splits per the SPEC §3 cap.
    /// Add a vertical split (PR-UI-7). `Ctrl+|` calls this.
    pub fn add_detail_split_vertical(&mut self) {
        self.add_detail_split_with_orientation(SplitOrientation::Vertical);
    }
    /// Add a horizontal split (PR-UI-7). `Ctrl+-` calls this.
    pub fn add_detail_split_horizontal(&mut self) {
        self.add_detail_split_with_orientation(SplitOrientation::Horizontal);
    }
    fn add_detail_split_with_orientation(&mut self, orientation: SplitOrientation) {
        let Some(id) = self.selected_agent_id() else {
            return;
        };
        if self.detail_splits.len() >= 4 {
            return;
        }
        self.detail_splits.push((id, orientation));
        self.selected_split = self.detail_splits.len() - 1;
    }
    /// Back-compat shim — earlier PRs called the unsuffixed name.
    /// Defaults to vertical (matching the most-common chord
    /// `Ctrl+|`). Kept so the test surface PR-UI-6 pinned doesn't
    /// drift.
    pub fn add_detail_split(&mut self) {
        self.add_detail_split_vertical();
    }
    pub fn close_focused_split(&mut self) {
        if self.detail_splits.is_empty() {
            return;
        }
        let i = self.selected_split.min(self.detail_splits.len() - 1);
        self.detail_splits.remove(i);
        self.selected_split = i.saturating_sub(1);
    }
    pub fn cycle_split_next(&mut self) {
        if self.detail_splits.is_empty() {
            return;
        }
        self.selected_split = (self.selected_split + 1) % self.detail_splits.len();
    }
    pub fn cycle_split_prev(&mut self) {
        if self.detail_splits.is_empty() {
            return;
        }
        self.selected_split = if self.selected_split == 0 {
            self.detail_splits.len() - 1
        } else {
            self.selected_split - 1
        };
    }

    /// Open the broadcast compose flow — picker first when at
    /// least one channel is declared, else fall back to the
    /// project's `all` channel (PR-UI-5 behaviour) on the
    /// assumption that `all` always exists in production composes.
    pub fn enter_compose_broadcast_with_picker(&mut self) {
        if self.team.channels.is_empty() {
            // Fall back to the PR-UI-5 default if no channels
            // are declared yet — should only happen with a
            // half-loaded snapshot.
            self.enter_compose_broadcast();
            return;
        }
        let project_id = self
            .team
            .channels
            .first()
            .map(|c| c.project_id.clone())
            .unwrap_or_default();
        self.previous_stage = self.stage;
        self.stage = Stage::ComposeModal;
        self.compose_target = Some(ComposeTarget::Broadcast {
            channel_id: format!("{project_id}:all"),
            project_id,
        });
        self.compose_editor = Editor::default();
        self.compose_error = None;
        self.compose_picker_open = true;
        self.compose_picker_index = 0;
    }
    pub fn picker_next(&mut self) {
        if self.team.channels.is_empty() {
            return;
        }
        self.compose_picker_index = (self.compose_picker_index + 1) % self.team.channels.len();
    }
    pub fn picker_prev(&mut self) {
        if self.team.channels.is_empty() {
            return;
        }
        self.compose_picker_index = if self.compose_picker_index == 0 {
            self.team.channels.len() - 1
        } else {
            self.compose_picker_index - 1
        };
    }
    pub fn picker_confirm(&mut self) {
        if let Some(ch) = self.team.channels.get(self.compose_picker_index) {
            self.compose_target = Some(ComposeTarget::Broadcast {
                channel_id: ch.id.clone(),
                project_id: ch.project_id.clone(),
            });
        }
        self.compose_picker_open = false;
    }

    /// T-32: open the path-input overlay. Resets the buffer so a
    /// previously-cancelled draft can't carry over.
    pub fn open_compose_attach_input(&mut self) {
        self.compose_attach_input_open = true;
        self.compose_attach_buffer.clear();
    }

    /// T-32: append a `📎 attachment: <path>` line to the compose
    /// editor and close the overlay. The line lands as a fresh row
    /// at the end of the body so the operator can edit it (or delete
    /// it) before sending. Whitespace-only buffers are ignored — Tab
    /// followed by Enter shouldn't insert an empty marker.
    pub fn confirm_compose_attach_input(&mut self) {
        let path = self.compose_attach_buffer.trim().to_string();
        if !path.is_empty() {
            let marker = format!("📎 attachment: {path}");
            // The body's final-trailing-blank rule (Editor::body)
            // strips empty trailing lines, so an empty last line
            // doesn't matter — we always push the marker as a new
            // line after current contents.
            if let Some(last) = self.compose_editor.lines.last_mut() {
                if !last.is_empty() {
                    self.compose_editor.lines.push(marker);
                } else {
                    *last = marker;
                }
            } else {
                self.compose_editor.lines.push(marker);
            }
            // Park the cursor at end of the new line so subsequent
            // typing in Insert mode picks up after the marker.
            self.compose_editor.cursor_row = self.compose_editor.lines.len() - 1;
            self.compose_editor.cursor_col = self
                .compose_editor
                .lines
                .last()
                .map(|l| l.len())
                .unwrap_or(0);
        }
        self.close_compose_attach_input();
    }

    pub fn close_compose_attach_input(&mut self) {
        self.compose_attach_input_open = false;
        self.compose_attach_buffer.clear();
    }

    pub fn cycle_mailbox_tab(&mut self) {
        self.mailbox_tab = self.mailbox_tab.next();
    }

    pub fn cycle_mailbox_tab_back(&mut self) {
        self.mailbox_tab = self.mailbox_tab.prev();
    }

    // T-131 PR-1: per-tab row cursor controls. Each delegates to the
    // matching `MailboxBuffers` method on the active tab, keeping the
    // App-level surface symmetric with the keybindings in
    // `handle_event` (Up/Down/j/k, PageUp/PageDown, Home/End).
    pub fn mailbox_cursor_down(&mut self) {
        self.mailbox.move_cursor_down(self.mailbox_tab);
    }

    pub fn mailbox_cursor_up(&mut self) {
        self.mailbox.move_cursor_up(self.mailbox_tab);
    }

    pub fn mailbox_page_down(&mut self) {
        self.mailbox.page_cursor_down(self.mailbox_tab);
    }

    pub fn mailbox_page_up(&mut self) {
        self.mailbox.page_cursor_up(self.mailbox_tab);
    }

    pub fn mailbox_cursor_home(&mut self) {
        self.mailbox.cursor_home(self.mailbox_tab);
    }

    pub fn mailbox_cursor_end(&mut self) {
        self.mailbox.cursor_end(self.mailbox_tab);
    }

    // T-131 PR-2: mailbox filter / search input mode. Singleton state
    // (only one input open at a time) drives editing into the active
    // tab's per-tab `filter_text` or `search_text` on MailboxBuffers.

    /// Open the sender-substring filter input on the active tab.
    /// Snapshots the current value so Esc can revert.
    pub fn open_mailbox_filter_input(&mut self) {
        self.mailbox_input_snapshot = self.mailbox.filter_text(self.mailbox_tab).to_string();
        self.mailbox_input_mode = Some(MailboxInputKind::Filter);
    }

    /// Open the body-substring search input on the active tab.
    /// Snapshots the current value so Esc can revert.
    pub fn open_mailbox_search_input(&mut self) {
        self.mailbox_input_snapshot = self.mailbox.search_text(self.mailbox_tab).to_string();
        self.mailbox_input_mode = Some(MailboxInputKind::Search);
    }

    /// Append `c` to the active input buffer. visible_indices
    /// recomputes live; the cursor re-clamps inside MailboxBuffers.
    pub fn mailbox_input_push_char(&mut self, c: char) {
        if let Some(kind) = self.mailbox_input_mode {
            self.mailbox.input_push_char(self.mailbox_tab, kind, c);
        }
    }

    /// Pop one character from the active input buffer.
    pub fn mailbox_input_pop_char(&mut self) {
        if let Some(kind) = self.mailbox_input_mode {
            self.mailbox.input_pop_char(self.mailbox_tab, kind);
        }
    }

    /// Confirm and close the input — keep the operator's typed text.
    pub fn mailbox_input_confirm(&mut self) {
        self.mailbox_input_mode = None;
        self.mailbox_input_snapshot.clear();
    }

    /// Cancel and close the input — revert the active buffer to the
    /// pre-open snapshot so the operator can back out without losing
    /// the prior filter / search.
    pub fn mailbox_input_cancel(&mut self) {
        if let Some(kind) = self.mailbox_input_mode {
            let snapshot = std::mem::take(&mut self.mailbox_input_snapshot);
            self.mailbox.set_input(self.mailbox_tab, kind, snapshot);
        }
        self.mailbox_input_mode = None;
        self.mailbox_input_snapshot.clear();
    }

    // T-131 PR-3: mailbox detail modal — snapshot-at-open, Esc/q
    // close, j/k scroll. The snapshot captures the row content at
    // open time so the rendered modal is stable across underlying
    // buffer drain (variant (a) locked).

    /// Open the detail modal on the currently-selected mailbox row.
    /// No-op when `visible_indices` is empty (no row to select) so
    /// `Enter` on an empty / fully-filtered tab silently does
    /// nothing rather than opening a modal on garbage.
    pub fn open_mailbox_detail_modal(&mut self) {
        let tab = self.mailbox_tab;
        let visible = self.mailbox.visible_indices(tab);
        if visible.is_empty() {
            return;
        }
        let idx = self.mailbox.cursor(tab).selected_idx.min(visible.len() - 1);
        let row_idx = visible[idx];
        let row = self.mailbox.rows(tab).get(row_idx).cloned();
        if let Some(row) = row {
            self.mailbox_detail_modal = Some(row);
            self.mailbox_detail_scroll = 0;
            self.stage = Stage::MailboxDetailModal;
        }
    }

    /// Close the detail modal and return to the Triptych. Clears
    /// the snapshot; the row cursor underneath is untouched.
    pub fn close_mailbox_detail_modal(&mut self) {
        self.mailbox_detail_modal = None;
        self.mailbox_detail_scroll = 0;
        self.stage = Stage::Triptych;
    }

    /// Scroll the detail modal body one wrapped line down. Caller
    /// supplies the maximum scroll value (lines beyond which there
    /// is no content); we clamp.
    pub fn mailbox_detail_scroll_down(&mut self) {
        // The renderer enforces the upper bound at draw time when it
        // knows the wrapped-body height; this helper just bumps the
        // offset. Saturating add caps at u16::MAX which is far
        // beyond any realistic body length.
        self.mailbox_detail_scroll = self.mailbox_detail_scroll.saturating_add(1);
    }

    /// Scroll the detail modal body one wrapped line up.
    pub fn mailbox_detail_scroll_up(&mut self) {
        self.mailbox_detail_scroll = self.mailbox_detail_scroll.saturating_sub(1);
    }

    pub fn cycle_focus_back(&mut self) {
        self.focused_pane = self.focused_pane.prev();
    }

    pub fn has_pending_approvals(&self) -> bool {
        !self.pending_approvals.is_empty()
    }

    pub fn enter_approvals_modal(&mut self) {
        if self.pending_approvals.is_empty() {
            return;
        }
        self.previous_stage = self.stage;
        self.stage = Stage::ApprovalsModal;
        self.selected_approval = 0;
        self.approval_error = None;
    }

    pub fn close_approvals_modal(&mut self) {
        self.stage = self.previous_stage;
        self.approval_error = None;
    }

    pub fn cycle_approval_next(&mut self) {
        if self.pending_approvals.is_empty() {
            return;
        }
        self.selected_approval = (self.selected_approval + 1) % self.pending_approvals.len();
    }

    pub fn cycle_approval_prev(&mut self) {
        if self.pending_approvals.is_empty() {
            return;
        }
        self.selected_approval = if self.selected_approval == 0 {
            self.pending_approvals.len() - 1
        } else {
            self.selected_approval - 1
        };
    }

    pub fn focused_approval(&self) -> Option<&Approval> {
        self.pending_approvals.get(self.selected_approval)
    }

    /// Replace the pending-approvals list. Closes the modal when
    /// the queue empties (no row to act on); preserves the modal
    /// otherwise but clamps `selected_approval` into range so an
    /// approval resolved out-of-band doesn't leave us pointing at
    /// a stale index.
    pub fn replace_approvals(&mut self, approvals: Vec<Approval>) {
        self.pending_approvals = approvals;
        if self.pending_approvals.is_empty() {
            if matches!(self.stage, Stage::ApprovalsModal) {
                self.close_approvals_modal();
            }
            self.selected_approval = 0;
        } else if self.selected_approval >= self.pending_approvals.len() {
            self.selected_approval = self.pending_approvals.len() - 1;
        }
    }

    /// Apply a decision to the focused approval via the injected
    /// decider. The decider routes through `teamctl approve|deny`
    /// in production; tests inject a recorder. On success the row
    /// gets removed from the local `pending_approvals` snapshot
    /// optimistically — the next `refresh_approvals` will reconcile
    /// against the broker.
    pub fn apply_decision<D: ApprovalDecider>(&mut self, decider: &D, kind: Decision, note: &str) {
        let Some(approval) = self.focused_approval().cloned() else {
            return;
        };
        match decider.decide(&self.team.root, approval.id, kind, note) {
            Ok(()) => {
                self.pending_approvals.retain(|a| a.id != approval.id);
                self.approval_error = None;
                if self.pending_approvals.is_empty() {
                    self.close_approvals_modal();
                } else if self.selected_approval >= self.pending_approvals.len() {
                    self.selected_approval = self.pending_approvals.len() - 1;
                }
            }
            Err(err) => {
                self.approval_error = Some(err.to_string());
            }
        }
    }

    /// Open the compose modal for the focused agent (if any). The
    /// `@` chord. No-op when no agent is focused.
    pub fn enter_compose_dm_for_focused(&mut self) {
        let Some(info) = self
            .selected_agent
            .and_then(|i| self.team.agents.get(i))
            .cloned()
        else {
            return;
        };
        self.previous_stage = self.stage;
        self.stage = Stage::ComposeModal;
        self.compose_target = Some(ComposeTarget::Dm {
            agent_id: info.id.clone(),
            project_id: info.project.clone(),
        });
        self.compose_editor = Editor::default();
        self.compose_error = None;
    }

    /// Open the compose modal targeting the project's `all`
    /// channel — the broadcast wire. The `!` chord. PR-UI-5 ships
    /// with channel scoping limited to `all` (the Wire tab is the
    /// only channel context the mailbox pane currently surfaces);
    /// PR-UI-6's mailbox UI work will widen the scope to per-channel
    /// targets when individual channels become first-class in the
    /// pane.
    pub fn enter_compose_broadcast(&mut self) {
        let project_id = self
            .selected_agent
            .and_then(|i| self.team.agents.get(i))
            .map(|a| a.project.clone())
            .or_else(|| self.team.agents.first().map(|a| a.project.clone()));
        let Some(project_id) = project_id else {
            return;
        };
        let channel_id = format!("{project_id}:all");
        self.previous_stage = self.stage;
        self.stage = Stage::ComposeModal;
        self.compose_target = Some(ComposeTarget::Broadcast {
            channel_id,
            project_id,
        });
        self.compose_editor = Editor::default();
        self.compose_error = None;
    }

    pub fn close_compose_modal(&mut self) {
        self.stage = self.previous_stage;
        self.compose_target = None;
        self.compose_editor = Editor::default();
        self.compose_error = None;
        // T-32: ensure the attach overlay state can't survive a
        // close-and-reopen of the modal.
        self.compose_attach_input_open = false;
        self.compose_attach_buffer.clear();
    }

    /// Send the current compose body via the injected message
    /// sender. Routes through `teamctl send|broadcast` in
    /// production; tests inject a recorder. Closes the modal +
    /// triggers a mailbox refresh on success; surfaces error
    /// inline on failure.
    pub fn apply_send<S: MessageSender, M: MailboxSource>(
        &mut self,
        sender: &S,
        mailbox_source: &M,
    ) {
        let Some(target) = self.compose_target.clone() else {
            return;
        };
        let body = self.compose_editor.body();
        if body.is_empty() {
            self.compose_error = Some("body is empty".into());
            return;
        }
        let result = match &target {
            ComposeTarget::Dm { agent_id, .. } => sender.send_dm(&self.team.root, agent_id, &body),
            ComposeTarget::Broadcast { channel_id, .. } => {
                sender.broadcast(&self.team.root, channel_id, &body)
            }
        };
        match result {
            Ok(()) => {
                self.close_compose_modal();
                // Refresh the mailbox so the just-sent row appears
                // in the relevant tab on the next paint.
                refresh_mailbox(self, mailbox_source);
            }
            Err(err) => {
                self.compose_error = Some(err.to_string());
            }
        }
    }

    pub fn dismiss_splash(&mut self) {
        if matches!(self.stage, Stage::Splash) {
            self.stage = Stage::Triptych;
            self.previous_stage = Stage::Triptych;
        }
    }

    pub fn cycle_focus(&mut self) {
        self.focused_pane = self.focused_pane.next();
    }

    /// Move roster selection up by one — wraps at the top. No-op
    /// when the team is empty. Does not change `focused_pane`.
    /// Resets mailbox buffers when the resulting agent id differs
    /// from the prior selection — switching agents should start the
    /// operator at the head of fresh traffic.
    pub fn select_prev(&mut self) {
        if self.team.agents.is_empty() {
            self.selected_agent = None;
            return;
        }
        let prior = self.selected_agent_id();
        self.selected_agent = Some(match self.selected_agent {
            None | Some(0) => self.team.agents.len() - 1,
            Some(i) => i - 1,
        });
        if prior != self.selected_agent_id() {
            self.mailbox.reset();
        }
    }

    /// Move roster selection down by one — wraps at the bottom.
    /// No-op when the team is empty.
    pub fn select_next(&mut self) {
        if self.team.agents.is_empty() {
            self.selected_agent = None;
            return;
        }
        let prior = self.selected_agent_id();
        self.selected_agent = Some(match self.selected_agent {
            None => 0,
            Some(i) => (i + 1) % self.team.agents.len(),
        });
        if prior != self.selected_agent_id() {
            self.mailbox.reset();
        }
    }

    /// `<project>:<agent>` of the currently selected agent, if any.
    pub fn selected_agent_id(&self) -> Option<String> {
        self.selected_agent
            .and_then(|i| self.team.agents.get(i))
            .map(|a| a.id.clone())
    }

    pub fn enter_quit_confirm(&mut self) {
        self.previous_stage = self.stage;
        self.stage = Stage::QuitConfirm;
    }

    pub fn cancel_quit(&mut self) {
        self.stage = self.previous_stage;
    }

    pub fn confirm_quit(&mut self) {
        self.running = false;
    }

    /// Replace the team snapshot. Preserves the current selection
    /// when the agent at that index still exists; otherwise resets
    /// to the first agent (or `None` for an empty team). Resets the
    /// mailbox buffers when the resulting agent id differs from the
    /// prior selection — same agent-changed contract as
    /// `select_next` / `select_prev`.
    pub fn replace_team(&mut self, team: TeamSnapshot) {
        let prior_id = self.selected_agent_id();
        self.team = team;
        self.selected_agent = match (prior_id.clone(), self.team.agents.is_empty()) {
            (_, true) => None,
            (Some(id), false) => self.team.agents.iter().position(|a| a.id == id).or(Some(0)),
            (None, false) => Some(0),
        };
        if prior_id != self.selected_agent_id() {
            self.mailbox.reset();
        }
    }

    /// Return the focused agent's tmux session name, if any. Used
    /// by the run loop to know which session to capture.
    pub fn focused_session(&self) -> Option<&str> {
        self.selected_agent
            .and_then(|i| self.team.agents.get(i))
            .map(|a| a.tmux_session.as_str())
    }

    /// Tmux session that stream-keys mode should target. Cell 0 of
    /// the detail-pane split layout is always the focused agent;
    /// cells 1..N are the entries in `detail_splits`. When the
    /// operator has focused a non-zero split, route stream-keys to
    /// that split's agent — that's the cell visually showing as the
    /// focus ring, so it's the one the operator expects to type into.
    pub fn stream_target_session(&self) -> Option<String> {
        if self.detail_splits.is_empty() || self.selected_split == 0 {
            return self.focused_session().map(|s| s.to_string());
        }
        let split_idx = self.selected_split - 1;
        let agent_id = self.detail_splits.get(split_idx).map(|(id, _)| id)?;
        self.team
            .agents
            .iter()
            .find(|a| &a.id == agent_id)
            .map(|a| a.tmux_session.clone())
    }

    /// Enter stream-keys mode. No-op unless an agent is selected —
    /// without a target session there's nothing to forward to.
    /// Caller is responsible for the focused-pane gate (entry chord
    /// only fires from `focused_pane == Pane::Detail`).
    pub fn enter_stream_keys(&mut self) {
        if self.stream_target_session().is_none() {
            return;
        }
        self.previous_stage = self.stage;
        self.stage = Stage::StreamKeys;
    }

    /// Exit stream-keys mode and return to whichever stage opened it.
    /// `Esc` is the only exit chord per the issue's recommendation —
    /// every other key (including `Ctrl+C`) forwards to the agent.
    pub fn exit_stream_keys(&mut self) {
        self.stage = self.previous_stage;
    }

    /// Replace the detail buffer, clipped at the recent-line cap.
    pub fn set_detail_buffer(&mut self, lines: Vec<String>) {
        let len = lines.len();
        let start = len.saturating_sub(MAX_DETAIL_LINES);
        self.detail_buffer = lines[start..].to_vec();
    }
}

impl Default for App {
    fn default() -> Self {
        Self::new()
    }
}

/// Refresh the team snapshot + the focused agent's pane capture +
/// the mailbox tabs (PR-UI-3). Pulled out so tests can drive a
/// single tick deterministically against `MockPaneSource` and
/// `MockMailboxSource` without going through the event loop.
pub fn refresh<P: PaneSource, M: MailboxSource, A: ApprovalSource>(
    app: &mut App,
    pane_source: &P,
    mailbox_source: &M,
    approval_source: &A,
) {
    if let Ok(Some(snapshot)) = TeamSnapshot::discover_and_load() {
        app.replace_team(snapshot);
    }
    if let Some(session) = app.focused_session().map(|s| s.to_string()) {
        if let Ok(lines) = pane_source.capture(&session) {
            app.set_detail_buffer(lines);
        }
    } else {
        app.detail_buffer.clear();
    }
    refresh_mailbox(app, mailbox_source);
    refresh_approvals(app, approval_source);
    app.last_refresh = Instant::now();
    app.last_pane_refresh = Instant::now();
}

/// Approvals-only refresh. Extracted on the same shape as
/// `refresh_mailbox` — PR-UI-5+ can call it on its own cadence
/// (e.g. in response to a `notify` signal) without re-running the
/// heavier paths. Errors degrade to "no pending" so the stripe
/// just hides on a transient broker read failure.
pub fn refresh_approvals<A: ApprovalSource>(app: &mut App, approval_source: &A) {
    let approvals = approval_source.pending().unwrap_or_default();
    app.replace_approvals(approvals);
}

/// Mailbox-only refresh — extracted so PR-UI-4+ can call it on its
/// own cadence (e.g. in response to a broker INSERT signal) without
/// re-running the heavier compose + tmux capture path. PR-UI-3
/// just calls it from the main `refresh` once per tick.
pub fn refresh_mailbox<M: MailboxSource>(app: &mut App, mailbox_source: &M) {
    let Some(agent_id) = app.selected_agent_id() else {
        // No agent focused → nothing to fetch. Buffers were already
        // reset on selection change so the empty-state hint shows.
        return;
    };
    let project_id = app
        .selected_agent
        .and_then(|i| app.team.agents.get(i))
        .map(|a| a.project.clone())
        .unwrap_or_default();
    if let Ok(batch) = mailbox_source.inbox(&agent_id, app.mailbox.inbox_after) {
        app.mailbox.extend(MailboxTab::Inbox, batch);
    }
    if let Ok(batch) = mailbox_source.sent(&agent_id, app.mailbox.sent_after) {
        app.mailbox.extend(MailboxTab::Sent, batch);
    }
    if let Ok(batch) = mailbox_source.channel_feed(&agent_id, app.mailbox.channel_after) {
        app.mailbox.extend(MailboxTab::Channel, batch);
    }
    if let Ok(batch) = mailbox_source.wire(&project_id, app.mailbox.wire_after) {
        app.mailbox.extend(MailboxTab::Wire, batch);
    }
}

pub fn run<B: Backend>(terminal: &mut Terminal<B>) -> Result<()> {
    let mut app = App::new();
    let pane_source = TmuxPaneSource;
    let decider = CliApprovalDecider;
    let sender = CliMessageSender;
    // #386: wrap the blocking `tmux send-keys` round-trip so it runs on
    // a background thread — the render/event loop enqueues each keystroke
    // in ~µs and stays free to observe the next key and redraw, instead
    // of blocking ~3ms per key on the inline subprocess.
    let key_sender = AsyncKeySender::new(TmuxKeySender);
    let pane_resizer = crate::pane_resize::TmuxPaneResizer;
    // First refresh resolves the team root; only then can we
    // bring up the file-watcher, which keys on `<root>/state/`.
    refresh_with_default_sources(&mut app, &pane_source);
    let mut watch = Watch::try_new(&app.team.root.join("state"));
    while app.running {
        // T-131 PR-4: refresh the render-tick clock so the mailbox
        // relative-time indicator reads a fresh `now_secs` each
        // draw. Keeping this on App (not in render) makes render a
        // pure function — tests can pin time deterministically.
        app.now_secs = chrono::Utc::now().timestamp() as f64;
        terminal.draw(|f| draw(f, &app))?;
        // T-199: after every frame, push the focused agent's inner
        // tmux pane to match teamctl-ui's Detail rect so claude
        // reflows when the operator resizes the host terminal (or
        // when focus switches to a different agent whose pane was
        // last sized for a different layout). The cache inside
        // `sync_focused_pane_size_to` keeps this to one HashMap
        // lookup per frame in the no-op case.
        let term_sz = terminal.size()?;
        let term_area = ratatui::layout::Rect::new(0, 0, term_sz.width, term_sz.height);
        sync_focused_pane_size_to(&mut app, term_area, &pane_resizer);
        if event::poll(POLL_INTERVAL)? {
            // The mailbox source for handle_event mirrors the
            // refresh path; the same db_path key avoids divergence
            // between read + write fanout.
            let db_path = app.team.root.join("state/mailbox.db");
            let mailbox_source = BrokerMailboxSource::new(db_path);
            handle_event(
                &mut app,
                event::read()?,
                &decider,
                &sender,
                &mailbox_source,
                &key_sender,
            );
        }
        if matches!(app.stage, Stage::Splash) && app.splash_started.elapsed() >= SPLASH_AUTO_DISMISS
        {
            app.dismiss_splash();
        }
        // Refresh on either (a) deadline elapsed or (b) the
        // notify-watcher said the broker DB changed. The watcher
        // shaves the typical refresh latency from ~1s to ~50ms when
        // the platform supports it; on platforms without notify
        // support `take_dirty` always returns false and the
        // deadline path is the only trigger (PR-UI-3 behaviour).
        let dirty = watch.take_dirty();
        if dirty || app.last_refresh.elapsed() >= REFRESH_INTERVAL {
            let prior_root = app.team.root.clone();
            refresh_with_default_sources(&mut app, &pane_source);
            // Team root drifted (operator launched in a different
            // tree) → swap the watcher to the new state dir.
            if app.team.root != prior_root {
                watch = Watch::try_new(&app.team.root.join("state"));
            }
        } else if app.last_pane_refresh.elapsed() >= PANE_REFRESH_INTERVAL {
            // Between full refreshes, keep the detail view live by
            // re-capturing just the focused agent's pane.
            recapture_focused_pane(&mut app, &pane_source);
        }
    }
    Ok(())
}

/// Fast-cadence re-capture of only the focused agent's tmux pane, so
/// the detail view tracks the live session between the heavier 1s full
/// refreshes. A single `capture-pane` subprocess — no team / mailbox /
/// approval / sysinfo work. No-op when no agent is focused.
fn recapture_focused_pane<P: PaneSource>(app: &mut App, pane_source: &P) {
    if let Some(session) = app.focused_session().map(|s| s.to_string()) {
        if let Ok(lines) = pane_source.capture(&session) {
            app.set_detail_buffer(lines);
        }
    }
    app.last_pane_refresh = Instant::now();
}

/// T-199: push the focused agent's inner tmux pane to match the
/// Detail rect teamctl-ui will draw into. No-op when:
///
/// - no agent is focused (nothing to size);
/// - the active main layout isn't Triptych (Wall / MailboxFirst
///   render differently and aren't in scope for this fix; flagged
///   as follow-up surfaces in #199);
/// - the Detail rect is degenerate (zero width or height — the
///   helper returns `None` and we leave the pane alone);
/// - the cached size for this session already matches.
///
/// The cache lives on `App` (`last_synced_pane_sizes`) so the
/// common case (no resize, focused on same agent) is a HashMap
/// lookup, not a subprocess spawn.
pub fn sync_focused_pane_size_to<R: crate::pane_resize::PaneResizer>(
    app: &mut App,
    total_area: ratatui::layout::Rect,
    resizer: &R,
) {
    if !matches!(app.layout, MainLayout::Triptych) {
        return;
    }
    let Some(detail) =
        crate::pane_resize::triptych_detail_area(total_area, app.has_pending_approvals())
    else {
        return;
    };
    let Some(session) = app.focused_session().map(|s| s.to_string()) else {
        return;
    };
    // `render_detail` wraps the captured pane in a bordered block with a
    // top title (Borders::ALL + `.title(...)`), so it draws into an inner
    // rect 2 cols and 2 rows smaller than `detail`. Size the tmux pane to
    // that INNER area — sizing to the outer rect makes the agent reflow to
    // N rows while we only render N-2, leaving an empty gap at the bottom
    // and nudging the first line down a row.
    let inner_w = detail.width.saturating_sub(2);
    let inner_h = detail.height.saturating_sub(2);
    if inner_w == 0 || inner_h == 0 {
        return;
    }
    let target = (inner_w, inner_h);
    if !crate::pane_resize::should_sync(&app.last_synced_pane_sizes, &session, target) {
        return;
    }
    resizer.resize(&session, target.0, target.1);
    app.last_synced_pane_sizes.insert(session, target);
}

/// Build the production `BrokerMailboxSource` + `BrokerApprovalSource`
/// from the current team root and run a refresh with all three
/// default sources. Lives here (rather than inline in `run`) so
/// the team-root → DB-path derivation has one home.
fn refresh_with_default_sources<P: PaneSource>(app: &mut App, pane_source: &P) {
    if let Ok(Some(snapshot)) = TeamSnapshot::discover_and_load() {
        app.replace_team(snapshot);
    }
    let db_path = app.team.root.join("state/mailbox.db");
    let mailbox_source = BrokerMailboxSource::new(db_path.clone());
    let approval_source = BrokerApprovalSource::new(db_path);
    if let Some(session) = app.focused_session().map(|s| s.to_string()) {
        if let Ok(lines) = pane_source.capture(&session) {
            app.set_detail_buffer(lines);
        }
    } else {
        app.detail_buffer.clear();
    }
    refresh_mailbox(app, &mailbox_source);
    refresh_approvals(app, &approval_source);
    // T-209: refresh the live CPU/RAM numbers on the same 1-second
    // cadence as the rest of App. `refresh_cpu_usage` + `refresh_memory`
    // are the minimal pair — `refresh_all` would also probe disks,
    // networks, processes, and components, none of which the status
    // bar shows. Sub-millisecond cost on modern hardware.
    app.sysinfo.refresh_cpu_usage();
    app.sysinfo.refresh_memory();
    app.last_refresh = Instant::now();
    app.last_pane_refresh = Instant::now();
}

pub fn draw(f: &mut Frame<'_>, app: &App) {
    let area = f.area();
    match app.stage {
        Stage::Splash => splash::draw(f, app),
        Stage::Triptych => draw_main(f, area, app),
        // T-108: stream-keys reuses the Triptych render path — the
        // detail pane carries the visual indicator (border + title +
        // statusline shift) via the `app.stage == StreamKeys` branch
        // in those widgets. No separate modal draw.
        Stage::StreamKeys => draw_main(f, area, app),
        Stage::QuitConfirm => {
            draw_main(f, area, app);
            draw_quit_confirm(f, area);
        }
        Stage::ApprovalsModal => {
            draw_main(f, area, app);
            draw_approvals_modal(f, area, app);
        }
        Stage::ComposeModal => {
            draw_main(f, area, app);
            draw_compose_modal(f, area, app);
        }
        Stage::HelpOverlay => {
            draw_main(f, area, app);
            let buf = f.buffer_mut();
            render_help_overlay(area, buf, app);
        }
        Stage::Tutorial => {
            draw_main(f, area, app);
            let buf = f.buffer_mut();
            render_tutorial(area, buf, app);
        }
        Stage::MailboxDetailModal => {
            draw_main(f, area, app);
            let buf = f.buffer_mut();
            render_mailbox_detail_modal(area, buf, app);
        }
    }
}

fn render_help_overlay(area: Rect, buf: &mut Buffer, app: &App) {
    let popup_w = 70u16.min(area.width.saturating_sub(4));
    let popup_h = 24u16.min(area.height.saturating_sub(2));
    let popup = centered_rect(popup_w, popup_h, area);
    Clear.render(popup, buf);
    let block = Block::default()
        .title("help · ? to close")
        .borders(Borders::ALL)
        .border_style(Style::default().fg(app.capabilities.accent()));
    let inner = block.inner(popup);
    block.render(popup, buf);
    let muted = Style::default().fg(app.capabilities.muted());
    let bold = Style::default().add_modifier(Modifier::BOLD);
    let mut lines: Vec<ratatui::text::Line<'_>> = Vec::new();
    for group in crate::help::ALL_GROUPS {
        lines.push(ratatui::text::Line::styled(group.title, bold));
        for b in group.bindings {
            lines.push(ratatui::text::Line::raw(format!(
                "  {:<22}  {}",
                b.chord, b.description
            )));
        }
        lines.push(ratatui::text::Line::styled("", muted));
    }
    Paragraph::new(lines).render(inner, buf);
}

/// T-131 PR-3: detail modal — full message body (wrapped + scrollable)
/// plus sender / recipient / kind / absolute timestamp / transport /
/// message id. Renders only when `app.mailbox_detail_modal` is `Some`;
/// otherwise a silent no-op so a render race during close-tearing
/// can't crash. The snapshot is the source of truth for everything
/// the modal shows — the underlying buffer is NOT consulted, which
/// is the variant-(a) snapshot-at-open contract.
fn render_mailbox_detail_modal(area: Rect, buf: &mut Buffer, app: &App) {
    let Some(row) = app.mailbox_detail_modal.as_ref() else {
        return;
    };
    let popup_w = 80u16.min(area.width.saturating_sub(4));
    let popup_h = 24u16.min(area.height.saturating_sub(2));
    let popup = centered_rect(popup_w, popup_h, area);
    Clear.render(popup, buf);
    let title = format!("MESSAGE · id {} · Esc/q to close", row.id);
    let block = Block::default()
        .title(title)
        .borders(Borders::ALL)
        .border_style(Style::default().fg(app.capabilities.accent()));
    let inner = block.inner(popup);
    block.render(popup, buf);
    if inner.height == 0 {
        return;
    }

    // Metadata header (5 lines) + 1 blank separator + body fills the
    // rest. Fixed metadata height keeps the body scroll math simple:
    // body height = inner.height - 6, and Paragraph::scroll((n, 0))
    // hides the first n wrapped lines.
    const META_LINES: u16 = 6;
    let meta_h = META_LINES.min(inner.height);
    let body_h = inner.height.saturating_sub(meta_h);
    let meta_area = Rect {
        x: inner.x,
        y: inner.y,
        width: inner.width,
        height: meta_h,
    };
    let body_area = Rect {
        x: inner.x,
        y: inner.y + meta_h,
        width: inner.width,
        height: body_h,
    };

    // Format the absolute timestamp in UTC with timezone (matches
    // issue's "absolute, with timezone"). UTC is unambiguous across
    // operator timezones — the local-time variant would need extra
    // care for the dogfood team's mixed-locale operators.
    let ts = chrono::DateTime::<chrono::Utc>::from_timestamp(
        row.sent_at as i64,
        ((row.sent_at.fract() * 1_000_000_000.0) as u32).min(999_999_999),
    )
    .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
    .unwrap_or_else(|| "—".to_string());

    let muted = Style::default().fg(app.capabilities.muted());
    let meta_lines = vec![
        ratatui::text::Line::raw(format!("from:      {}", row.sender)),
        ratatui::text::Line::raw(format!("to:        {}", row.recipient)),
        ratatui::text::Line::raw(format!("kind:      {}", crate::mailbox::kind_label(row))),
        ratatui::text::Line::raw(format!("time:      {ts}")),
        ratatui::text::Line::raw(format!(
            "transport: {}",
            crate::mailbox::transport_label(row)
        )),
        ratatui::text::Line::styled("", muted),
    ];
    Paragraph::new(meta_lines)
        .style(Style::default())
        .render(meta_area, buf);

    // Body: wrap to inner width, scroll by the operator's offset.
    // No upper-bound clamp on scroll here — Paragraph's scroll
    // semantics tolerate values past the end (renders empty), and
    // the j/k handlers' saturating_add caps at u16::MAX which is
    // far beyond any realistic body length.
    Paragraph::new(row.text.clone())
        .wrap(Wrap { trim: false })
        .scroll((app.mailbox_detail_scroll, 0))
        .render(body_area, buf);
}

fn render_tutorial(area: Rect, buf: &mut Buffer, app: &App) {
    let popup_w = 64u16.min(area.width.saturating_sub(4));
    let popup_h = 14u16.min(area.height.saturating_sub(2));
    let popup = centered_rect(popup_w, popup_h, area);
    Clear.render(popup, buf);
    let total = crate::onboarding::STEPS.len();
    let i = app.tutorial_step.min(total.saturating_sub(1));
    let step = &crate::onboarding::STEPS[i];
    let block = Block::default()
        .title(format!("tutorial · {}/{total}", i + 1))
        .borders(Borders::ALL)
        .border_style(Style::default().fg(app.capabilities.accent()));
    let inner = block.inner(popup);
    block.render(popup, buf);
    let muted = Style::default().fg(app.capabilities.muted());
    let lines = vec![
        ratatui::text::Line::styled(step.heading, Style::default().add_modifier(Modifier::BOLD)),
        ratatui::text::Line::raw(""),
        ratatui::text::Line::raw(step.body),
        ratatui::text::Line::raw(""),
        ratatui::text::Line::styled("any key next  ·  k / ↑ / p back  ·  Esc skip", muted),
    ];
    // T-074 bug 5: tutorial bodies are prose paragraphs, not pre-
    // formatted lines — clip-on-overflow leaves them looking truncated
    // on common (≤80 col) terminals. Soft-wrap with `trim: true` so
    // long step descriptions reflow into the modal width instead of
    // dropping off the right edge.
    Paragraph::new(lines)
        .wrap(ratatui::widgets::Wrap { trim: true })
        .render(inner, buf);
}

fn draw_main(f: &mut Frame<'_>, area: Rect, app: &App) {
    // T-209: bottom of the screen is now a two-row footer —
    // existing keybindings statusline on top, new status bar
    // (cwd-left + CPU/RAM-right; T-212 will fill the center slot
    // per coordination with kian) below. Both 1 row tall.
    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Min(3),
            Constraint::Length(1), // existing keybindings statusline
            Constraint::Length(1), // T-209 bottom status bar
        ])
        .split(area);
    let buf = f.buffer_mut();
    match app.layout {
        crate::triptych::MainLayout::Triptych => {
            triptych::Triptych { app }.render(chunks[0], buf);
        }
        crate::triptych::MainLayout::Wall => {
            layouts::Wall { app }.render(chunks[0], buf);
        }
        crate::triptych::MainLayout::MailboxFirst => {
            layouts::MailboxFirst { app }.render(chunks[0], buf);
        }
    }
    statusline::Statusline { app }.render(chunks[1], buf);
    status_bar::StatusBar { app }.render(chunks[2], buf);
}

fn draw_approvals_modal(f: &mut Frame<'_>, area: Rect, app: &App) {
    let buf = f.buffer_mut();
    render_approvals_modal(area, buf, app);
}

fn draw_compose_modal(f: &mut Frame<'_>, area: Rect, app: &App) {
    let buf = f.buffer_mut();
    render_compose_modal(area, buf, app);
}

fn render_compose_picker_body(inner: Rect, buf: &mut Buffer, app: &App) {
    let muted = Style::default().fg(app.capabilities.muted());
    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Min(1),
            Constraint::Length(1),
            Constraint::Length(1),
        ])
        .split(inner);
    let lines: Vec<ratatui::text::Line<'_>> = if app.team.channels.is_empty() {
        vec![ratatui::text::Line::styled(
            "(no channels declared in team-compose)",
            muted,
        )]
    } else {
        app.team
            .channels
            .iter()
            .enumerate()
            .map(|(i, ch)| {
                let label = format!("  #{}  ({})", ch.name, ch.project_id);
                let style = if i == app.compose_picker_index {
                    Style::default()
                        .fg(app.capabilities.accent())
                        .add_modifier(Modifier::REVERSED)
                } else {
                    Style::default()
                };
                ratatui::text::Line::styled(label, style)
            })
            .collect()
    };
    Paragraph::new(lines).render(chunks[0], buf);
    Paragraph::new("pick a channel to broadcast to")
        .style(muted)
        .render(chunks[1], buf);
    Paragraph::new("Enter pick · j/k navigate · Esc cancel")
        .style(muted)
        .render(chunks[2], buf);
}

fn render_compose_modal(area: Rect, buf: &mut Buffer, app: &App) {
    let popup_w = 80u16.min(area.width.saturating_sub(4));
    let popup_h = 16u16.min(area.height.saturating_sub(2));
    let popup = centered_rect(popup_w, popup_h, area);
    Clear.render(popup, buf);
    let title = app
        .compose_target
        .as_ref()
        .map(|t| t.title(&app.team))
        .unwrap_or_else(|| "→ ?".into());
    let block = Block::default()
        .title(title)
        .borders(Borders::ALL)
        .border_style(Style::default().fg(app.capabilities.accent()));
    let inner = block.inner(popup);
    block.render(popup, buf);

    if inner.height < 3 {
        return;
    }
    // PR-UI-6: when the broadcast picker is open we render a
    // channel-list inside the modal instead of the editor; the
    // editor footer stays so operators see the same layout.
    if app.compose_picker_open {
        render_compose_picker_body(inner, buf, app);
        return;
    }
    if app.compose_attach_input_open {
        render_compose_attach_input(inner, buf, app);
        return;
    }
    // Reserve the bottom two rows: an error line (rendered when
    // present, blank otherwise) and the footer with key hints.
    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Min(1),    // editor body
            Constraint::Length(1), // error / status
            Constraint::Length(1), // footer
        ])
        .split(inner);

    // Body — render lines with a `▏` cursor marker on the active
    // row when in Insert. Skip cursor cell in Normal/Ex modes so
    // the operator's eye finds the row by row context, not a
    // blinking caret.
    let muted = Style::default().fg(app.capabilities.muted());
    let body_lines: Vec<ratatui::text::Line<'_>> = app
        .compose_editor
        .lines
        .iter()
        .enumerate()
        .map(|(row, line)| {
            if row == app.compose_editor.cursor_row
                && app.compose_editor.mode == crate::compose::VimMode::Insert
            {
                let col = app.compose_editor.cursor_col.min(line.len());
                let (head, tail) = line.split_at(col);
                ratatui::text::Line::from(vec![
                    ratatui::text::Span::raw(head.to_string()),
                    ratatui::text::Span::styled(
                        "▏",
                        Style::default().fg(app.capabilities.accent()),
                    ),
                    ratatui::text::Span::raw(tail.to_string()),
                ])
            } else {
                ratatui::text::Line::raw(line.clone())
            }
        })
        .collect();
    Paragraph::new(body_lines).render(chunks[0], buf);

    let error_line = match (&app.compose_error, app.compose_editor.mode) {
        (Some(e), _) => format!("error: {e}"),
        (None, crate::compose::VimMode::Ex) => format!(":{}", app.compose_editor.ex_buffer),
        (None, crate::compose::VimMode::Normal) => "-- NORMAL --".into(),
        (None, crate::compose::VimMode::Insert) => "-- INSERT --".into(),
    };
    let style = if app.compose_error.is_some() {
        Style::default().fg(app.capabilities.accent())
    } else {
        muted
    };
    Paragraph::new(error_line)
        .style(style)
        .render(chunks[1], buf);

    Paragraph::new("Esc Enter send · Esc Esc cancel · Tab attach")
        .style(muted)
        .render(chunks[2], buf);
}

/// T-32: render the path-input overlay. Single-line buffer + a
/// caret marker, with hints for confirm/cancel. Mirrors the picker
/// overlay's layout (body / status line / footer) so the modal's
/// vertical rhythm doesn't shift between the two overlays.
fn render_compose_attach_input(inner: Rect, buf: &mut Buffer, app: &App) {
    let muted = Style::default().fg(app.capabilities.muted());
    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Min(1),
            Constraint::Length(1),
            Constraint::Length(1),
        ])
        .split(inner);
    let line = ratatui::text::Line::from(vec![
        ratatui::text::Span::raw(format!("path: {}", app.compose_attach_buffer)),
        ratatui::text::Span::styled("▏", Style::default().fg(app.capabilities.accent())),
    ]);
    Paragraph::new(line).render(chunks[0], buf);
    Paragraph::new("type or paste an absolute path; the agent reads it via the broker")
        .style(muted)
        .render(chunks[1], buf);
    Paragraph::new("Enter confirm · Esc cancel")
        .style(muted)
        .render(chunks[2], buf);
}

fn render_approvals_modal(area: Rect, buf: &mut Buffer, app: &App) {
    let popup_w = 80u16.min(area.width.saturating_sub(4));
    let popup_h = 18u16.min(area.height.saturating_sub(2));
    let popup = centered_rect(popup_w, popup_h, area);
    Clear.render(popup, buf);
    let n = app.pending_approvals.len();
    let i = app.selected_approval.min(n.saturating_sub(1));
    let title = format!("approvals · {}/{n}", i + 1);
    let block = Block::default()
        .title(title)
        .borders(Borders::ALL)
        .border_style(Style::default().fg(app.capabilities.accent()));
    let inner = block.inner(popup);
    block.render(popup, buf);

    let muted = Style::default().fg(app.capabilities.muted());
    let bold = Style::default().add_modifier(Modifier::BOLD);

    let Some(a) = app.focused_approval() else {
        Paragraph::new("(no pending approvals)")
            .style(muted)
            .alignment(Alignment::Center)
            .render(inner, buf);
        return;
    };

    let mut lines: Vec<ratatui::text::Line<'_>> = vec![
        ratatui::text::Line::styled(format!("#{}  {}", a.id, a.action), bold),
        ratatui::text::Line::styled(
            format!("from: {}", crate::data::agent_label(&app.team, &a.agent_id)),
            muted,
        ),
        ratatui::text::Line::raw(""),
        ratatui::text::Line::raw(a.summary.clone()),
    ];
    if !a.payload_json.is_empty() && a.payload_json != "{}" {
        lines.push(ratatui::text::Line::raw(""));
        lines.push(ratatui::text::Line::styled("payload:", muted));
        for chunk in a.payload_json.lines().take(4) {
            lines.push(ratatui::text::Line::raw(chunk.to_string()));
        }
    }
    if let Some(err) = &app.approval_error {
        lines.push(ratatui::text::Line::raw(""));
        lines.push(ratatui::text::Line::styled(
            format!("error: {err}"),
            Style::default().fg(app.capabilities.accent()),
        ));
    }
    lines.push(ratatui::text::Line::raw(""));
    lines.push(ratatui::text::Line::styled(
        "[y] approve  ·  [Shift-N] deny  ·  [j/k] cycle  ·  [Esc] close",
        muted,
    ));
    Paragraph::new(lines).render(inner, buf);
}

fn draw_quit_confirm(f: &mut Frame<'_>, area: Rect) {
    let popup_w = 36u16.min(area.width.saturating_sub(2));
    let popup_h = 5u16.min(area.height.saturating_sub(2));
    let popup = centered_rect(popup_w, popup_h, area);
    let buf = f.buffer_mut();
    Clear.render(popup, buf);
    Paragraph::new("Quit teamctl-ui?  [y / n]")
        .alignment(Alignment::Center)
        .block(Block::default().borders(Borders::ALL).title("confirm"))
        .render(popup, buf);
}

fn centered_rect(w: u16, h: u16, area: Rect) -> Rect {
    let x = area.x + area.width.saturating_sub(w) / 2;
    let y = area.y + area.height.saturating_sub(h) / 2;
    Rect {
        x,
        y,
        width: w,
        height: h,
    }
}

pub fn handle_event<D: ApprovalDecider, S: MessageSender, M: MailboxSource, K: KeySender>(
    app: &mut App,
    ev: Event,
    decider: &D,
    sender: &S,
    mailbox_source: &M,
    key_sender: &K,
) {
    use crossterm::event::KeyModifiers;
    match ev {
        Event::Key(k) if k.kind == KeyEventKind::Press => match app.stage {
            Stage::Splash => app.dismiss_splash(),
            Stage::Triptych => match k.code {
                // T-131 PR-2: mailbox input-mode interception. When
                // the filter / search input is open, all keys route
                // to the active input buffer; everything else
                // (cursor keys, tab cycle, chord prefixes, even `q`
                // quit) is swallowed so a stray key can't trigger
                // unrelated behavior mid-edit. These MUST come first
                // — placed before the unguarded `Char('q')` quit
                // arm so typing `q` into the filter doesn't quit.
                KeyCode::Enter if app.mailbox_input_mode.is_some() => app.mailbox_input_confirm(),
                KeyCode::Esc if app.mailbox_input_mode.is_some() => app.mailbox_input_cancel(),
                KeyCode::Backspace if app.mailbox_input_mode.is_some() => {
                    app.mailbox_input_pop_char()
                }
                // Char arm is gated on "no modifier or Shift only" so
                // Ctrl / Alt / Meta + Char combos (e.g. the `Ctrl+W`
                // chord prefix, `Ctrl+C`) fall through to the swallow
                // arm below instead of landing literally in the filter
                // buffer — matches standard text-input UX (modifier
                // combos aren't typed as their literal char). qa #335
                // nit 2.
                KeyCode::Char(c)
                    if app.mailbox_input_mode.is_some()
                        && (k.modifiers.is_empty() || k.modifiers == KeyModifiers::SHIFT) =>
                {
                    app.mailbox_input_push_char(c)
                }
                _ if app.mailbox_input_mode.is_some() => {}

                // PR-UI-7 chord-prefix follow-ups MUST be tested
                // before unguarded `Char('q')` / `Char('o')` arms,
                // otherwise the no-modifier `q` quit would shadow
                // the `Ctrl+W q` close-split.
                KeyCode::Char('q') if app.pending_chord == Some(KeyCode::Char('w')) => {
                    app.pending_chord = None;
                    app.close_focused_split();
                }
                KeyCode::Char('o') if app.pending_chord == Some(KeyCode::Char('w')) => {
                    app.pending_chord = None;
                    if !app.detail_splits.is_empty() {
                        let keep = app.selected_split.min(app.detail_splits.len() - 1);
                        let kept = app.detail_splits.remove(keep);
                        app.detail_splits.clear();
                        app.detail_splits.push(kept);
                        app.selected_split = 0;
                    }
                }
                KeyCode::Char('q') if k.modifiers.is_empty() => app.enter_quit_confirm(),
                // PR-UI-4: `a` opens the approvals modal when there's
                // at least one pending row. No-op otherwise so the
                // chord doesn't surprise anyone hammering keys.
                KeyCode::Char('a') => app.enter_approvals_modal(),
                // PR-UI-5: `@` opens DM compose to focused agent.
                // PR-UI-6: `!` now opens the broadcast picker so
                // operators choose which channel to broadcast to,
                // not just the project's `all` wire.
                KeyCode::Char('@') => app.enter_compose_dm_for_focused(),
                KeyCode::Char('!') => app.enter_compose_broadcast_with_picker(),
                // PR-UI-7 chord-prefix: when there's at least one
                // detail split, `Ctrl+W` arms the chord-prefix
                // (the next key dispatches `q` close-split, `o`
                // close-others). Tested BEFORE the wall-layout
                // toggle below so the chord-prefix wins when
                // relevant. Both casings accepted because CapsLock
                // / Shift+Ctrl produce `Char('W')`; armed value is
                // normalised to lowercase so the follow-up arms
                // above can match a single literal.
                KeyCode::Char('w') | KeyCode::Char('W')
                    if k.modifiers.contains(KeyModifiers::CONTROL)
                        && !app.detail_splits.is_empty() =>
                {
                    app.pending_chord = Some(KeyCode::Char('w'))
                }
                // PR-UI-6: layout toggles. `Ctrl+W` for Wall when
                // there are no splits to chord on; `Ctrl+M` for
                // MailboxFirst (always). Both casings accepted —
                // see the chord-arm comment above.
                KeyCode::Char('w') | KeyCode::Char('W')
                    if k.modifiers.contains(KeyModifiers::CONTROL) =>
                {
                    app.toggle_wall_layout()
                }
                KeyCode::Char('m') | KeyCode::Char('M')
                    if k.modifiers.contains(KeyModifiers::CONTROL) =>
                {
                    app.toggle_mailbox_first_layout()
                }
                // PR-UI-7 splitscreen lift: `Ctrl+|` subdivides
                // vertically, `Ctrl+-` horizontally — vim/tmux
                // operators' muscle memory matches the visual.
                KeyCode::Char('|') if k.modifiers.contains(KeyModifiers::CONTROL) => {
                    app.add_detail_split_vertical()
                }
                KeyCode::Char('-') if k.modifiers.contains(KeyModifiers::CONTROL) => {
                    app.add_detail_split_horizontal()
                }
                // Vim window-motion `Ctrl+H/J/K/L` cycles between
                // splits when there's more than one. Both casings
                // accepted — see the Ctrl+W chord-arm comment above
                // for the CapsLock + Shift+Ctrl rationale.
                KeyCode::Char('h')
                | KeyCode::Char('H')
                | KeyCode::Char('k')
                | KeyCode::Char('K')
                    if k.modifiers.contains(KeyModifiers::CONTROL) =>
                {
                    app.cycle_split_prev()
                }
                KeyCode::Char('l')
                | KeyCode::Char('L')
                | KeyCode::Char('j')
                | KeyCode::Char('J')
                    if k.modifiers.contains(KeyModifiers::CONTROL) =>
                {
                    app.cycle_split_next()
                }
                // PR-UI-6 alias preserved for back-compat: `Ctrl+Q`
                // closes the focused split. PR-UI-7 also wires the
                // proper `Ctrl+W q` chord; both work. Both casings
                // accepted for the same reason as Ctrl+W/M.
                KeyCode::Char('q') | KeyCode::Char('Q')
                    if k.modifiers.contains(KeyModifiers::CONTROL) =>
                {
                    app.close_focused_split()
                }
                // T-108: `Ctrl+E` activates stream-keys mode when the
                // detail pane is focused — every subsequent keystroke
                // forwards to the agent's tmux pane. Gated on detail
                // focus so operators in the roster / mailbox don't
                // get pulled into stream-mode by a stray chord. Both
                // casings accepted for the CapsLock/Shift+Ctrl case
                // (same rationale as Ctrl+W/M arms above).
                KeyCode::Char('e') | KeyCode::Char('E')
                    if k.modifiers.contains(KeyModifiers::CONTROL)
                        && app.focused_pane == Pane::Detail =>
                {
                    app.enter_stream_keys()
                }
                // (chord-prefix follow-ups handled at top of arm
                // before unguarded letter-arms — see top of
                // `Stage::Triptych` match.)
                // PR-UI-7 help + tutorial chords. `?` opens help
                // overlay; `t` reopens tutorial. Both no-op if a
                // modifier is in flight (so `Shift+?` and `Ctrl+T`
                // don't double-bind).
                KeyCode::Char('?')
                    if k.modifiers.is_empty() || k.modifiers == KeyModifiers::SHIFT =>
                {
                    app.enter_help_overlay()
                }
                KeyCode::Char('t') if k.modifiers.is_empty() => app.enter_tutorial(),
                // PR-UI-4: Shift+Tab cycles panes backward. Some
                // terminals send `BackTab`, others send `Tab` with
                // SHIFT — handle both.
                KeyCode::BackTab => app.cycle_focus_back(),
                KeyCode::Tab if k.modifiers.contains(KeyModifiers::SHIFT) => app.cycle_focus_back(),
                // T-074 bug 6: Tab always cycles pane focus, never
                // mailbox tabs. Previously Tab routed into mailbox
                // tab-cycling when the mailbox pane was focused —
                // this stranded operators inside the mailbox with no
                // discoverable way out (Alireza's exact report). The
                // vim/tmux convention is "Tab moves between panes";
                // honour it across every pane uniformly.
                KeyCode::Tab => app.cycle_focus(),
                // T-124: mailbox sub-navigation uses Left/Right
                // arrows (more discoverable than the prior `[`/`]`
                // chord). Gated on the mailbox pane being focused
                // so the keys stay unsurprising elsewhere — Up/Down
                // remain free to scroll layout-specific lists, and
                // Left/Right have no other binding today.
                KeyCode::Right if app.focused_pane == Pane::Mailbox => app.cycle_mailbox_tab(),
                KeyCode::Left if app.focused_pane == Pane::Mailbox => app.cycle_mailbox_tab_back(),
                // PR-UI-6: in Wall layout, `j`/`k` (and arrows)
                // scroll the tile grid — same vim shape, different
                // surface. In Triptych roster focus they still
                // navigate the roster.
                KeyCode::Up | KeyCode::Char('k') if matches!(app.layout, MainLayout::Wall) => {
                    app.wall_scroll_up()
                }
                KeyCode::Down | KeyCode::Char('j') if matches!(app.layout, MainLayout::Wall) => {
                    app.wall_scroll_down()
                }
                // PR-UI-6: in MailboxFirst, `j`/`k` walk the
                // channel list.
                KeyCode::Up | KeyCode::Char('k')
                    if matches!(app.layout, MainLayout::MailboxFirst) =>
                {
                    app.select_prev_channel()
                }
                KeyCode::Down | KeyCode::Char('j')
                    if matches!(app.layout, MainLayout::MailboxFirst) =>
                {
                    app.select_next_channel()
                }
                // T-131 PR-1: mailbox row navigation when the
                // mailbox pane is focused. j/k mirror Vim; arrows
                // mirror every-day navigation. PageUp/PageDown jump a
                // screen; Home/End jump to ends. Per-tab cursor state
                // lives on `MailboxBuffers` and survives tab switches.
                // NB on layout precedence: the MailboxFirst-layout
                // arms above (j/k → channel walk) match first by
                // arm order and intentionally shadow these in that
                // layout — MailboxFirst's UX is channel-feed-centric,
                // not row-cursor-centric. These arms cover the
                // Triptych layout where mailbox is one focused pane
                // among three.
                KeyCode::Up | KeyCode::Char('k') if app.focused_pane == Pane::Mailbox => {
                    app.mailbox_cursor_up()
                }
                KeyCode::Down | KeyCode::Char('j') if app.focused_pane == Pane::Mailbox => {
                    app.mailbox_cursor_down()
                }
                KeyCode::PageUp if app.focused_pane == Pane::Mailbox => app.mailbox_page_up(),
                KeyCode::PageDown if app.focused_pane == Pane::Mailbox => app.mailbox_page_down(),
                KeyCode::Home if app.focused_pane == Pane::Mailbox => app.mailbox_cursor_home(),
                KeyCode::End if app.focused_pane == Pane::Mailbox => app.mailbox_cursor_end(),
                // T-131 PR-2: `f` opens the sender-substring filter
                // input on the active tab; `/` opens the body-search
                // input. Both gated on Pane::Mailbox so the keys stay
                // unsurprising in other panes. Once open, the
                // input-mode arms at the top of this match own the
                // keystrokes.
                KeyCode::Char('f') if app.focused_pane == Pane::Mailbox => {
                    app.open_mailbox_filter_input()
                }
                KeyCode::Char('/') if app.focused_pane == Pane::Mailbox => {
                    app.open_mailbox_search_input()
                }
                // T-131 PR-3: Enter on a selected mailbox row opens
                // the detail modal — captures the row content at
                // snapshot-at-open (variant (a) locked) and flips
                // Stage to MailboxDetailModal. No-op when
                // visible_indices is empty (`open_…` handles the
                // gate). Placed AFTER the input-mode `Enter`
                // confirm arm at the top of this match — that arm
                // fires when the operator presses Enter from
                // inside an open filter/search, not from cursor
                // selection, so the two are disjoint by mode.
                KeyCode::Enter if app.focused_pane == Pane::Mailbox => {
                    app.open_mailbox_detail_modal()
                }
                // Roster navigation — only when roster is the
                // focused pane. j/k mirror Vim; arrows mirror
                // every-day navigation.
                KeyCode::Up | KeyCode::Char('k') if app.focused_pane == Pane::Roster => {
                    app.select_prev()
                }
                KeyCode::Down | KeyCode::Char('j') if app.focused_pane == Pane::Roster => {
                    app.select_next()
                }
                _ => {}
            },
            Stage::QuitConfirm => match k.code {
                KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => app.confirm_quit(),
                KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => app.cancel_quit(),
                _ => {}
            },
            Stage::ApprovalsModal => match k.code {
                // Asymmetric chord shape (T-074 bug 4 fix): approve is
                // the common path so it accepts both `y` and `Y` —
                // matches QuitConfirm's loose convention and the
                // muscle-memory most TUI prompts build. Deny is the
                // destructive side, so it requires deliberate Shift
                // (`N` only); a stray lowercase `n` does nothing.
                // Trades cosmetic chord-symmetry for discoverability
                // on the load-bearing approve flow.
                KeyCode::Char('y') | KeyCode::Char('Y') => {
                    app.apply_decision(decider, Decision::Approve, "")
                }
                KeyCode::Char('N') => app.apply_decision(decider, Decision::Deny, ""),
                KeyCode::Char('j') | KeyCode::Down => app.cycle_approval_next(),
                KeyCode::Char('k') | KeyCode::Up => app.cycle_approval_prev(),
                KeyCode::Esc | KeyCode::Char('q') => app.close_approvals_modal(),
                _ => {}
            },
            Stage::ComposeModal => {
                // PR-UI-6: when the broadcast picker is open the
                // editor doesn't see keys yet — operator first
                // chooses a channel.
                if app.compose_picker_open {
                    match k.code {
                        KeyCode::Down | KeyCode::Char('j') => app.picker_next(),
                        KeyCode::Up | KeyCode::Char('k') => app.picker_prev(),
                        KeyCode::Enter => app.picker_confirm(),
                        // PR-UI-6 fixup (Q6, dev2 review): Esc
                        // dismisses the picker overlay only and
                        // returns to the editor with whatever the
                        // operator already typed; the editor's own
                        // Esc-Esc cancel-the-modal flow handles
                        // bailing out of the whole compose. Mirrors
                        // the overlay-vs-modal symmetry vim users
                        // expect.
                        KeyCode::Esc => {
                            app.compose_picker_open = false;
                            app.compose_picker_index = 0;
                        }
                        _ => {}
                    }
                } else if app.compose_attach_input_open {
                    // T-32: path-input overlay. Keys edit the buffer
                    // directly; Enter confirms (appends marker line
                    // to the editor body); Esc cancels back to the
                    // editor. Same overlay-vs-modal symmetry as the
                    // picker — Esc dismisses *the overlay*, not the
                    // whole compose.
                    match k.code {
                        KeyCode::Char(c) => app.compose_attach_buffer.push(c),
                        KeyCode::Backspace => {
                            app.compose_attach_buffer.pop();
                        }
                        KeyCode::Enter => app.confirm_compose_attach_input(),
                        KeyCode::Esc => app.close_compose_attach_input(),
                        _ => {}
                    }
                } else if k.code == KeyCode::Tab {
                    // T-32: Tab opens the path-input overlay. The
                    // editor never sees Tab today (apply_insert
                    // ignores it), so intercepting here doesn't
                    // change the editor's surface.
                    app.open_compose_attach_input();
                } else {
                    // Route every keypress through the editor; the
                    // editor returns Send / Cancel / Continue.
                    match app.compose_editor.apply_key(k) {
                        EditorAction::Continue => {}
                        EditorAction::Send => app.apply_send(sender, mailbox_source),
                        EditorAction::Cancel => app.close_compose_modal(),
                    }
                }
            }
            Stage::HelpOverlay => match k.code {
                KeyCode::Esc | KeyCode::Char('?') | KeyCode::Char('q') => app.close_help_overlay(),
                _ => {}
            },
            // T-131 PR-3: mailbox detail modal. Esc OR q close;
            // j/k or Up/Down scroll the wrapped body when it
            // overflows the modal height. Other keys are swallowed
            // so a stray chord doesn't accidentally act on the
            // Triptych rendered underneath.
            Stage::MailboxDetailModal => match k.code {
                KeyCode::Esc | KeyCode::Char('q') => app.close_mailbox_detail_modal(),
                KeyCode::Char('j') | KeyCode::Down => app.mailbox_detail_scroll_down(),
                KeyCode::Char('k') | KeyCode::Up => app.mailbox_detail_scroll_up(),
                _ => {}
            },
            Stage::Tutorial => match k.code {
                KeyCode::Esc => app.close_tutorial(),
                KeyCode::Char('k') | KeyCode::Up | KeyCode::Char('p') => app.tutorial_back(),
                _ => app.tutorial_advance(),
            },
            // T-108 / T-374 stream-keys mode. `Ctrl+E` is the only
            // chord we intercept — it's the symmetric exit toggle
            // (the same chord enters the mode from a focused detail
            // pane). Every other key — including `Esc`, `Ctrl+C`,
            // arrow keys, `Enter` — forwards to the agent's tmux
            // pane. The pass-through is intentional: the operator is
            // "effectively attached," so `Esc` reaches Claude Code
            // (it's load-bearing there) and a shell-user's `Ctrl+C`
            // sends SIGINT to the agent rather than bailing out of
            // the mode they just entered.
            Stage::StreamKeys => {
                let ctrl = k.modifiers.contains(KeyModifiers::CONTROL);
                let ctrl_shift = k
                    .modifiers
                    .contains(KeyModifiers::CONTROL | KeyModifiers::SHIFT);
                if ctrl && matches!(k.code, KeyCode::Char('e') | KeyCode::Char('E')) {
                    app.exit_stream_keys();
                } else if ctrl_shift && matches!(k.code, KeyCode::Up | KeyCode::Down) {
                    // Ctrl+Shift+↑/↓ switches which agent receives
                    // keystrokes without leaving stream-keys mode, so the
                    // banner, the detail pane, and the stream target move
                    // together. (Ctrl+↑/↓ alone is reserved by macOS
                    // Mission Control, so the switch chord adds Shift.)
                    //
                    // Only act when the stream target IS the roster
                    // selection — i.e. no detail split is focused. With a
                    // split focused, `stream_target_session` targets the
                    // split's agent rather than `selected_agent`, so moving
                    // the roster selection would desync the banner/detail
                    // from the session keystrokes actually land in. There
                    // we consume the chord as a no-op instead of misrouting
                    // or leaking a stray `C-S-arrow` to the pane.
                    if app.detail_splits.is_empty() || app.selected_split == 0 {
                        if matches!(k.code, KeyCode::Up) {
                            app.select_prev();
                        } else {
                            app.select_next();
                        }
                    }
                } else if let Some(session) = app.stream_target_session() {
                    if let Some(encoded) = encode_key(k) {
                        // Best-effort: a tmux failure (session
                        // vanished, target pane gone) is silent in
                        // v1; the next refresh tick reflects whatever
                        // the agent's pane actually shows.
                        let _ = key_sender.send(&session, &encoded);
                    }
                } else {
                    // Target session disappeared mid-stream (agent
                    // restarted, team reloaded). Drop back to
                    // Triptych so the operator isn't typing into the
                    // void with no feedback.
                    app.exit_stream_keys();
                }
            }
        },
        Event::Resize(_, _) => {
            // ratatui redraws on the next loop iteration; nothing to do.
        }
        // T-158: mouse-wheel routes by focused pane. Detail forwards
        // each tick to the agent's tmux pane as a copy-mode scroll —
        // wheel-up enters copy-mode and walks history, wheel-down
        // walks back toward live. Roster steps the agent selection
        // (same step as `j`/`k`). T-131 PR-1 wires Mailbox to step
        // the per-tab row cursor (same step as `j`/`k`).
        // Stages other than Triptych ignore mouse input — modal
        // overlays (compose, approvals, picker, help) own the screen
        // and shouldn't get a surprise scroll routed past them.
        Event::Mouse(m) if matches!(app.stage, Stage::Triptych) => {
            use crossterm::event::MouseEventKind;
            let direction = match m.kind {
                MouseEventKind::ScrollUp => Some(ScrollDirection::Up),
                MouseEventKind::ScrollDown => Some(ScrollDirection::Down),
                _ => None,
            };
            if let Some(dir) = direction {
                match app.focused_pane {
                    Pane::Detail => {
                        if let Some(session) = app.focused_session().map(|s| s.to_string()) {
                            // Best-effort, same convention as
                            // stream-keys: tmux failure (session
                            // vanished) is silent; the next refresh
                            // reflects reality.
                            let _ = key_sender.scroll(&session, dir);
                        }
                    }
                    Pane::Roster => match dir {
                        ScrollDirection::Up => app.select_prev(),
                        ScrollDirection::Down => app.select_next(),
                    },
                    Pane::Mailbox => match dir {
                        ScrollDirection::Up => app.mailbox_cursor_up(),
                        ScrollDirection::Down => app.mailbox_cursor_down(),
                    },
                }
            }
        }
        _ => {}
    }
}

/// Render the entire UI into a `Buffer` at fixed size — used by the
/// snapshot tests. Mirrors `draw` exactly but doesn't require a
/// `Terminal`. Update both in lockstep when adding new stages.
pub fn render_to_buffer(app: &App, width: u16, height: u16) -> Buffer {
    let area = Rect::new(0, 0, width, height);
    let mut buf = Buffer::empty(area);
    match app.stage {
        Stage::Splash => splash::Splash { app }.render(area, &mut buf),
        Stage::Triptych => render_main(app, area, &mut buf),
        Stage::StreamKeys => render_main(app, area, &mut buf),
        Stage::QuitConfirm => {
            render_main(app, area, &mut buf);
            render_quit_confirm(area, &mut buf);
        }
        Stage::ApprovalsModal => {
            render_main(app, area, &mut buf);
            render_approvals_modal(area, &mut buf, app);
        }
        Stage::ComposeModal => {
            render_main(app, area, &mut buf);
            render_compose_modal(area, &mut buf, app);
        }
        Stage::HelpOverlay => {
            render_main(app, area, &mut buf);
            render_help_overlay(area, &mut buf, app);
        }
        Stage::Tutorial => {
            render_main(app, area, &mut buf);
            render_tutorial(area, &mut buf, app);
        }
        Stage::MailboxDetailModal => {
            render_main(app, area, &mut buf);
            render_mailbox_detail_modal(area, &mut buf, app);
        }
    }
    buf
}

fn render_main(app: &App, area: Rect, buf: &mut Buffer) {
    // T-209: two-row footer — keep this in lockstep with `draw_main`
    // (snapshot tests render via this fn, the runtime via the other).
    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Min(3),
            Constraint::Length(1), // existing keybindings statusline
            Constraint::Length(1), // T-209 bottom status bar
        ])
        .split(area);
    match app.layout {
        crate::triptych::MainLayout::Triptych => {
            triptych::Triptych { app }.render(chunks[0], buf);
        }
        crate::triptych::MainLayout::Wall => {
            layouts::Wall { app }.render(chunks[0], buf);
        }
        crate::triptych::MainLayout::MailboxFirst => {
            layouts::MailboxFirst { app }.render(chunks[0], buf);
        }
    }
    statusline::Statusline { app }.render(chunks[1], buf);
    status_bar::StatusBar { app }.render(chunks[2], buf);
}

fn render_quit_confirm(area: Rect, buf: &mut Buffer) {
    let popup_w = 36u16.min(area.width.saturating_sub(2));
    let popup_h = 5u16.min(area.height.saturating_sub(2));
    let popup = centered_rect(popup_w, popup_h, area);
    Clear.render(popup, buf);
    Paragraph::new("Quit teamctl-ui?  [y / n]")
        .alignment(Alignment::Center)
        .block(Block::default().borders(Borders::ALL).title("confirm"))
        .render(popup, buf);
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::data::AgentInfo;
    use crossterm::event::{KeyEvent, KeyEventState, KeyModifiers};
    use team_core::supervisor::AgentState;

    fn key(code: KeyCode) -> Event {
        Event::Key(KeyEvent {
            code,
            modifiers: KeyModifiers::NONE,
            kind: KeyEventKind::Press,
            state: KeyEventState::NONE,
        })
    }

    fn key_with(code: KeyCode, modifiers: KeyModifiers) -> Event {
        Event::Key(KeyEvent {
            code,
            modifiers,
            kind: KeyEventKind::Press,
            state: KeyEventState::NONE,
        })
    }

    /// Noop decider for tests that don't exercise approve/deny.
    struct NoopDecider;
    impl crate::approvals::ApprovalDecider for NoopDecider {
        fn decide(
            &self,
            _root: &std::path::Path,
            _id: i64,
            _kind: crate::approvals::Decision,
            _note: &str,
        ) -> anyhow::Result<()> {
            Ok(())
        }
    }

    /// Noop sender for tests that don't exercise compose-send.
    struct NoopSender;
    impl crate::compose::MessageSender for NoopSender {
        fn send_dm(
            &self,
            _root: &std::path::Path,
            _agent: &str,
            _body: &str,
        ) -> anyhow::Result<()> {
            Ok(())
        }
        fn broadcast(
            &self,
            _root: &std::path::Path,
            _channel: &str,
            _body: &str,
        ) -> anyhow::Result<()> {
            Ok(())
        }
    }

    /// Mailbox source that returns nothing — refresh_mailbox after
    /// a successful send becomes a no-op.
    struct EmptyMailbox;
    impl crate::mailbox::MailboxSource for EmptyMailbox {
        fn inbox(&self, _id: &str, _after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
            Ok(Vec::new())
        }
        fn sent(&self, _id: &str, _after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
            Ok(Vec::new())
        }
        fn channel_feed(
            &self,
            _id: &str,
            _after: i64,
        ) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
            Ok(Vec::new())
        }
        fn wire(&self, _id: &str, _after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
            Ok(Vec::new())
        }
    }

    /// Boilerplate-free dispatcher for tests not exercising the
    /// decision / send paths.
    fn dispatch(app: &mut App, ev: Event) {
        super::handle_event(
            app,
            ev,
            &NoopDecider,
            &NoopSender,
            &EmptyMailbox,
            &crate::keysender::test_support::MockKeySender::default(),
        );
    }

    fn agent(id: &str, state: AgentState) -> AgentInfo {
        AgentInfo {
            id: id.into(),
            agent: id
                .split_once(':')
                .map(|(_, a)| a.to_string())
                .unwrap_or_default(),
            project: id
                .split_once(':')
                .map(|(p, _)| p.to_string())
                .unwrap_or_default(),
            tmux_session: format!("t-{}", id.replace(':', "-")),
            state,
            unread_mail: 0,
            pending_approvals: 0,
            is_manager: false,
            display_name: None,
            rate_limit_resets_at: None,
            last_activity_at: None,
            reports_to: None,
        }
    }

    pub fn fixture_team(agents: Vec<AgentInfo>) -> TeamSnapshot {
        TeamSnapshot {
            root: std::path::PathBuf::from("/fixture"),
            team_name: "fixture".into(),
            agents,
            channels: Vec::new(),
        }
    }

    #[test]
    fn splash_dismissed_by_any_key() {
        let mut app = App::new();
        assert_eq!(app.stage, Stage::Splash);
        dispatch(&mut app, key(KeyCode::Char(' ')));
        assert_eq!(app.stage, Stage::Triptych);
    }

    #[test]
    fn tab_cycles_panes_uniformly_and_wraps_through_mailbox() {
        // T-074 bug 6: Tab cycles pane focus only — Roster → Detail
        // → Mailbox → Roster — at every step. The previous "Tab
        // cycles tabs once focused on mailbox" shape stranded
        // operators inside the mailbox; this test pins the corrected
        // uniform cycle so a future refactor can't reintroduce the
        // dead-end.
        let mut app = App::new();
        app.dismiss_splash();
        assert_eq!(app.focused_pane, Pane::Roster);
        dispatch(&mut app, key(KeyCode::Tab));
        assert_eq!(app.focused_pane, Pane::Detail);
        dispatch(&mut app, key(KeyCode::Tab));
        assert_eq!(app.focused_pane, Pane::Mailbox);
        assert_eq!(
            app.mailbox_tab,
            MailboxTab::Inbox,
            "Tab into mailbox does NOT touch the active mailbox tab"
        );
        dispatch(&mut app, key(KeyCode::Tab));
        assert_eq!(
            app.focused_pane,
            Pane::Roster,
            "Tab from mailbox wraps to roster, not into mailbox subtabs"
        );
        assert_eq!(
            app.mailbox_tab,
            MailboxTab::Inbox,
            "mailbox tab still untouched"
        );
    }

    #[test]
    fn arrow_keys_walk_mailbox_tabs_when_mailbox_focused() {
        // T-124: Right/Left arrows are the mailbox-tab walker
        // (more discoverable than the prior `[`/`]` chord). Gated
        // on mailbox being the focused pane so the arrows stay
        // unsurprising in every other context.
        let mut app = App::new();
        app.dismiss_splash();
        // Walk into mailbox via Tab.
        dispatch(&mut app, key(KeyCode::Tab));
        dispatch(&mut app, key(KeyCode::Tab));
        assert_eq!(app.focused_pane, Pane::Mailbox);
        assert_eq!(app.mailbox_tab, MailboxTab::Inbox);

        dispatch(&mut app, key(KeyCode::Right));
        assert_eq!(app.mailbox_tab, MailboxTab::Sent);
        dispatch(&mut app, key(KeyCode::Right));
        assert_eq!(app.mailbox_tab, MailboxTab::Channel);
        dispatch(&mut app, key(KeyCode::Right));
        assert_eq!(app.mailbox_tab, MailboxTab::Wire);
        dispatch(&mut app, key(KeyCode::Right));
        assert_eq!(app.mailbox_tab, MailboxTab::Inbox, "→ wraps");

        dispatch(&mut app, key(KeyCode::Left));
        assert_eq!(app.mailbox_tab, MailboxTab::Wire, "← walks back");
    }

    #[test]
    fn arrow_keys_no_op_when_mailbox_not_focused() {
        // The arrows must not surprise an operator scrolling the
        // roster — gate is load-bearing.
        let mut app = App::new();
        app.dismiss_splash();
        assert_eq!(app.focused_pane, Pane::Roster);
        let initial = app.mailbox_tab;
        dispatch(&mut app, key(KeyCode::Right));
        dispatch(&mut app, key(KeyCode::Left));
        assert_eq!(
            app.mailbox_tab, initial,
            "←/→ from non-mailbox panes must not flip the active tab"
        );
    }

    #[test]
    fn brackets_no_longer_cycle_mailbox_tabs() {
        // T-124 regression: `[` / `]` were the previous binding;
        // hard-swap means they are now fully inert in the mailbox
        // pane. Pin the no-op so a future binding can't quietly
        // re-introduce the old chord.
        let mut app = App::new();
        app.dismiss_splash();
        dispatch(&mut app, key(KeyCode::Tab));
        dispatch(&mut app, key(KeyCode::Tab));
        assert_eq!(app.focused_pane, Pane::Mailbox);
        let initial = app.mailbox_tab;

        dispatch(&mut app, key(KeyCode::Char(']')));
        dispatch(&mut app, key(KeyCode::Char('[')));
        assert_eq!(
            app.mailbox_tab, initial,
            "`[` / `]` must no longer cycle mailbox tabs (T-124 hard-swap)",
        );
    }

    #[test]
    fn q_opens_confirm_then_n_cancels() {
        let mut app = App::new();
        app.dismiss_splash();
        dispatch(&mut app, key(KeyCode::Char('q')));
        assert_eq!(app.stage, Stage::QuitConfirm);
        dispatch(&mut app, key(KeyCode::Char('n')));
        assert_eq!(app.stage, Stage::Triptych);
        assert!(app.running, "n must not exit");
    }

    #[test]
    fn q_then_y_exits() {
        let mut app = App::new();
        app.dismiss_splash();
        dispatch(&mut app, key(KeyCode::Char('q')));
        dispatch(&mut app, key(KeyCode::Char('y')));
        assert!(!app.running);
    }

    #[test]
    fn esc_cancels_quit_confirm() {
        let mut app = App::new();
        app.dismiss_splash();
        app.enter_quit_confirm();
        dispatch(&mut app, key(KeyCode::Esc));
        assert_eq!(app.stage, Stage::Triptych);
    }

    #[test]
    fn render_does_not_panic_at_minimal_size() {
        let app = App::new();
        let _ = render_to_buffer(&app, 20, 8);
    }

    #[test]
    fn render_does_not_panic_at_huge_size() {
        let app = App::new();
        let _ = render_to_buffer(&app, 240, 80);
    }

    #[test]
    fn select_next_wraps_through_team() {
        let mut app = App::new();
        app.replace_team(fixture_team(vec![
            agent("p:a", AgentState::Running),
            agent("p:b", AgentState::Running),
            agent("p:c", AgentState::Running),
        ]));
        assert_eq!(app.selected_agent, Some(0));
        app.select_next();
        assert_eq!(app.selected_agent, Some(1));
        app.select_next();
        assert_eq!(app.selected_agent, Some(2));
        app.select_next();
        assert_eq!(app.selected_agent, Some(0)); // wraps
    }

    #[test]
    fn select_prev_wraps_at_top() {
        let mut app = App::new();
        app.replace_team(fixture_team(vec![
            agent("p:a", AgentState::Running),
            agent("p:b", AgentState::Running),
        ]));
        app.selected_agent = Some(0);
        app.select_prev();
        assert_eq!(app.selected_agent, Some(1));
    }

    #[test]
    fn select_no_op_on_empty_team() {
        let mut app = App::new();
        app.select_next();
        assert_eq!(app.selected_agent, None);
        app.select_prev();
        assert_eq!(app.selected_agent, None);
    }

    #[test]
    fn replace_team_preserves_selection_when_agent_still_present() {
        let mut app = App::new();
        app.replace_team(fixture_team(vec![
            agent("p:a", AgentState::Running),
            agent("p:b", AgentState::Running),
        ]));
        app.selected_agent = Some(1);
        app.replace_team(fixture_team(vec![
            agent("p:a", AgentState::Running),
            agent("p:b", AgentState::Stopped), // same id, new state
        ]));
        assert_eq!(app.selected_agent, Some(1), "selection follows the id");
    }

    #[test]
    fn replace_team_resets_selection_when_agent_disappears() {
        let mut app = App::new();
        app.replace_team(fixture_team(vec![
            agent("p:a", AgentState::Running),
            agent("p:gone", AgentState::Running),
        ]));
        app.selected_agent = Some(1);
        app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
        assert_eq!(app.selected_agent, Some(0), "falls back to first agent");
    }

    #[test]
    fn switching_agent_resets_mailbox_buffers() {
        // The mailbox cursors are per-agent context; switching to a
        // new agent must clear them so we don't skip historical
        // rows that landed before the new agent's first refresh.
        let mut app = App::new();
        app.replace_team(fixture_team(vec![
            agent("p:a", AgentState::Running),
            agent("p:b", AgentState::Running),
        ]));
        app.mailbox.extend(
            crate::mailbox::MailboxTab::Inbox,
            vec![crate::mailbox::MessageRow {
                id: 7,
                sender: "p:b".into(),
                recipient: "p:a".into(),
                text: "hi".into(),
                sent_at: 0.0,
            }],
        );
        assert_eq!(app.mailbox.inbox.len(), 1);
        assert_eq!(app.mailbox.inbox_after, 7);
        // Move selection to p:b — different agent id, mailbox resets.
        app.select_next();
        assert_eq!(app.selected_agent_id().as_deref(), Some("p:b"));
        assert!(app.mailbox.inbox.is_empty());
        assert_eq!(app.mailbox.inbox_after, 0);
    }

    /// Tiny single-call mailbox stub for the refresh-fanout test —
    /// keeps the assertion local without depending on
    /// `mailbox::tests::MockMailboxSource` (which lives behind a
    /// private `tests` module).
    struct TripleFilterMock {
        inbox: Vec<crate::mailbox::MessageRow>,
        sent: Vec<crate::mailbox::MessageRow>,
        channel: Vec<crate::mailbox::MessageRow>,
        wire: Vec<crate::mailbox::MessageRow>,
        calls: std::sync::Mutex<Vec<(&'static str, String, i64)>>,
    }
    impl crate::mailbox::MailboxSource for TripleFilterMock {
        fn inbox(&self, id: &str, after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
            self.calls.lock().unwrap().push(("inbox", id.into(), after));
            Ok(self.inbox.clone())
        }
        fn sent(&self, id: &str, after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
            self.calls.lock().unwrap().push(("sent", id.into(), after));
            Ok(self.sent.clone())
        }
        fn channel_feed(
            &self,
            id: &str,
            after: i64,
        ) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
            self.calls
                .lock()
                .unwrap()
                .push(("channel", id.into(), after));
            Ok(self.channel.clone())
        }
        fn wire(&self, id: &str, after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
            self.calls.lock().unwrap().push(("wire", id.into(), after));
            Ok(self.wire.clone())
        }
    }

    #[test]
    fn refresh_mailbox_fans_out_to_four_filters() {
        use crate::mailbox::MessageRow;
        let mut app = App::new();
        app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
        let mock = TripleFilterMock {
            inbox: vec![MessageRow {
                id: 1,
                sender: "p:b".into(),
                recipient: "p:a".into(),
                text: "dm".into(),
                sent_at: 0.0,
            }],
            sent: vec![MessageRow {
                id: 4,
                sender: "p:a".into(),
                recipient: "p:b".into(),
                text: "outgoing dm".into(),
                sent_at: 0.0,
            }],
            channel: vec![MessageRow {
                id: 2,
                sender: "p:b".into(),
                recipient: "channel:p:editorial".into(),
                text: "ch".into(),
                sent_at: 0.0,
            }],
            wire: vec![MessageRow {
                id: 3,
                sender: "p:b".into(),
                recipient: "channel:p:all".into(),
                text: "wire".into(),
                sent_at: 0.0,
            }],
            calls: std::sync::Mutex::new(Vec::new()),
        };
        super::refresh_mailbox(&mut app, &mock);
        assert_eq!(app.mailbox.inbox.len(), 1);
        assert_eq!(app.mailbox.sent.len(), 1);
        assert_eq!(app.mailbox.channel.len(), 1);
        assert_eq!(app.mailbox.wire.len(), 1);
        let calls = mock.calls.lock().unwrap();
        // The selected agent is p:a (auto-set by replace_team to
        // index 0); the wire filter takes the project id `p`.
        assert!(calls.contains(&("inbox", "p:a".into(), 0)));
        assert!(calls.contains(&("sent", "p:a".into(), 0)));
        assert!(calls.contains(&("channel", "p:a".into(), 0)));
        assert!(calls.contains(&("wire", "p".into(), 0)));
    }

    fn ap(id: i64) -> crate::approvals::Approval {
        crate::approvals::Approval {
            id,
            project_id: "p".into(),
            agent_id: "p:m".into(),
            action: "publish".into(),
            summary: format!("approval #{id}"),
            payload_json: String::new(),
        }
    }

    #[test]
    fn has_pending_approvals_tracks_replace_calls() {
        let mut app = App::new();
        assert!(!app.has_pending_approvals());
        app.replace_approvals(vec![ap(1), ap(2)]);
        assert!(app.has_pending_approvals());
        app.replace_approvals(vec![]);
        assert!(!app.has_pending_approvals());
    }

    #[test]
    fn enter_approvals_modal_no_op_when_queue_empty() {
        let mut app = App::new();
        app.dismiss_splash();
        app.enter_approvals_modal();
        assert_eq!(app.stage, Stage::Triptych, "no pending → no modal");
    }

    #[test]
    fn a_chord_opens_modal_when_pending() {
        let mut app = App::new();
        app.dismiss_splash();
        app.replace_approvals(vec![ap(1), ap(2)]);
        dispatch(&mut app, key(KeyCode::Char('a')));
        assert_eq!(app.stage, Stage::ApprovalsModal);
        assert_eq!(app.selected_approval, 0);
    }

    #[test]
    fn modal_cycle_jk_walks_approvals() {
        let mut app = App::new();
        app.dismiss_splash();
        app.replace_approvals(vec![ap(1), ap(2), ap(3)]);
        app.enter_approvals_modal();
        dispatch(&mut app, key(KeyCode::Char('j')));
        assert_eq!(app.selected_approval, 1);
        dispatch(&mut app, key(KeyCode::Char('j')));
        assert_eq!(app.selected_approval, 2);
        dispatch(&mut app, key(KeyCode::Char('j')));
        assert_eq!(app.selected_approval, 0, "wraps");
        dispatch(&mut app, key(KeyCode::Char('k')));
        assert_eq!(app.selected_approval, 2, "k wraps too");
    }

    #[test]
    fn capital_y_routes_approve_through_decider() {
        use crate::approvals::test_support::MockApprovalDecider;
        let dec = MockApprovalDecider::default();
        let mut app = App::new();
        app.dismiss_splash();
        app.replace_approvals(vec![ap(7), ap(8)]);
        app.enter_approvals_modal();
        super::handle_event(
            &mut app,
            key(KeyCode::Char('Y')),
            &dec,
            &NoopSender,
            &EmptyMailbox,
            &crate::keysender::test_support::MockKeySender::default(),
        );
        let calls = dec.calls.lock().unwrap().clone();
        assert_eq!(calls.len(), 1);
        assert_eq!(calls[0].0, 7);
        assert_eq!(calls[0].1, crate::approvals::Decision::Approve);
        // Optimistic local removal — approval id 7 dropped.
        assert_eq!(app.pending_approvals.len(), 1);
        assert_eq!(app.pending_approvals[0].id, 8);
    }

    #[test]
    fn capital_n_routes_deny_through_decider() {
        use crate::approvals::test_support::MockApprovalDecider;
        let dec = MockApprovalDecider::default();
        let mut app = App::new();
        app.dismiss_splash();
        app.replace_approvals(vec![ap(7)]);
        app.enter_approvals_modal();
        super::handle_event(
            &mut app,
            key(KeyCode::Char('N')),
            &dec,
            &NoopSender,
            &EmptyMailbox,
            &crate::keysender::test_support::MockKeySender::default(),
        );
        let calls = dec.calls.lock().unwrap().clone();
        assert_eq!(calls.len(), 1);
        assert_eq!(calls[0].1, crate::approvals::Decision::Deny);
        // Queue empty after the only approval resolves → modal closes.
        assert_eq!(app.stage, Stage::Triptych);
    }

    #[test]
    fn esc_closes_approvals_modal() {
        let mut app = App::new();
        app.dismiss_splash();
        app.replace_approvals(vec![ap(1)]);
        app.enter_approvals_modal();
        dispatch(&mut app, key(KeyCode::Esc));
        assert_eq!(app.stage, Stage::Triptych);
    }

    #[test]
    fn lowercase_y_routes_approve_through_decider() {
        // T-074 bug 4: discoverable approve. Most operators try
        // lowercase first; the modal must accept it on the
        // approve (low-risk) side. Deny stays Shift-gated.
        use crate::approvals::test_support::MockApprovalDecider;
        let dec = MockApprovalDecider::default();
        let mut app = App::new();
        app.dismiss_splash();
        app.replace_approvals(vec![ap(7)]);
        app.enter_approvals_modal();
        super::handle_event(
            &mut app,
            key(KeyCode::Char('y')),
            &dec,
            &NoopSender,
            &EmptyMailbox,
            &crate::keysender::test_support::MockKeySender::default(),
        );
        let calls = dec.calls.lock().unwrap().clone();
        assert_eq!(calls.len(), 1);
        assert_eq!(calls[0].1, crate::approvals::Decision::Approve);
    }

    #[test]
    fn lowercase_n_does_not_deny() {
        // Asymmetry guard: deny is destructive — `n` lowercase must
        // NOT fire the decider. A future "symmetric loose" refactor
        // would silently regress the destructive-deny Shift-gate;
        // this test pins it.
        use crate::approvals::test_support::MockApprovalDecider;
        let dec = MockApprovalDecider::default();
        let mut app = App::new();
        app.dismiss_splash();
        app.replace_approvals(vec![ap(7)]);
        app.enter_approvals_modal();
        super::handle_event(
            &mut app,
            key(KeyCode::Char('n')),
            &dec,
            &NoopSender,
            &EmptyMailbox,
            &crate::keysender::test_support::MockKeySender::default(),
        );
        assert!(
            dec.calls.lock().unwrap().is_empty(),
            "lowercase n must not route through the decider"
        );
        assert_eq!(
            app.stage,
            Stage::ApprovalsModal,
            "stale lowercase n leaves the modal open"
        );
    }

    #[test]
    fn shift_tab_cycles_panes_backward() {
        use crossterm::event::KeyModifiers;
        let mut app = App::new();
        app.dismiss_splash();
        assert_eq!(app.focused_pane, Pane::Roster);
        // Shift+Tab from Roster → Mailbox (the "back out of mailbox"
        // direction's mirror).
        dispatch(&mut app, key(KeyCode::BackTab));
        assert_eq!(app.focused_pane, Pane::Mailbox);
        // Some terminals send Tab + SHIFT instead of BackTab.
        dispatch(&mut app, key_with(KeyCode::Tab, KeyModifiers::SHIFT));
        assert_eq!(app.focused_pane, Pane::Detail);
    }

    #[test]
    fn at_chord_opens_compose_dm_to_focused_agent() {
        let mut app = App::new();
        app.replace_team(fixture_team(vec![
            agent("writing:manager", AgentState::Running),
            agent("writing:dev1", AgentState::Running),
        ]));
        app.dismiss_splash();
        app.select_next();
        dispatch(&mut app, key(KeyCode::Char('@')));
        assert_eq!(app.stage, Stage::ComposeModal);
        match app.compose_target.as_ref() {
            Some(crate::compose::ComposeTarget::Dm { agent_id, .. }) => {
                assert_eq!(agent_id, "writing:dev1");
            }
            other => panic!("expected DM target, got {other:?}"),
        }
    }

    #[test]
    fn bang_chord_opens_compose_broadcast_to_all_channel() {
        let mut app = App::new();
        app.replace_team(fixture_team(vec![agent(
            "writing:manager",
            AgentState::Running,
        )]));
        app.dismiss_splash();
        dispatch(&mut app, key(KeyCode::Char('!')));
        assert_eq!(app.stage, Stage::ComposeModal);
        match app.compose_target.as_ref() {
            Some(crate::compose::ComposeTarget::Broadcast { channel_id, .. }) => {
                assert_eq!(channel_id, "writing:all");
            }
            other => panic!("expected Broadcast target, got {other:?}"),
        }
    }

    #[test]
    fn send_routes_dm_through_mock_sender() {
        use crate::compose::test_support::MockMessageSender;
        let sender = MockMessageSender::default();
        let mailbox = EmptyMailbox;
        let mut app = App::new();
        app.replace_team(fixture_team(vec![agent(
            "writing:dev1",
            AgentState::Running,
        )]));
        app.dismiss_splash();
        app.enter_compose_dm_for_focused();
        for c in "ship it".chars() {
            super::handle_event(
                &mut app,
                key(KeyCode::Char(c)),
                &NoopDecider,
                &sender,
                &mailbox,
                &crate::keysender::test_support::MockKeySender::default(),
            );
        }
        super::handle_event(
            &mut app,
            key_with(KeyCode::Enter, crossterm::event::KeyModifiers::CONTROL),
            &NoopDecider,
            &sender,
            &mailbox,
            &crate::keysender::test_support::MockKeySender::default(),
        );
        let calls = sender.dm_calls.lock().unwrap().clone();
        assert_eq!(calls.len(), 1);
        assert_eq!(calls[0].0, "writing:dev1");
        assert_eq!(calls[0].1, "ship it");
        assert_eq!(app.stage, Stage::Triptych, "modal closes on send");
    }

    #[test]
    fn esc_esc_cancels_compose_without_send() {
        use crate::compose::test_support::MockMessageSender;
        let sender = MockMessageSender::default();
        let mailbox = EmptyMailbox;
        let mut app = App::new();
        app.replace_team(fixture_team(vec![agent(
            "writing:dev1",
            AgentState::Running,
        )]));
        app.dismiss_splash();
        app.enter_compose_dm_for_focused();
        for c in "draft".chars() {
            super::handle_event(
                &mut app,
                key(KeyCode::Char(c)),
                &NoopDecider,
                &sender,
                &mailbox,
                &crate::keysender::test_support::MockKeySender::default(),
            );
        }
        super::handle_event(
            &mut app,
            key(KeyCode::Esc),
            &NoopDecider,
            &sender,
            &mailbox,
            &crate::keysender::test_support::MockKeySender::default(),
        );
        super::handle_event(
            &mut app,
            key(KeyCode::Esc),
            &NoopDecider,
            &sender,
            &mailbox,
            &crate::keysender::test_support::MockKeySender::default(),
        );
        assert_eq!(app.stage, Stage::Triptych);
        assert!(sender.dm_calls.lock().unwrap().is_empty());
    }

    #[test]
    fn send_failure_surfaces_error_inline_keeps_modal_open() {
        use crate::compose::test_support::MockMessageSender;
        let sender = MockMessageSender::default();
        *sender.fail_next.lock().unwrap() = Some("rate limit".into());
        let mailbox = EmptyMailbox;
        let mut app = App::new();
        app.replace_team(fixture_team(vec![agent(
            "writing:dev1",
            AgentState::Running,
        )]));
        app.dismiss_splash();
        app.enter_compose_dm_for_focused();
        super::handle_event(
            &mut app,
            key(KeyCode::Char('x')),
            &NoopDecider,
            &sender,
            &mailbox,
            &crate::keysender::test_support::MockKeySender::default(),
        );
        super::handle_event(
            &mut app,
            key_with(KeyCode::Enter, crossterm::event::KeyModifiers::CONTROL),
            &NoopDecider,
            &sender,
            &mailbox,
            &crate::keysender::test_support::MockKeySender::default(),
        );
        assert_eq!(app.stage, Stage::ComposeModal, "modal stays open on err");
        assert!(app
            .compose_error
            .as_deref()
            .unwrap_or_default()
            .contains("rate limit"));
    }

    fn channel(id: &str, project: &str) -> crate::data::ChannelInfo {
        crate::data::ChannelInfo {
            id: id.into(),
            name: id
                .rsplit_once(':')
                .map(|(_, n)| n.to_string())
                .unwrap_or_default(),
            project_id: project.into(),
        }
    }

    fn fixture_team_with_channels(
        agents: Vec<AgentInfo>,
        channels: Vec<crate::data::ChannelInfo>,
    ) -> TeamSnapshot {
        TeamSnapshot {
            root: std::path::PathBuf::from("/fixture"),
            team_name: "fixture".into(),
            agents,
            channels,
        }
    }

    #[test]
    fn ctrl_w_toggles_wall_layout() {
        use crossterm::event::KeyModifiers;
        let mut app = App::new();
        app.dismiss_splash();
        assert_eq!(app.layout, MainLayout::Triptych);
        dispatch(
            &mut app,
            key_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
        );
        assert_eq!(app.layout, MainLayout::Wall);
        dispatch(
            &mut app,
            key_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
        );
        assert_eq!(app.layout, MainLayout::Triptych);
    }

    #[test]
    fn ctrl_m_toggles_mailbox_first_layout() {
        use crossterm::event::KeyModifiers;
        let mut app = App::new();
        app.dismiss_splash();
        dispatch(
            &mut app,
            key_with(KeyCode::Char('m'), KeyModifiers::CONTROL),
        );
        assert_eq!(app.layout, MainLayout::MailboxFirst);
        dispatch(
            &mut app,
            key_with(KeyCode::Char('m'), KeyModifiers::CONTROL),
        );
        assert_eq!(app.layout, MainLayout::Triptych);
    }

    #[test]
    fn wall_scroll_pages_through_overflow_agents() {
        let mut app = App::new();
        let mut agents: Vec<_> = (1..=10)
            .map(|i| agent(&format!("p:agent-{i:02}"), AgentState::Running))
            .collect();
        // managers-first sort would otherwise reorder; mark all as workers.
        for a in agents.iter_mut() {
            a.is_manager = false;
        }
        app.replace_team(fixture_team(agents));
        app.dismiss_splash();
        app.toggle_wall_layout();
        assert_eq!(app.wall_scroll, 0);
        app.wall_scroll_down();
        assert_eq!(app.wall_scroll, 4);
        app.wall_scroll_down();
        assert_eq!(app.wall_scroll, 8);
        // Past 10-1 = 9; cap blocks 12.
        app.wall_scroll_down();
        assert_eq!(app.wall_scroll, 8, "scroll capped at last full window");
        app.wall_scroll_up();
        assert_eq!(app.wall_scroll, 4);
    }

    #[test]
    fn ctrl_pipe_adds_detail_split_capped_at_four() {
        use crossterm::event::KeyModifiers;
        let mut app = App::new();
        app.replace_team(fixture_team(vec![
            agent("p:a", AgentState::Running),
            agent("p:b", AgentState::Running),
        ]));
        app.dismiss_splash();
        for _ in 0..6 {
            dispatch(
                &mut app,
                key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
            );
        }
        assert_eq!(app.detail_splits.len(), 4, "split count capped at 4");
    }

    #[test]
    fn ctrl_q_closes_focused_split() {
        use crossterm::event::KeyModifiers;
        let mut app = App::new();
        app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
        app.dismiss_splash();
        dispatch(
            &mut app,
            key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
        );
        dispatch(
            &mut app,
            key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
        );
        assert_eq!(app.detail_splits.len(), 2);
        dispatch(
            &mut app,
            key_with(KeyCode::Char('Q'), KeyModifiers::CONTROL),
        );
        assert_eq!(app.detail_splits.len(), 1);
    }

    #[test]
    fn ctrl_hjkl_cycles_splits() {
        use crossterm::event::KeyModifiers;
        let mut app = App::new();
        app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
        app.dismiss_splash();
        for _ in 0..3 {
            dispatch(
                &mut app,
                key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
            );
        }
        assert_eq!(app.selected_split, 2);
        dispatch(
            &mut app,
            key_with(KeyCode::Char('l'), KeyModifiers::CONTROL),
        );
        assert_eq!(app.selected_split, 0, "wraps");
        dispatch(
            &mut app,
            key_with(KeyCode::Char('h'), KeyModifiers::CONTROL),
        );
        assert_eq!(app.selected_split, 2);
    }

    #[test]
    fn wall_scroll_at_exactly_cap_agents_does_not_scroll() {
        // PR-UI-6 fixup (qa Gap 1a): with exactly WALL_TILE_CAP=4
        // agents the entire team fits in one window — scrolling
        // is a no-op in both directions. Pinning this catches a
        // future `<` → `<=` slip in `wall_scroll_down`.
        let mut app = App::new();
        let agents: Vec<_> = (1..=4)
            .map(|i| agent(&format!("p:agent-{i}"), AgentState::Running))
            .collect();
        app.replace_team(fixture_team(agents));
        app.dismiss_splash();
        app.toggle_wall_layout();
        assert_eq!(app.wall_scroll, 0);
        app.wall_scroll_down();
        assert_eq!(app.wall_scroll, 0, "exactly-cap should not advance");
        app.wall_scroll_up();
        assert_eq!(app.wall_scroll, 0);
    }

    #[test]
    fn wall_scroll_at_cap_plus_one_advances_then_stops() {
        // PR-UI-6 fixup (qa Gap 1b): exactly 5 agents → 4 fit in
        // window-0, the 5th lives at window-4. One scroll
        // advances; the next caps. Pins the off-by-one between 4
        // and 5 agents.
        let mut app = App::new();
        let agents: Vec<_> = (1..=5)
            .map(|i| agent(&format!("p:agent-{i}"), AgentState::Running))
            .collect();
        app.replace_team(fixture_team(agents));
        app.dismiss_splash();
        app.toggle_wall_layout();
        app.wall_scroll_down();
        assert_eq!(app.wall_scroll, 4, "first scroll exposes agent 5");
        app.wall_scroll_down();
        assert_eq!(app.wall_scroll, 4, "second scroll caps; nothing past");
    }

    #[test]
    fn esc_in_picker_dismisses_overlay_only_keeps_modal_open() {
        // PR-UI-6 fixup (Q6 dev2 review + qa Gap 3): Esc inside
        // the broadcast picker should close the picker overlay
        // and return to the editor in its current state — NOT
        // close the whole compose modal. Editor's Esc-Esc
        // already handles cancel-the-modal.
        let mut app = App::new();
        app.replace_team(fixture_team_with_channels(
            vec![agent("writing:manager", AgentState::Running)],
            vec![
                channel("writing:all", "writing"),
                channel("writing:editorial", "writing"),
            ],
        ));
        app.dismiss_splash();
        dispatch(&mut app, key(KeyCode::Char('!')));
        assert!(app.compose_picker_open);
        assert_eq!(app.stage, Stage::ComposeModal);
        dispatch(&mut app, key(KeyCode::Esc));
        assert!(!app.compose_picker_open, "picker dismissed");
        assert_eq!(app.stage, Stage::ComposeModal, "compose modal stays open");
    }

    #[test]
    fn send_routes_broadcast_through_mock_sender_via_picker() {
        // PR-UI-6 fixup (qa Gap 4): the broadcast path needs the
        // same MockMessageSender pin the DM path got in PR-UI-5.
        // Pins both per-channel-correct-id (picker selection
        // flows through to the send call) AND routes-through-
        // `broadcast()`-not-`send()` (no DM call recorded).
        use crate::compose::test_support::MockMessageSender;
        let sender = MockMessageSender::default();
        let mailbox = EmptyMailbox;
        let mut app = App::new();
        app.replace_team(fixture_team_with_channels(
            vec![agent("writing:manager", AgentState::Running)],
            vec![
                channel("writing:all", "writing"),
                channel("writing:editorial", "writing"),
                channel("writing:critique", "writing"),
            ],
        ));
        app.dismiss_splash();
        // Open picker, walk to channel index 1 (`editorial`),
        // confirm, type a body, Ctrl+Enter to send.
        super::handle_event(
            &mut app,
            key(KeyCode::Char('!')),
            &NoopDecider,
            &sender,
            &mailbox,
            &crate::keysender::test_support::MockKeySender::default(),
        );
        super::handle_event(
            &mut app,
            key(KeyCode::Char('j')),
            &NoopDecider,
            &sender,
            &mailbox,
            &crate::keysender::test_support::MockKeySender::default(),
        );
        super::handle_event(
            &mut app,
            key(KeyCode::Enter),
            &NoopDecider,
            &sender,
            &mailbox,
            &crate::keysender::test_support::MockKeySender::default(),
        );
        for c in "ship docs".chars() {
            super::handle_event(
                &mut app,
                key(KeyCode::Char(c)),
                &NoopDecider,
                &sender,
                &mailbox,
                &crate::keysender::test_support::MockKeySender::default(),
            );
        }
        super::handle_event(
            &mut app,
            key_with(KeyCode::Enter, crossterm::event::KeyModifiers::CONTROL),
            &NoopDecider,
            &sender,
            &mailbox,
            &crate::keysender::test_support::MockKeySender::default(),
        );
        let dm_calls = sender.dm_calls.lock().unwrap().clone();
        let bcast_calls = sender.broadcast_calls.lock().unwrap().clone();
        assert!(dm_calls.is_empty(), "broadcast must not route via send_dm");
        assert_eq!(bcast_calls.len(), 1);
        assert_eq!(
            bcast_calls[0].0, "writing:editorial",
            "channel id from picker selection"
        );
        assert_eq!(bcast_calls[0].1, "ship docs");
        assert_eq!(app.stage, Stage::Triptych, "modal closes on send");
    }

    #[test]
    fn bang_chord_opens_picker_when_channels_available() {
        let mut app = App::new();
        app.replace_team(fixture_team_with_channels(
            vec![agent("writing:manager", AgentState::Running)],
            vec![
                channel("writing:all", "writing"),
                channel("writing:editorial", "writing"),
                channel("writing:critique", "writing"),
            ],
        ));
        app.dismiss_splash();
        dispatch(&mut app, key(KeyCode::Char('!')));
        assert_eq!(app.stage, Stage::ComposeModal);
        assert!(app.compose_picker_open);
        // Walk the picker.
        dispatch(&mut app, key(KeyCode::Char('j')));
        assert_eq!(app.compose_picker_index, 1);
        // Confirm pulls into compose target.
        dispatch(&mut app, key(KeyCode::Enter));
        assert!(!app.compose_picker_open, "picker closes on confirm");
        match app.compose_target.as_ref() {
            Some(crate::compose::ComposeTarget::Broadcast { channel_id, .. }) => {
                assert_eq!(channel_id, "writing:editorial");
            }
            other => panic!("expected Broadcast target, got {other:?}"),
        }
    }

    #[test]
    fn mailbox_first_layout_seeds_channel_selection_on_entry() {
        let mut app = App::new();
        app.replace_team(fixture_team_with_channels(
            vec![agent("writing:manager", AgentState::Running)],
            vec![
                channel("writing:all", "writing"),
                channel("writing:editorial", "writing"),
            ],
        ));
        app.dismiss_splash();
        assert!(app.selected_channel.is_none());
        app.toggle_mailbox_first_layout();
        assert_eq!(app.selected_channel, Some(0));
    }

    #[test]
    fn help_overlay_opens_on_question_mark_closes_on_esc() {
        let mut app = App::new();
        app.dismiss_splash();
        dispatch(&mut app, key(KeyCode::Char('?')));
        assert_eq!(app.stage, Stage::HelpOverlay);
        dispatch(&mut app, key(KeyCode::Esc));
        assert_eq!(app.stage, Stage::Triptych);
    }

    #[test]
    fn tutorial_opens_on_t_advances_and_closes() {
        let mut app = App::new();
        app.dismiss_splash();
        dispatch(&mut app, key(KeyCode::Char('t')));
        assert_eq!(app.stage, Stage::Tutorial);
        assert_eq!(app.tutorial_step, 0);
        // Any non-Esc/back key advances.
        dispatch(&mut app, key(KeyCode::Char(' ')));
        assert_eq!(app.tutorial_step, 1);
        // `k` walks back.
        dispatch(&mut app, key(KeyCode::Char('k')));
        assert_eq!(app.tutorial_step, 0);
        // Esc closes from any step.
        dispatch(&mut app, key(KeyCode::Esc));
        assert_eq!(app.stage, Stage::Triptych);
    }

    #[test]
    fn tutorial_walk_back_at_step_zero_is_no_op() {
        // qa Gap C fold: pin the chosen behaviour for `k`/`Up`/`p`
        // at step 0 — saturating decrement keeps `tutorial_step`
        // at 0 rather than wrapping. Any future shift to
        // wrap-to-end would break this test, which is the point.
        let mut app = App::new();
        app.dismiss_splash();
        app.enter_tutorial();
        assert_eq!(app.tutorial_step, 0);
        dispatch(&mut app, key(KeyCode::Char('k')));
        assert_eq!(app.tutorial_step, 0, "step-0 walk-back is no-op");
        // The walk-back keypress must NOT close the tutorial
        // either — the Stage stays.
        assert_eq!(app.stage, Stage::Tutorial);
    }

    #[test]
    fn ctrl_pipe_adds_vertical_split_ctrl_minus_adds_horizontal() {
        use crossterm::event::KeyModifiers;
        let mut app = App::new();
        app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
        app.dismiss_splash();
        dispatch(
            &mut app,
            key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
        );
        dispatch(
            &mut app,
            key_with(KeyCode::Char('-'), KeyModifiers::CONTROL),
        );
        assert_eq!(app.detail_splits.len(), 2);
        assert_eq!(app.detail_splits[0].1, SplitOrientation::Vertical);
        assert_eq!(app.detail_splits[1].1, SplitOrientation::Horizontal);
    }

    #[test]
    fn ctrl_w_q_chord_prefix_closes_focused_split() {
        use crossterm::event::KeyModifiers;
        let mut app = App::new();
        app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
        app.dismiss_splash();
        // Two splits — `Ctrl+W` arms only when there's something
        // to close.
        dispatch(
            &mut app,
            key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
        );
        dispatch(
            &mut app,
            key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
        );
        dispatch(
            &mut app,
            key_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
        );
        assert_eq!(app.pending_chord, Some(KeyCode::Char('w')));
        // Plain `q` (no modifier) is now interpreted as the
        // chord-prefix follow-up — close split, NOT quit.
        dispatch(&mut app, key(KeyCode::Char('q')));
        assert_eq!(app.detail_splits.len(), 1);
        assert_eq!(app.stage, Stage::Triptych, "must not enter quit confirm");
        assert_eq!(app.pending_chord, None, "chord cleared");
    }

    #[test]
    fn ctrl_w_o_chord_keeps_only_focused_split() {
        use crossterm::event::KeyModifiers;
        let mut app = App::new();
        app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
        app.dismiss_splash();
        for _ in 0..3 {
            dispatch(
                &mut app,
                key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
            );
        }
        // Focus the middle split.
        app.selected_split = 1;
        let kept_id = app.detail_splits[1].0.clone();
        dispatch(
            &mut app,
            key_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
        );
        dispatch(&mut app, key(KeyCode::Char('o')));
        assert_eq!(app.detail_splits.len(), 1);
        assert_eq!(app.detail_splits[0].0, kept_id);
        assert_eq!(app.selected_split, 0);
    }

    #[test]
    fn add_detail_split_saturates_at_four_with_explicit_4_and_5_calls() {
        // qa Gap 2 fold: pin the cap explicitly. Reaching exactly
        // 4 must stick; the 5th call must be a no-op (not panic,
        // not silently grow). If `add_detail_split` ever returns
        // a Result, this test catches the silent-success regression.
        let mut app = App::new();
        app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
        for _ in 0..4 {
            app.add_detail_split();
        }
        assert_eq!(app.detail_splits.len(), 4);
        let snapshot_len = app.detail_splits.len();
        app.add_detail_split();
        assert_eq!(app.detail_splits.len(), snapshot_len, "5th call rejected");
    }

    #[test]
    fn replace_approvals_clamps_selection_in_range() {
        let mut app = App::new();
        app.replace_approvals(vec![ap(1), ap(2), ap(3)]);
        app.selected_approval = 2;
        // Approval id 3 resolved out-of-band; new snapshot has 2 rows.
        app.replace_approvals(vec![ap(1), ap(2)]);
        assert_eq!(app.selected_approval, 1, "clamps to last index");
    }

    #[test]
    fn arrow_keys_navigate_only_when_roster_focused() {
        let mut app = App::new();
        app.replace_team(fixture_team(vec![
            agent("p:a", AgentState::Running),
            agent("p:b", AgentState::Running),
        ]));
        app.dismiss_splash();
        // Focused pane is Roster → arrow cycles selection.
        app.selected_agent = Some(0);
        dispatch(&mut app, key(KeyCode::Down));
        assert_eq!(app.selected_agent, Some(1));
        // Cycle to Detail → arrow no longer touches selection.
        app.cycle_focus();
        dispatch(&mut app, key(KeyCode::Down));
        assert_eq!(
            app.selected_agent,
            Some(1),
            "non-roster focus ignores arrows"
        );
    }

    // ---- T-108 stream-keys mode -------------------------------------------

    /// Spin up a Triptych-stage app with one agent selected and the
    /// detail pane focused — the standard precondition for entering
    /// stream-keys mode.
    fn stream_keys_fixture() -> App {
        let mut app = App::new();
        app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
        app.dismiss_splash();
        app.cycle_focus(); // Roster → Detail
        assert_eq!(app.focused_pane, Pane::Detail);
        assert_eq!(app.selected_agent, Some(0));
        app
    }

    fn stream_dispatch(
        app: &mut App,
        ev: Event,
        key_sender: &crate::keysender::test_support::MockKeySender,
    ) {
        super::handle_event(
            app,
            ev,
            &NoopDecider,
            &NoopSender,
            &EmptyMailbox,
            key_sender,
        );
    }

    #[test]
    fn ctrl_e_enters_stream_keys_when_detail_focused() {
        use crate::keysender::test_support::MockKeySender;
        use crossterm::event::KeyModifiers;
        let mut app = stream_keys_fixture();
        let ks = MockKeySender::default();
        stream_dispatch(
            &mut app,
            key_with(KeyCode::Char('e'), KeyModifiers::CONTROL),
            &ks,
        );
        assert_eq!(app.stage, Stage::StreamKeys);
        assert!(
            ks.calls.lock().unwrap().is_empty(),
            "the activation chord itself never forwards a keystroke"
        );
    }

    #[test]
    fn ctrl_e_no_op_when_detail_not_focused() {
        // Activation gate: stream-mode never triggers from Roster /
        // Mailbox focus, so a stray `Ctrl+E` while scrolling the
        // roster doesn't yank the operator into a modal they didn't
        // ask for.
        use crate::keysender::test_support::MockKeySender;
        use crossterm::event::KeyModifiers;
        let mut app = App::new();
        app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
        app.dismiss_splash();
        assert_eq!(app.focused_pane, Pane::Roster);
        let ks = MockKeySender::default();
        stream_dispatch(
            &mut app,
            key_with(KeyCode::Char('e'), KeyModifiers::CONTROL),
            &ks,
        );
        assert_eq!(app.stage, Stage::Triptych);
    }

    #[test]
    fn ctrl_e_no_op_when_no_agent_selected() {
        // No target session → entering stream-mode would type into
        // the void. The guard short-circuits.
        use crate::keysender::test_support::MockKeySender;
        use crossterm::event::KeyModifiers;
        let mut app = App::new();
        app.dismiss_splash();
        app.cycle_focus(); // Detail
        assert_eq!(app.selected_agent, None);
        let ks = MockKeySender::default();
        stream_dispatch(
            &mut app,
            key_with(KeyCode::Char('e'), KeyModifiers::CONTROL),
            &ks,
        );
        assert_eq!(app.stage, Stage::Triptych);
    }

    #[test]
    fn esc_forwards_to_pane_in_stream_keys() {
        // T-374: Esc is NOT the exit chord — it forwards to the
        // agent's tmux pane like any other key, so Claude Code (where
        // Esc is load-bearing) receives it. Exit is `Ctrl+E` now.
        use crate::keysender::test_support::MockKeySender;
        use crossterm::event::KeyModifiers;
        let mut app = stream_keys_fixture();
        app.enter_stream_keys();
        assert_eq!(app.stage, Stage::StreamKeys);
        let ks = MockKeySender::default();
        stream_dispatch(&mut app, key_with(KeyCode::Esc, KeyModifiers::NONE), &ks);
        assert_eq!(
            app.stage,
            Stage::StreamKeys,
            "Esc does NOT exit stream-keys"
        );
        let calls = ks.calls.lock().unwrap();
        assert_eq!(calls.len(), 1, "Esc forwards as one keystroke");
        assert_eq!(calls[0].0, "t-p-a");
        assert_eq!(calls[0].1.args, vec!["Escape".to_string()]);
    }

    #[test]
    fn ctrl_e_exits_stream_keys() {
        // T-374: Ctrl+E is the symmetric exit toggle (same chord
        // that enters the mode). It must exit, not forward.
        use crate::keysender::test_support::MockKeySender;
        use crossterm::event::KeyModifiers;
        let mut app = stream_keys_fixture();
        app.enter_stream_keys();
        assert_eq!(app.stage, Stage::StreamKeys);
        let ks = MockKeySender::default();
        stream_dispatch(
            &mut app,
            key_with(KeyCode::Char('e'), KeyModifiers::CONTROL),
            &ks,
        );
        assert_eq!(app.stage, Stage::Triptych);
        assert!(
            ks.calls.lock().unwrap().is_empty(),
            "Ctrl+E is the exit chord — it must not forward as a keystroke"
        );
    }

    #[test]
    fn stream_mode_forwards_printable_chars_to_target_session() {
        use crate::keysender::test_support::MockKeySender;
        let mut app = stream_keys_fixture();
        app.enter_stream_keys();
        let ks = MockKeySender::default();
        for c in "hi".chars() {
            stream_dispatch(&mut app, key(KeyCode::Char(c)), &ks);
        }
        let calls = ks.calls.lock().unwrap();
        assert_eq!(calls.len(), 2, "one tmux send-keys per keystroke");
        // Target session = the focused agent's tmux_session (set by
        // the fixture to `t-p-a`).
        assert_eq!(calls[0].0, "t-p-a");
        assert_eq!(calls[0].1.args, vec!["-l".to_string(), "h".to_string()]);
        assert_eq!(calls[1].1.args, vec!["-l".to_string(), "i".to_string()]);
    }

    #[test]
    fn stream_mode_passes_ctrl_c_through_to_agent() {
        // Issue #108 design point: Ctrl+C is shell-SIGINT semantics,
        // not a stream-mode escape. Pin the contract so a future
        // "intercept Ctrl+C as bail" refactor doesn't regress it.
        use crate::keysender::test_support::MockKeySender;
        use crossterm::event::KeyModifiers;
        let mut app = stream_keys_fixture();
        app.enter_stream_keys();
        let ks = MockKeySender::default();
        stream_dispatch(
            &mut app,
            key_with(KeyCode::Char('c'), KeyModifiers::CONTROL),
            &ks,
        );
        assert_eq!(app.stage, Stage::StreamKeys, "Ctrl+C does NOT exit");
        let calls = ks.calls.lock().unwrap();
        assert_eq!(calls.len(), 1);
        assert_eq!(calls[0].1.args, vec!["C-c".to_string()]);
    }

    #[test]
    fn stream_mode_forwards_enter_and_arrows() {
        use crate::keysender::test_support::MockKeySender;
        let mut app = stream_keys_fixture();
        app.enter_stream_keys();
        let ks = MockKeySender::default();
        stream_dispatch(&mut app, key(KeyCode::Enter), &ks);
        stream_dispatch(&mut app, key(KeyCode::Up), &ks);
        let calls = ks.calls.lock().unwrap();
        assert_eq!(calls[0].1.args, vec!["Enter".to_string()]);
        assert_eq!(calls[1].1.args, vec!["Up".to_string()]);
    }

    #[test]
    fn stream_target_session_uses_focused_split_when_present() {
        // Splits change which agent the operator is "looking at."
        // The selected_split index drives the focus ring in
        // render_detail_splits; stream_target_session must mirror
        // that so typing lands in the right pane.
        let mut app = App::new();
        app.replace_team(fixture_team(vec![
            agent("p:a", AgentState::Running),
            agent("p:b", AgentState::Running),
        ]));
        app.dismiss_splash();
        app.cycle_focus(); // Detail
        app.selected_agent = Some(0);
        // Manually push a split for `p:b` and focus it.
        app.detail_splits
            .push(("p:b".into(), crate::app::SplitOrientation::Vertical));
        app.selected_split = 1; // cell index 0 = focused agent, 1 = first split
        let target = app.stream_target_session();
        assert_eq!(
            target.as_deref(),
            Some("t-p-b"),
            "selected split's agent drives the target"
        );
    }

    #[test]
    fn stream_mode_drops_back_when_target_session_disappears() {
        // If the team gets reloaded mid-stream and the focused
        // agent's index points off the end, the next keystroke
        // can't resolve a session. Drop back to Triptych so the
        // operator isn't silently typing into the void.
        use crate::keysender::test_support::MockKeySender;
        let mut app = stream_keys_fixture();
        app.enter_stream_keys();
        // Simulate the agent disappearing.
        app.selected_agent = None;
        app.team.agents.clear();
        let ks = MockKeySender::default();
        stream_dispatch(&mut app, key(KeyCode::Char('a')), &ks);
        assert_eq!(app.stage, Stage::Triptych);
        assert!(ks.calls.lock().unwrap().is_empty());
    }

    // ── fast-cadence focused-pane re-capture ────────────────────────

    #[test]
    fn recapture_focused_pane_sets_buffer_and_advances_clock() {
        // Between full 1s refreshes the run loop re-captures just the
        // focused agent's pane so the detail view stays live. It must
        // (a) push the captured lines into the detail buffer and
        // (b) advance `last_pane_refresh` so the next tick re-gates.
        use crate::pane::test_support::MockPaneSource;
        let mut app = App::new();
        app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
        app.dismiss_splash();
        assert_eq!(app.selected_agent, Some(0));
        let mock = MockPaneSource {
            lines: vec!["hello".into(), "world".into()],
            asked: std::sync::Mutex::new(Vec::new()),
        };
        // Backdate the clock so any advance is observable.
        let before = Instant::now() - PANE_REFRESH_INTERVAL;
        app.last_pane_refresh = before;

        super::recapture_focused_pane(&mut app, &mock);

        assert_eq!(app.detail_buffer, vec!["hello", "world"]);
        // The focused agent's session — and only that one — got captured.
        assert_eq!(mock.asked.lock().unwrap().clone(), vec!["t-p-a"]);
        assert!(
            app.last_pane_refresh > before,
            "re-capture advances the fast-cadence clock"
        );
    }

    #[test]
    fn recapture_focused_pane_no_op_when_no_agent_focused() {
        // Empty team → `focused_session()` is None, so there is nothing
        // to capture. It must not panic and must not query the source.
        use crate::pane::test_support::MockPaneSource;
        let mut app = App::new();
        app.dismiss_splash();
        assert_eq!(app.selected_agent, None);
        let mock = MockPaneSource {
            lines: vec!["unused".into()],
            asked: std::sync::Mutex::new(Vec::new()),
        };

        super::recapture_focused_pane(&mut app, &mock);

        assert!(
            mock.asked.lock().unwrap().is_empty(),
            "no focused agent → no capture call"
        );
        assert!(
            app.detail_buffer.is_empty(),
            "detail buffer untouched with no agent"
        );
    }

    #[test]
    fn recapture_focused_pane_no_op_when_selection_cleared() {
        // A populated team but `selected_agent == None` (e.g. focus
        // dropped) is also a no-op — the source is never queried.
        use crate::pane::test_support::MockPaneSource;
        let mut app = App::new();
        app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
        app.dismiss_splash();
        app.selected_agent = None;
        let mock = MockPaneSource {
            lines: vec!["unused".into()],
            asked: std::sync::Mutex::new(Vec::new()),
        };

        super::recapture_focused_pane(&mut app, &mock);

        assert!(mock.asked.lock().unwrap().is_empty());
        assert!(app.detail_buffer.is_empty());
    }

    // ── Ctrl+Shift+↑/↓ stream-keys agent switch ─────────────────────

    /// Two-agent variant of `stream_keys_fixture`: Detail focused,
    /// agent 0 selected, already in StreamKeys mode. Used by the
    /// agent-switch chord tests.
    fn stream_keys_fixture_two_agents() -> App {
        let mut app = App::new();
        app.replace_team(fixture_team(vec![
            agent("p:a", AgentState::Running),
            agent("p:b", AgentState::Running),
        ]));
        app.dismiss_splash();
        app.cycle_focus(); // Roster → Detail
        assert_eq!(app.focused_pane, Pane::Detail);
        assert_eq!(app.selected_agent, Some(0));
        app.enter_stream_keys();
        assert_eq!(app.stage, Stage::StreamKeys);
        app
    }

    #[test]
    fn ctrl_shift_down_moves_selection_to_next_agent_no_split() {
        // With no detail split focused, the switch chord steps the
        // roster selection forward, stays in StreamKeys, and consumes
        // the chord (no stray keystroke forwarded to the pane).
        use crate::keysender::test_support::MockKeySender;
        let mut app = stream_keys_fixture_two_agents();
        let ks = MockKeySender::default();
        stream_dispatch(
            &mut app,
            key_with(KeyCode::Down, KeyModifiers::CONTROL | KeyModifiers::SHIFT),
            &ks,
        );
        assert_eq!(app.selected_agent, Some(1), "switched to next agent");
        assert_eq!(app.stage, Stage::StreamKeys, "stays in stream-keys");
        assert!(
            ks.calls.lock().unwrap().is_empty(),
            "the switch chord never forwards a keystroke"
        );
    }

    #[test]
    fn ctrl_shift_up_moves_selection_to_prev_agent_no_split() {
        // Mirror of the Down case: Ctrl+Shift+Up steps the selection
        // backward (wrapping from agent 0 to the last agent).
        use crate::keysender::test_support::MockKeySender;
        let mut app = stream_keys_fixture_two_agents();
        let ks = MockKeySender::default();
        stream_dispatch(
            &mut app,
            key_with(KeyCode::Up, KeyModifiers::CONTROL | KeyModifiers::SHIFT),
            &ks,
        );
        assert_eq!(
            app.selected_agent,
            Some(1),
            "Up from agent 0 wraps to the last agent"
        );
        assert_eq!(app.stage, Stage::StreamKeys);
        assert!(ks.calls.lock().unwrap().is_empty());
    }

    #[test]
    fn ctrl_shift_switch_no_op_when_split_focused() {
        // Regression guard (the bug this PR fixes): with a detail split
        // focused, `stream_target_session` routes to the split's agent,
        // not `selected_agent`. Moving the roster selection there would
        // desync the banner/detail from where keystrokes actually land,
        // so the chord is consumed as a pure no-op: selection unchanged,
        // stage unchanged, and nothing forwarded to the pane.
        use crate::keysender::test_support::MockKeySender;
        for code in [KeyCode::Up, KeyCode::Down] {
            let mut app = stream_keys_fixture_two_agents();
            // Push a split for `p:b` and focus it (cell 0 = focused
            // agent, cell 1 = first split).
            app.detail_splits
                .push(("p:b".into(), SplitOrientation::Vertical));
            app.selected_split = 1;
            let ks = MockKeySender::default();
            stream_dispatch(
                &mut app,
                key_with(code, KeyModifiers::CONTROL | KeyModifiers::SHIFT),
                &ks,
            );
            assert_eq!(
                app.selected_agent,
                Some(0),
                "split focused → selection must not move ({code:?})"
            );
            assert_eq!(app.stage, Stage::StreamKeys);
            assert!(
                ks.calls.lock().unwrap().is_empty(),
                "split-focused switch chord is consumed, not forwarded ({code:?})"
            );
        }
    }

    #[test]
    fn ctrl_shift_switch_single_agent_is_no_op() {
        // Single-agent team: stepping forward wraps back to index 0,
        // so the selection is effectively unchanged. No panic, and the
        // chord still doesn't leak a keystroke to the pane.
        use crate::keysender::test_support::MockKeySender;
        let mut app = stream_keys_fixture(); // one agent, StreamKeys-capable
        app.enter_stream_keys();
        assert_eq!(app.selected_agent, Some(0));
        let ks = MockKeySender::default();
        stream_dispatch(
            &mut app,
            key_with(KeyCode::Down, KeyModifiers::CONTROL | KeyModifiers::SHIFT),
            &ks,
        );
        assert_eq!(app.selected_agent, Some(0), "single agent → stays at 0");
        assert_eq!(app.stage, Stage::StreamKeys);
        assert!(ks.calls.lock().unwrap().is_empty());
    }

    // ── T-199: detail-pane → inner-tmux size sync ───────────────────

    fn pane_sync_fixture() -> App {
        let mut app = App::new();
        app.team = fixture_team(vec![
            agent("hello:mgr", AgentState::Running),
            agent("hello:dev", AgentState::Running),
        ]);
        app.selected_agent = Some(0);
        app.stage = Stage::Triptych;
        app.layout = MainLayout::Triptych;
        app
    }

    #[test]
    fn sync_fires_resize_on_first_frame() {
        let mut app = pane_sync_fixture();
        let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
        sync_focused_pane_size_to(
            &mut app,
            ratatui::layout::Rect::new(0, 0, 120, 40),
            &resizer,
        );
        let calls = resizer.calls.lock().unwrap();
        // First frame: cache empty, expect one call for the focused
        // session (mgr) at the typical 120×40 Triptych Detail rect.
        assert_eq!(calls.len(), 1);
        assert_eq!(calls[0].0, "t-hello-mgr");
        assert_eq!(calls[0].1, 90); // inner = (120 - 28 sidebar) - 2 border
        assert_eq!(calls[0].2, 22); // inner = (3/5 of 40) - 2 border
    }

    #[test]
    fn sync_skips_when_size_unchanged() {
        let mut app = pane_sync_fixture();
        let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
        // Two frames at identical size → only the first should fire.
        sync_focused_pane_size_to(
            &mut app,
            ratatui::layout::Rect::new(0, 0, 120, 40),
            &resizer,
        );
        sync_focused_pane_size_to(
            &mut app,
            ratatui::layout::Rect::new(0, 0, 120, 40),
            &resizer,
        );
        assert_eq!(resizer.calls.lock().unwrap().len(), 1);
    }

    #[test]
    fn sync_fires_again_when_terminal_resizes() {
        let mut app = pane_sync_fixture();
        let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
        sync_focused_pane_size_to(
            &mut app,
            ratatui::layout::Rect::new(0, 0, 120, 40),
            &resizer,
        );
        // Operator resized the host terminal.
        sync_focused_pane_size_to(
            &mut app,
            ratatui::layout::Rect::new(0, 0, 200, 60),
            &resizer,
        );
        let calls = resizer.calls.lock().unwrap();
        assert_eq!(calls.len(), 2);
        assert_eq!(calls[0].1, 90); // (120 - 28) - 2 border
        assert_eq!(calls[0].2, 22); // (3/5 of 40) - 2 border
        assert_eq!(calls[1].1, 170); // (200 - 28) - 2 border
                                     // Height = (3/5 of 60) - 2 border = 34.
        assert_eq!(calls[1].2, 34);
    }

    #[test]
    fn sync_fires_on_focus_switch_to_unsynced_session() {
        let mut app = pane_sync_fixture();
        let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
        sync_focused_pane_size_to(
            &mut app,
            ratatui::layout::Rect::new(0, 0, 120, 40),
            &resizer,
        );
        // Operator switched focus to the dev agent.
        app.selected_agent = Some(1);
        sync_focused_pane_size_to(
            &mut app,
            ratatui::layout::Rect::new(0, 0, 120, 40),
            &resizer,
        );
        let calls = resizer.calls.lock().unwrap();
        assert_eq!(calls.len(), 2);
        assert_eq!(calls[0].0, "t-hello-mgr");
        assert_eq!(calls[1].0, "t-hello-dev");
    }

    #[test]
    fn sync_is_noop_when_no_agent_focused() {
        let mut app = pane_sync_fixture();
        app.selected_agent = None;
        let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
        sync_focused_pane_size_to(
            &mut app,
            ratatui::layout::Rect::new(0, 0, 120, 40),
            &resizer,
        );
        assert!(resizer.calls.lock().unwrap().is_empty());
    }

    #[test]
    fn sync_is_noop_when_layout_is_not_triptych() {
        let mut app = pane_sync_fixture();
        app.layout = MainLayout::Wall;
        let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
        sync_focused_pane_size_to(
            &mut app,
            ratatui::layout::Rect::new(0, 0, 120, 40),
            &resizer,
        );
        // Wall / MailboxFirst use different geometry; out of scope for
        // T-199. No tmux resize-pane should fire from this path.
        assert!(resizer.calls.lock().unwrap().is_empty());
    }

    #[test]
    fn sync_is_noop_on_degenerate_terminal_area() {
        let mut app = pane_sync_fixture();
        let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
        // Width is exactly the sidebar (28) → Detail rect is zero.
        sync_focused_pane_size_to(&mut app, ratatui::layout::Rect::new(0, 0, 28, 40), &resizer);
        assert!(resizer.calls.lock().unwrap().is_empty());
    }

    #[test]
    fn sync_accounts_for_approvals_stripe_when_present() {
        let mut app = pane_sync_fixture();
        // Force the approvals-stripe path: one pending approval.
        app.pending_approvals = vec![crate::approvals::Approval {
            id: 1,
            project_id: "hello".into(),
            agent_id: "hello:dev".into(),
            action: "test".into(),
            summary: "test approval".into(),
            payload_json: String::new(),
        }];
        assert!(app.has_pending_approvals());
        let resizer = crate::pane_resize::test_support::MockPaneResizer::default();
        sync_focused_pane_size_to(
            &mut app,
            ratatui::layout::Rect::new(0, 0, 120, 40),
            &resizer,
        );
        let calls = resizer.calls.lock().unwrap();
        // Stripe consumes one row → Detail height 3/5 of 39 = 23 outer,
        // minus 2 border = 21 inner.
        assert_eq!(calls.len(), 1);
        assert_eq!(calls[0].2, 21);
    }

    // T-131 PR-2: mailbox filter/search input-mode integration tests.
    // Drive real key events through `handle_event` to pin the
    // dispatch contract (input arms at the top of the Triptych match,
    // openers gated on Pane::Mailbox).

    fn app_with_mailbox_focused() -> App {
        let mut app = App::new();
        app.dismiss_splash();
        // Cycle focus to the Mailbox pane (Roster → Detail → Mailbox).
        app.cycle_focus();
        app.cycle_focus();
        assert_eq!(app.focused_pane, Pane::Mailbox);
        app
    }

    #[test]
    fn f_opens_filter_input_when_mailbox_focused() {
        let mut app = app_with_mailbox_focused();
        assert!(app.mailbox_input_mode.is_none());
        dispatch(&mut app, key(KeyCode::Char('f')));
        assert_eq!(app.mailbox_input_mode, Some(MailboxInputKind::Filter));
    }

    #[test]
    fn slash_opens_search_input_when_mailbox_focused() {
        let mut app = app_with_mailbox_focused();
        dispatch(&mut app, key(KeyCode::Char('/')));
        assert_eq!(app.mailbox_input_mode, Some(MailboxInputKind::Search));
    }

    #[test]
    fn f_does_not_open_filter_when_roster_focused() {
        // The opener is Pane::Mailbox-gated so it stays unsurprising
        // in other panes (where `f` has no meaning today, but the
        // guard keeps us out of trouble if it later picks up one).
        let mut app = App::new();
        app.dismiss_splash();
        assert_eq!(app.focused_pane, Pane::Roster);
        dispatch(&mut app, key(KeyCode::Char('f')));
        assert!(app.mailbox_input_mode.is_none());
    }

    #[test]
    fn typing_into_filter_input_mutates_active_tab_buffer() {
        let mut app = app_with_mailbox_focused();
        dispatch(&mut app, key(KeyCode::Char('f')));
        dispatch(&mut app, key(KeyCode::Char('a')));
        dispatch(&mut app, key(KeyCode::Char('d')));
        dispatch(&mut app, key(KeyCode::Char('a')));
        assert_eq!(app.mailbox.filter_text(app.mailbox_tab), "ada");
        // Sibling tab's filter must remain empty (per-tab independence).
        assert_eq!(app.mailbox.filter_text(MailboxTab::Sent), "");
    }

    #[test]
    fn backspace_pops_input_buffer() {
        let mut app = app_with_mailbox_focused();
        dispatch(&mut app, key(KeyCode::Char('/')));
        for c in "abc".chars() {
            dispatch(&mut app, key(KeyCode::Char(c)));
        }
        assert_eq!(app.mailbox.search_text(app.mailbox_tab), "abc");
        dispatch(&mut app, key(KeyCode::Backspace));
        assert_eq!(app.mailbox.search_text(app.mailbox_tab), "ab");
    }

    #[test]
    fn enter_confirms_keeps_typed_text() {
        let mut app = app_with_mailbox_focused();
        dispatch(&mut app, key(KeyCode::Char('f')));
        for c in "kian".chars() {
            dispatch(&mut app, key(KeyCode::Char(c)));
        }
        dispatch(&mut app, key(KeyCode::Enter));
        assert!(
            app.mailbox_input_mode.is_none(),
            "input must close on Enter"
        );
        assert_eq!(
            app.mailbox.filter_text(app.mailbox_tab),
            "kian",
            "Enter must keep the typed text (confirm-keep semantics)"
        );
    }

    #[test]
    fn esc_cancels_reverts_to_snapshot() {
        let mut app = app_with_mailbox_focused();
        // Seed a prior filter so the snapshot has something to restore.
        app.mailbox
            .set_input(app.mailbox_tab, MailboxInputKind::Filter, "previous".into());
        dispatch(&mut app, key(KeyCode::Char('f')));
        // Now overwrite via typing.
        dispatch(&mut app, key(KeyCode::Backspace));
        dispatch(&mut app, key(KeyCode::Backspace));
        dispatch(&mut app, key(KeyCode::Char('x')));
        assert_eq!(app.mailbox.filter_text(app.mailbox_tab), "previox");
        // Esc → revert.
        dispatch(&mut app, key(KeyCode::Esc));
        assert!(app.mailbox_input_mode.is_none());
        assert_eq!(
            app.mailbox.filter_text(app.mailbox_tab),
            "previous",
            "Esc must revert the active buffer to the pre-open snapshot"
        );
    }

    #[test]
    fn open_input_swallows_pr1_cursor_keys() {
        // While input is open, Up/Down/j/k/PageUp/PageDown/Home/End
        // must NOT move the row cursor — they're swallowed by the
        // input-mode catchall arm.
        let mut app = app_with_mailbox_focused();
        // Seed buffers so the cursor has somewhere to move.
        app.mailbox.extend(
            app.mailbox_tab,
            (1..=10)
                .map(|i| crate::mailbox::MessageRow {
                    id: i,
                    sender: "p:a".into(),
                    recipient: "p:dev".into(),
                    text: "x".into(),
                    sent_at: 0.0,
                })
                .collect(),
        );
        let seated = app.mailbox.cursor(app.mailbox_tab).selected_idx;
        assert_eq!(seated, 9, "extend seats cursor at tail (PR-1 contract)");
        // Open filter, then try to move the cursor — must not move.
        dispatch(&mut app, key(KeyCode::Char('f')));
        dispatch(&mut app, key(KeyCode::Up));
        dispatch(&mut app, key(KeyCode::PageUp));
        dispatch(&mut app, key(KeyCode::Home));
        // Cursor still at 9 (input ate `f` but those chars are part
        // of the filter buffer; Up/PageUp/Home are swallowed).
        // Note: typing `f` opens, but Up/PageUp/Home are not Char(_)
        // so they hit the catchall swallow arm.
        assert_eq!(app.mailbox.cursor(app.mailbox_tab).selected_idx, 9);
    }

    #[test]
    fn ctrl_modifier_char_does_not_inject_into_input() {
        // qa #335 nit 2: Ctrl+W / Ctrl+C / Alt+Char while filter is
        // open must NOT land their literal char in the buffer.
        // Modifier combos fall through the Char-arm's guard and hit
        // the swallow arm — operator can still type plain `w` to
        // search for that char.
        let mut app = app_with_mailbox_focused();
        dispatch(&mut app, key(KeyCode::Char('f'))); // open filter
        dispatch(
            &mut app,
            key_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
        );
        dispatch(
            &mut app,
            key_with(KeyCode::Char('c'), KeyModifiers::CONTROL),
        );
        dispatch(&mut app, key_with(KeyCode::Char('a'), KeyModifiers::ALT));
        assert_eq!(
            app.mailbox.filter_text(app.mailbox_tab),
            "",
            "modifier+Char combos must not leak into the filter buffer"
        );
        // Plain Char (no modifier) still types — sanity that the
        // guard didn't lock everyone out.
        dispatch(&mut app, key(KeyCode::Char('w')));
        assert_eq!(app.mailbox.filter_text(app.mailbox_tab), "w");
        // Shift+Char (capital letter shape) also types — Shift is
        // explicitly allowed in the guard.
        dispatch(&mut app, key_with(KeyCode::Char('X'), KeyModifiers::SHIFT));
        assert_eq!(app.mailbox.filter_text(app.mailbox_tab), "wX");
    }

    #[test]
    fn open_input_swallows_q_quit() {
        // The killer test: pressing `q` while filter is open MUST go
        // into the filter buffer, NOT trigger the quit confirm.
        // (Char(c)-with-input-mode-guard arm placed BEFORE
        // `Char('q')` quit arm in match order.)
        let mut app = app_with_mailbox_focused();
        dispatch(&mut app, key(KeyCode::Char('f')));
        dispatch(&mut app, key(KeyCode::Char('q')));
        assert_eq!(
            app.stage,
            Stage::Triptych,
            "q must NOT trigger quit while input is open"
        );
        assert_eq!(
            app.mailbox.filter_text(app.mailbox_tab),
            "q",
            "q must land in the filter buffer"
        );
    }

    // T-131 PR-3: mailbox detail modal — open/close/scroll +
    // snapshot-at-open contract.

    fn seed_inbox_rows(app: &mut App, n: i64) {
        let rows: Vec<MessageRow> = (1..=n)
            .map(|i| MessageRow {
                id: i,
                sender: "p:dev".into(),
                recipient: "p:mgr".into(),
                text: format!("body #{i}"),
                sent_at: 1_700_000_000.0 + i as f64,
            })
            .collect();
        app.mailbox.extend(MailboxTab::Inbox, rows);
    }

    #[test]
    fn enter_on_mailbox_opens_detail_modal_with_snapshot() {
        let mut app = app_with_mailbox_focused();
        seed_inbox_rows(&mut app, 5);
        // Cursor seats at tail (row id 5) per PR-1 contract.
        dispatch(&mut app, key(KeyCode::Enter));
        assert_eq!(app.stage, Stage::MailboxDetailModal);
        let snap = app.mailbox_detail_modal.as_ref().expect("modal open");
        assert_eq!(snap.id, 5);
        assert_eq!(snap.text, "body #5");
        assert_eq!(app.mailbox_detail_scroll, 0, "scroll resets on open");
    }

    #[test]
    fn enter_on_empty_visible_indices_is_noop() {
        // Filter to nothing → Enter must NOT flip stage or snapshot.
        let mut app = app_with_mailbox_focused();
        seed_inbox_rows(&mut app, 3);
        app.mailbox.set_input(
            MailboxTab::Inbox,
            MailboxInputKind::Filter,
            "no-such-sender".into(),
        );
        assert!(app.mailbox.visible_indices(MailboxTab::Inbox).is_empty());
        dispatch(&mut app, key(KeyCode::Enter));
        assert_eq!(app.stage, Stage::Triptych);
        assert!(app.mailbox_detail_modal.is_none());
    }

    #[test]
    fn snapshot_stable_across_underlying_drain() {
        // The variant-(a) killer test: open modal on row id 3, then
        // drain the buffer past it. Modal still renders id 3 because
        // the snapshot owns the content, not the underlying buffer.
        let mut app = app_with_mailbox_focused();
        seed_inbox_rows(&mut app, 5);
        app.mailbox.cursor_home(MailboxTab::Inbox);
        app.mailbox.move_cursor_down(MailboxTab::Inbox);
        app.mailbox.move_cursor_down(MailboxTab::Inbox); // selected_idx = 2 → row id 3
        dispatch(&mut app, key(KeyCode::Enter));
        let snap_id = app.mailbox_detail_modal.as_ref().expect("open").id;
        assert_eq!(snap_id, 3);
        // Now drain the front by pushing enough rows to trim past
        // row id 3. MAX_TAB_ROWS = 500.
        let more: Vec<MessageRow> = (6..=600)
            .map(|i| MessageRow {
                id: i,
                sender: "p:dev".into(),
                recipient: "p:mgr".into(),
                text: format!("body #{i}"),
                sent_at: 1_700_000_000.0 + i as f64,
            })
            .collect();
        app.mailbox.extend(MailboxTab::Inbox, more);
        // The original row id 3 should no longer be in the buffer.
        let still_there = app
            .mailbox
            .rows(MailboxTab::Inbox)
            .iter()
            .any(|r| r.id == 3);
        assert!(!still_there, "row id 3 must have been drained");
        // But the modal snapshot is unchanged — operator sees the
        // message they clicked, full stop.
        let snap = app.mailbox_detail_modal.as_ref().expect("still open");
        assert_eq!(snap.id, 3, "snapshot id must survive underlying drain");
        assert_eq!(snap.text, "body #3");
    }

    #[test]
    fn esc_closes_detail_modal() {
        let mut app = app_with_mailbox_focused();
        seed_inbox_rows(&mut app, 3);
        dispatch(&mut app, key(KeyCode::Enter));
        assert_eq!(app.stage, Stage::MailboxDetailModal);
        dispatch(&mut app, key(KeyCode::Esc));
        assert_eq!(app.stage, Stage::Triptych);
        assert!(app.mailbox_detail_modal.is_none());
    }

    #[test]
    fn q_closes_detail_modal() {
        let mut app = app_with_mailbox_focused();
        seed_inbox_rows(&mut app, 3);
        dispatch(&mut app, key(KeyCode::Enter));
        dispatch(&mut app, key(KeyCode::Char('q')));
        assert_eq!(app.stage, Stage::Triptych);
        assert!(app.mailbox_detail_modal.is_none());
    }

    #[test]
    fn j_and_k_scroll_body_in_modal() {
        let mut app = app_with_mailbox_focused();
        seed_inbox_rows(&mut app, 3);
        dispatch(&mut app, key(KeyCode::Enter));
        assert_eq!(app.mailbox_detail_scroll, 0);
        dispatch(&mut app, key(KeyCode::Char('j')));
        dispatch(&mut app, key(KeyCode::Char('j')));
        dispatch(&mut app, key(KeyCode::Down));
        assert_eq!(app.mailbox_detail_scroll, 3);
        dispatch(&mut app, key(KeyCode::Char('k')));
        dispatch(&mut app, key(KeyCode::Up));
        assert_eq!(app.mailbox_detail_scroll, 1);
        // Saturating: more `k`s than current offset clamp at 0.
        for _ in 0..10 {
            dispatch(&mut app, key(KeyCode::Char('k')));
        }
        assert_eq!(app.mailbox_detail_scroll, 0);
    }

    #[test]
    fn unrelated_keys_swallowed_in_modal() {
        // While modal open, `f` / `/` / `Tab` must not trigger their
        // Triptych meanings — the modal owns the stage.
        let mut app = app_with_mailbox_focused();
        seed_inbox_rows(&mut app, 3);
        dispatch(&mut app, key(KeyCode::Enter));
        assert_eq!(app.stage, Stage::MailboxDetailModal);
        let focused_before = app.focused_pane;
        dispatch(&mut app, key(KeyCode::Char('f')));
        dispatch(&mut app, key(KeyCode::Char('/')));
        dispatch(&mut app, key(KeyCode::Tab));
        assert_eq!(app.stage, Stage::MailboxDetailModal, "stage stays");
        assert!(app.mailbox_input_mode.is_none(), "filter/search not opened");
        assert_eq!(
            app.focused_pane, focused_before,
            "Tab must not cycle panes underneath an open modal"
        );
    }
}
