Skip to main content

teamctl_ui/
app.rs

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