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