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