Skip to main content

teamctl_ui/
app.rs

1//! App state and the top-level run loop.
2//!
3//! Three stages today: `Splash` (figlet logo for ~3s or until first
4//! key), `Triptych` (the default read view, now backed by a live
5//! team snapshot from PR-UI-2), and `QuitConfirm` (a modal asking
6//! "really?"). Subsequent stacked PRs bolt on more modals and the
7//! layout variants from SPEC §3 — those wire in by adding `Stage`
8//! variants and dispatching from `draw`/`handle_event`, no
9//! rearchitecting.
10
11use std::time::{Duration, Instant};
12
13use anyhow::Result;
14use crossterm::event::{self, Event, KeyCode, KeyEventKind};
15use ratatui::backend::Backend;
16use ratatui::buffer::Buffer;
17use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
18use ratatui::style::{Modifier, Style};
19use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget};
20use ratatui::{Frame, Terminal};
21
22use crate::approvals::{
23    Approval, ApprovalDecider, ApprovalSource, BrokerApprovalSource, CliApprovalDecider, Decision,
24};
25use crate::compose::{CliMessageSender, ComposeTarget, Editor, EditorAction, MessageSender};
26use crate::data::TeamSnapshot;
27use crate::keysender::{encode_key, KeySender, ScrollDirection, TmuxKeySender};
28use crate::layouts;
29use crate::mailbox::{BrokerMailboxSource, MailboxBuffers, MailboxSource, MailboxTab};
30use crate::pane::{PaneSource, TmuxPaneSource};
31use crate::splash;
32use crate::statusline;
33use crate::theme::{detect_capabilities, Capabilities};
34use crate::triptych::{self, MainLayout, Pane};
35use crate::tutorial;
36use crate::watch::Watch;
37
38const SPLASH_AUTO_DISMISS: Duration = Duration::from_secs(3);
39const POLL_INTERVAL: Duration = Duration::from_millis(50);
40/// How often the team snapshot + detail-pane capture get refreshed.
41/// PR-UI-2 polls; PR-UI-3 may upgrade to event subscriptions.
42const REFRESH_INTERVAL: Duration = Duration::from_secs(1);
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum Stage {
46    Splash,
47    Triptych,
48    QuitConfirm,
49    /// Approvals modal — opens on `a` (only when there's a
50    /// pending approval), routes Approve/Deny via the existing
51    /// `teamctl approve|deny` CLI so T-031's `delivered_at`
52    /// contract stays honored.
53    ApprovalsModal,
54    /// Compose modal — opens on `@` (DM-to-focused-agent) or `!`
55    /// (broadcast-to-current-channel). Routes through `teamctl
56    /// send|broadcast` so the channel-ACL + ratelimit + delivery
57    /// hooks the CLI already runs through ride for free.
58    ComposeModal,
59    /// `?` help overlay — modal listing every chord registered in
60    /// `help::ALL_GROUPS`. Read-only; closes on Esc / `?`.
61    HelpOverlay,
62    /// Onboarding tutorial walkthrough. Auto-opens on first
63    /// launch (per-team sentinel at
64    /// `.team/state/ui-tutorial-completed`); reopenable via `t`
65    /// from any non-modal state.
66    Tutorial,
67    /// Stream-keys mode (T-108). Activated by `Ctrl+E` while the
68    /// detail pane is focused; every subsequent keystroke (except
69    /// `Esc`, the exit chord) is forwarded to the focused agent's
70    /// tmux pane via `tmux send-keys`. The Triptych keeps rendering
71    /// underneath — the 1s refresh tick still captures whatever the
72    /// agent prints in response — so the operator interacts with
73    /// the agent in real time without leaving the UI.
74    StreamKeys,
75}
76
77/// Splitscreen orientation per detail-pane split (PR-UI-7 lift
78/// of PR-UI-6's deferred Q1). `Vertical` subdivides side-by-side
79/// (Ctrl+|); `Horizontal` stacks top-to-bottom (Ctrl+-).
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum SplitOrientation {
82    Vertical,
83    Horizontal,
84}
85
86pub struct App {
87    pub stage: Stage,
88    /// Tracked so QuitConfirm can return to whichever stage opened it.
89    pub previous_stage: Stage,
90    pub focused_pane: Pane,
91    pub team: TeamSnapshot,
92    /// Index into `team.agents` of the agent the detail pane is
93    /// streaming. `None` when the team is empty or roster
94    /// navigation hasn't picked one yet.
95    pub selected_agent: Option<usize>,
96    /// Lines from the most recent pane capture. Bounded to the last
97    /// `MAX_DETAIL_LINES` so the buffer doesn't grow unboundedly
98    /// over a long-running session.
99    pub detail_buffer: Vec<String>,
100    pub version: &'static str,
101    pub capabilities: Capabilities,
102    pub splash_started: Instant,
103    /// Last time the snapshot + pane capture were refreshed. Used by
104    /// `tick()` to gate the next refresh.
105    pub last_refresh: Instant,
106    pub running: bool,
107    /// First-launch detection — when the marker file exists, future
108    /// stacked-PRs (PR-UI-7) skip the tutorial after splash. PR-UI-1
109    /// only reads the flag; nothing routes off it yet.
110    pub tutorial_completed: bool,
111    /// Active tab inside the mailbox pane (PR-UI-3). Walked with
112    /// `←` / `→` when `focused_pane == Mailbox` (T-124 hard-swapped
113    /// the prior `[` / `]` chord for arrow keys; T-074 bug 6 is
114    /// the gating-on-focus invariant). `Tab` always cycles pane
115    /// focus, never mailbox tabs — the previous "Tab cycles tabs
116    /// when mailbox is focused" shape stranded operators inside
117    /// the mailbox.
118    pub mailbox_tab: MailboxTab,
119    /// Per-tab buffers + cursors for the focused agent's mailbox
120    /// view. Reset whenever the focused agent changes — switching
121    /// agents starts the operator at the head of fresh traffic.
122    pub mailbox: MailboxBuffers,
123    /// Pending approvals snapshot (PR-UI-4). Drives the conditional
124    /// stripe at the top of Triptych and the modal opened by `a`.
125    pub pending_approvals: Vec<Approval>,
126    /// Index into `pending_approvals` of the row the modal is
127    /// currently showing. Reset to 0 each time the modal opens;
128    /// `j` / `k` (or `↑` / `↓`) cycle.
129    pub selected_approval: usize,
130    /// Last error from a CLI-routed Approve/Deny call — surfaced
131    /// inline in the modal so the operator sees why a decision
132    /// didn't take.
133    pub approval_error: Option<String>,
134    /// Open compose target — `Some` while `Stage::ComposeModal`
135    /// is the active stage, `None` otherwise. Stored on App so
136    /// the editor's contents survive rerenders.
137    pub compose_target: Option<ComposeTarget>,
138    /// Editor backing the compose modal. Reset to `default()` each
139    /// time the modal opens so an old draft from a prior
140    /// invocation can't leak into a new send.
141    pub compose_editor: Editor,
142    /// Last error from a CLI-routed send call — surfaced inline
143    /// in the modal so the operator sees rate-limit / ACL-block
144    /// errors without leaving the UI.
145    pub compose_error: Option<String>,
146    /// Active main-view layout (PR-UI-6). Triptych is the default;
147    /// `Ctrl+W` toggles Wall, `Ctrl+M` toggles MailboxFirst.
148    pub layout: MainLayout,
149    /// Top-of-window agent index for the Wall view's vertical
150    /// scroll. SPEC §3 caps visible tiles at 4; this offsets which
151    /// 4-agent window is shown when the team has more.
152    pub wall_scroll: usize,
153    /// Selected channel index (into `team.channels`) for the
154    /// MailboxFirst layout's channel list and for the broadcast
155    /// picker. `None` until the operator picks one.
156    pub selected_channel: Option<usize>,
157    /// Splits within Triptych's detail pane (PR-UI-6). When
158    /// non-empty, the detail pane subdivides; each entry pairs an
159    /// agent id with the per-split orientation (PR-UI-7 lift of
160    /// the Q1 deferral). `selected_split` is the vim-window-motion
161    /// focus.
162    pub detail_splits: Vec<(String, SplitOrientation)>,
163    pub selected_split: usize,
164    /// Chord-prefix machine for `Ctrl+W` follow-ups (PR-UI-7 lift
165    /// of PR-UI-6's `Ctrl+Q` alias). When `Some(KeyCode::Char('w'))`,
166    /// the next key is interpreted as a `Ctrl+W` follow: `q` =
167    /// close split, `o` = close others. Cleared on any unrelated
168    /// keypress so a typo doesn't leave the editor stuck.
169    pub pending_chord: Option<KeyCode>,
170    /// `true` when the operator's first launch on this team has
171    /// not yet completed the tutorial — drives the auto-open after
172    /// splash. Reset to `false` on tutorial completion.
173    pub tutorial_pending_for_team: bool,
174    /// Brand-spinner frame counter (PR-UI-7). Bumped each refresh
175    /// tick so the statusline indicator shows the app is alive.
176    pub spinner_frame: usize,
177    /// Tutorial step cursor (PR-UI-7). Index into
178    /// `onboarding::STEPS`; reset to 0 when the tutorial reopens.
179    pub tutorial_step: usize,
180    /// Modal substage for the broadcast channel picker (PR-UI-6).
181    /// When `true` the compose modal renders a picker over the
182    /// editor; selecting a channel populates `compose_target` and
183    /// drops back to the editor.
184    pub compose_picker_open: bool,
185    /// Picker selection cursor — index into `team.channels`.
186    pub compose_picker_index: usize,
187    /// T-32: when `true`, the compose modal renders a single-line
188    /// path-input overlay instead of the editor; Enter appends a
189    /// `📎 attachment: <path>` line to the editor body and closes the
190    /// overlay. Tab inside the editor opens it; Esc inside the
191    /// overlay cancels back to the editor (matches the picker
192    /// overlay's modal-vs-modal symmetry from PR-UI-6).
193    pub compose_attach_input_open: bool,
194    /// Single-line buffer for the path-input overlay. Reset on close
195    /// so a cancelled draft can't leak into the next attach attempt.
196    pub compose_attach_buffer: String,
197}
198
199const MAX_DETAIL_LINES: usize = 2000;
200
201impl App {
202    /// Construct an empty App — no team snapshot loaded. Used by
203    /// tests and as the splash-stage default. Production launch
204    /// goes through `App::launch()` which immediately runs an
205    /// initial `refresh()` so the splash screen already shows the
206    /// real team name + agent count.
207    pub fn new() -> Self {
208        Self {
209            stage: Stage::Splash,
210            previous_stage: Stage::Splash,
211            focused_pane: Pane::Roster,
212            team: TeamSnapshot::empty(std::path::PathBuf::new()),
213            selected_agent: None,
214            detail_buffer: Vec::new(),
215            version: env!("CARGO_PKG_VERSION"),
216            capabilities: detect_capabilities(),
217            splash_started: Instant::now(),
218            last_refresh: Instant::now() - REFRESH_INTERVAL,
219            running: true,
220            tutorial_completed: tutorial::is_completed(),
221            mailbox_tab: MailboxTab::Inbox,
222            mailbox: MailboxBuffers::default(),
223            pending_approvals: Vec::new(),
224            selected_approval: 0,
225            approval_error: None,
226            compose_target: None,
227            compose_editor: Editor::default(),
228            compose_error: None,
229            layout: MainLayout::Triptych,
230            wall_scroll: 0,
231            selected_channel: None,
232            detail_splits: Vec::new(),
233            selected_split: 0,
234            compose_picker_open: false,
235            compose_picker_index: 0,
236            compose_attach_input_open: false,
237            compose_attach_buffer: String::new(),
238            pending_chord: None,
239            tutorial_pending_for_team: false,
240            spinner_frame: 0,
241            tutorial_step: 0,
242        }
243    }
244
245    /// Per-tutorial-step cursor (used by Stage::Tutorial). Wraps
246    /// at the end so `t`-then-keys walks the full tour.
247    pub fn enter_help_overlay(&mut self) {
248        self.previous_stage = self.stage;
249        self.stage = Stage::HelpOverlay;
250    }
251    pub fn close_help_overlay(&mut self) {
252        self.stage = self.previous_stage;
253    }
254    pub fn enter_tutorial(&mut self) {
255        self.previous_stage = self.stage;
256        self.stage = Stage::Tutorial;
257        self.tutorial_step = 0;
258    }
259    pub fn close_tutorial(&mut self) {
260        self.stage = self.previous_stage;
261        self.tutorial_pending_for_team = false;
262        if !self.team.root.as_os_str().is_empty() {
263            let _ = crate::onboarding::mark_completed(&self.team.root);
264        }
265    }
266    pub fn tutorial_advance(&mut self) {
267        let len = crate::onboarding::STEPS.len();
268        if len == 0 {
269            self.close_tutorial();
270            return;
271        }
272        if self.tutorial_step + 1 >= len {
273            self.close_tutorial();
274        } else {
275            self.tutorial_step += 1;
276        }
277    }
278    pub fn tutorial_back(&mut self) {
279        self.tutorial_step = self.tutorial_step.saturating_sub(1);
280    }
281
282    pub fn toggle_wall_layout(&mut self) {
283        self.layout = self.layout.toggle_wall();
284    }
285    pub fn toggle_mailbox_first_layout(&mut self) {
286        self.layout = self.layout.toggle_mailbox_first();
287        // First entry into MailboxFirst seeds the channel cursor
288        // so the feed pane has something to render.
289        if matches!(self.layout, MainLayout::MailboxFirst) && self.selected_channel.is_none() {
290            self.selected_channel = if self.team.channels.is_empty() {
291                None
292            } else {
293                Some(0)
294            };
295        }
296    }
297    pub fn wall_scroll_up(&mut self) {
298        self.wall_scroll = self
299            .wall_scroll
300            .saturating_sub(crate::layouts::WALL_TILE_CAP);
301    }
302    pub fn wall_scroll_down(&mut self) {
303        let next = self.wall_scroll + crate::layouts::WALL_TILE_CAP;
304        if next < self.team.agents.len() {
305            self.wall_scroll = next;
306        }
307    }
308    pub fn select_next_channel(&mut self) {
309        if self.team.channels.is_empty() {
310            return;
311        }
312        self.selected_channel = Some(match self.selected_channel {
313            None => 0,
314            Some(i) => (i + 1) % self.team.channels.len(),
315        });
316    }
317    pub fn select_prev_channel(&mut self) {
318        if self.team.channels.is_empty() {
319            return;
320        }
321        self.selected_channel = Some(match self.selected_channel {
322            None | Some(0) => self.team.channels.len() - 1,
323            Some(i) => i - 1,
324        });
325    }
326
327    /// Add a split for the focused agent (or current selection)
328    /// to the detail pane. Cap at 4 splits per the SPEC §3 cap.
329    /// Add a vertical split (PR-UI-7). `Ctrl+|` calls this.
330    pub fn add_detail_split_vertical(&mut self) {
331        self.add_detail_split_with_orientation(SplitOrientation::Vertical);
332    }
333    /// Add a horizontal split (PR-UI-7). `Ctrl+-` calls this.
334    pub fn add_detail_split_horizontal(&mut self) {
335        self.add_detail_split_with_orientation(SplitOrientation::Horizontal);
336    }
337    fn add_detail_split_with_orientation(&mut self, orientation: SplitOrientation) {
338        let Some(id) = self.selected_agent_id() else {
339            return;
340        };
341        if self.detail_splits.len() >= 4 {
342            return;
343        }
344        self.detail_splits.push((id, orientation));
345        self.selected_split = self.detail_splits.len() - 1;
346    }
347    /// Back-compat shim — earlier PRs called the unsuffixed name.
348    /// Defaults to vertical (matching the most-common chord
349    /// `Ctrl+|`). Kept so the test surface PR-UI-6 pinned doesn't
350    /// drift.
351    pub fn add_detail_split(&mut self) {
352        self.add_detail_split_vertical();
353    }
354    pub fn close_focused_split(&mut self) {
355        if self.detail_splits.is_empty() {
356            return;
357        }
358        let i = self.selected_split.min(self.detail_splits.len() - 1);
359        self.detail_splits.remove(i);
360        self.selected_split = i.saturating_sub(1);
361    }
362    pub fn cycle_split_next(&mut self) {
363        if self.detail_splits.is_empty() {
364            return;
365        }
366        self.selected_split = (self.selected_split + 1) % self.detail_splits.len();
367    }
368    pub fn cycle_split_prev(&mut self) {
369        if self.detail_splits.is_empty() {
370            return;
371        }
372        self.selected_split = if self.selected_split == 0 {
373            self.detail_splits.len() - 1
374        } else {
375            self.selected_split - 1
376        };
377    }
378
379    /// Open the broadcast compose flow — picker first when at
380    /// least one channel is declared, else fall back to the
381    /// project's `all` channel (PR-UI-5 behaviour) on the
382    /// assumption that `all` always exists in production composes.
383    pub fn enter_compose_broadcast_with_picker(&mut self) {
384        if self.team.channels.is_empty() {
385            // Fall back to the PR-UI-5 default if no channels
386            // are declared yet — should only happen with a
387            // half-loaded snapshot.
388            self.enter_compose_broadcast();
389            return;
390        }
391        let project_id = self
392            .team
393            .channels
394            .first()
395            .map(|c| c.project_id.clone())
396            .unwrap_or_default();
397        self.previous_stage = self.stage;
398        self.stage = Stage::ComposeModal;
399        self.compose_target = Some(ComposeTarget::Broadcast {
400            channel_id: format!("{project_id}:all"),
401            project_id,
402        });
403        self.compose_editor = Editor::default();
404        self.compose_error = None;
405        self.compose_picker_open = true;
406        self.compose_picker_index = 0;
407    }
408    pub fn picker_next(&mut self) {
409        if self.team.channels.is_empty() {
410            return;
411        }
412        self.compose_picker_index = (self.compose_picker_index + 1) % self.team.channels.len();
413    }
414    pub fn picker_prev(&mut self) {
415        if self.team.channels.is_empty() {
416            return;
417        }
418        self.compose_picker_index = if self.compose_picker_index == 0 {
419            self.team.channels.len() - 1
420        } else {
421            self.compose_picker_index - 1
422        };
423    }
424    pub fn picker_confirm(&mut self) {
425        if let Some(ch) = self.team.channels.get(self.compose_picker_index) {
426            self.compose_target = Some(ComposeTarget::Broadcast {
427                channel_id: ch.id.clone(),
428                project_id: ch.project_id.clone(),
429            });
430        }
431        self.compose_picker_open = false;
432    }
433
434    /// T-32: open the path-input overlay. Resets the buffer so a
435    /// previously-cancelled draft can't carry over.
436    pub fn open_compose_attach_input(&mut self) {
437        self.compose_attach_input_open = true;
438        self.compose_attach_buffer.clear();
439    }
440
441    /// T-32: append a `📎 attachment: <path>` line to the compose
442    /// editor and close the overlay. The line lands as a fresh row
443    /// at the end of the body so the operator can edit it (or delete
444    /// it) before sending. Whitespace-only buffers are ignored — Tab
445    /// followed by Enter shouldn't insert an empty marker.
446    pub fn confirm_compose_attach_input(&mut self) {
447        let path = self.compose_attach_buffer.trim().to_string();
448        if !path.is_empty() {
449            let marker = format!("📎 attachment: {path}");
450            // The body's final-trailing-blank rule (Editor::body)
451            // strips empty trailing lines, so an empty last line
452            // doesn't matter — we always push the marker as a new
453            // line after current contents.
454            if let Some(last) = self.compose_editor.lines.last_mut() {
455                if !last.is_empty() {
456                    self.compose_editor.lines.push(marker);
457                } else {
458                    *last = marker;
459                }
460            } else {
461                self.compose_editor.lines.push(marker);
462            }
463            // Park the cursor at end of the new line so subsequent
464            // typing in Insert mode picks up after the marker.
465            self.compose_editor.cursor_row = self.compose_editor.lines.len() - 1;
466            self.compose_editor.cursor_col = self
467                .compose_editor
468                .lines
469                .last()
470                .map(|l| l.len())
471                .unwrap_or(0);
472        }
473        self.close_compose_attach_input();
474    }
475
476    pub fn close_compose_attach_input(&mut self) {
477        self.compose_attach_input_open = false;
478        self.compose_attach_buffer.clear();
479    }
480
481    pub fn cycle_mailbox_tab(&mut self) {
482        self.mailbox_tab = self.mailbox_tab.next();
483    }
484
485    pub fn cycle_mailbox_tab_back(&mut self) {
486        self.mailbox_tab = self.mailbox_tab.prev();
487    }
488
489    pub fn cycle_focus_back(&mut self) {
490        self.focused_pane = self.focused_pane.prev();
491    }
492
493    pub fn has_pending_approvals(&self) -> bool {
494        !self.pending_approvals.is_empty()
495    }
496
497    pub fn enter_approvals_modal(&mut self) {
498        if self.pending_approvals.is_empty() {
499            return;
500        }
501        self.previous_stage = self.stage;
502        self.stage = Stage::ApprovalsModal;
503        self.selected_approval = 0;
504        self.approval_error = None;
505    }
506
507    pub fn close_approvals_modal(&mut self) {
508        self.stage = self.previous_stage;
509        self.approval_error = None;
510    }
511
512    pub fn cycle_approval_next(&mut self) {
513        if self.pending_approvals.is_empty() {
514            return;
515        }
516        self.selected_approval = (self.selected_approval + 1) % self.pending_approvals.len();
517    }
518
519    pub fn cycle_approval_prev(&mut self) {
520        if self.pending_approvals.is_empty() {
521            return;
522        }
523        self.selected_approval = if self.selected_approval == 0 {
524            self.pending_approvals.len() - 1
525        } else {
526            self.selected_approval - 1
527        };
528    }
529
530    pub fn focused_approval(&self) -> Option<&Approval> {
531        self.pending_approvals.get(self.selected_approval)
532    }
533
534    /// Replace the pending-approvals list. Closes the modal when
535    /// the queue empties (no row to act on); preserves the modal
536    /// otherwise but clamps `selected_approval` into range so an
537    /// approval resolved out-of-band doesn't leave us pointing at
538    /// a stale index.
539    pub fn replace_approvals(&mut self, approvals: Vec<Approval>) {
540        self.pending_approvals = approvals;
541        if self.pending_approvals.is_empty() {
542            if matches!(self.stage, Stage::ApprovalsModal) {
543                self.close_approvals_modal();
544            }
545            self.selected_approval = 0;
546        } else if self.selected_approval >= self.pending_approvals.len() {
547            self.selected_approval = self.pending_approvals.len() - 1;
548        }
549    }
550
551    /// Apply a decision to the focused approval via the injected
552    /// decider. The decider routes through `teamctl approve|deny`
553    /// in production; tests inject a recorder. On success the row
554    /// gets removed from the local `pending_approvals` snapshot
555    /// optimistically — the next `refresh_approvals` will reconcile
556    /// against the broker.
557    pub fn apply_decision<D: ApprovalDecider>(&mut self, decider: &D, kind: Decision, note: &str) {
558        let Some(approval) = self.focused_approval().cloned() else {
559            return;
560        };
561        match decider.decide(&self.team.root, approval.id, kind, note) {
562            Ok(()) => {
563                self.pending_approvals.retain(|a| a.id != approval.id);
564                self.approval_error = None;
565                if self.pending_approvals.is_empty() {
566                    self.close_approvals_modal();
567                } else if self.selected_approval >= self.pending_approvals.len() {
568                    self.selected_approval = self.pending_approvals.len() - 1;
569                }
570            }
571            Err(err) => {
572                self.approval_error = Some(err.to_string());
573            }
574        }
575    }
576
577    /// Open the compose modal for the focused agent (if any). The
578    /// `@` chord. No-op when no agent is focused.
579    pub fn enter_compose_dm_for_focused(&mut self) {
580        let Some(info) = self
581            .selected_agent
582            .and_then(|i| self.team.agents.get(i))
583            .cloned()
584        else {
585            return;
586        };
587        self.previous_stage = self.stage;
588        self.stage = Stage::ComposeModal;
589        self.compose_target = Some(ComposeTarget::Dm {
590            agent_id: info.id.clone(),
591            project_id: info.project.clone(),
592        });
593        self.compose_editor = Editor::default();
594        self.compose_error = None;
595    }
596
597    /// Open the compose modal targeting the project's `all`
598    /// channel — the broadcast wire. The `!` chord. PR-UI-5 ships
599    /// with channel scoping limited to `all` (the Wire tab is the
600    /// only channel context the mailbox pane currently surfaces);
601    /// PR-UI-6's mailbox UI work will widen the scope to per-channel
602    /// targets when individual channels become first-class in the
603    /// pane.
604    pub fn enter_compose_broadcast(&mut self) {
605        let project_id = self
606            .selected_agent
607            .and_then(|i| self.team.agents.get(i))
608            .map(|a| a.project.clone())
609            .or_else(|| self.team.agents.first().map(|a| a.project.clone()));
610        let Some(project_id) = project_id else {
611            return;
612        };
613        let channel_id = format!("{project_id}:all");
614        self.previous_stage = self.stage;
615        self.stage = Stage::ComposeModal;
616        self.compose_target = Some(ComposeTarget::Broadcast {
617            channel_id,
618            project_id,
619        });
620        self.compose_editor = Editor::default();
621        self.compose_error = None;
622    }
623
624    pub fn close_compose_modal(&mut self) {
625        self.stage = self.previous_stage;
626        self.compose_target = None;
627        self.compose_editor = Editor::default();
628        self.compose_error = None;
629        // T-32: ensure the attach overlay state can't survive a
630        // close-and-reopen of the modal.
631        self.compose_attach_input_open = false;
632        self.compose_attach_buffer.clear();
633    }
634
635    /// Send the current compose body via the injected message
636    /// sender. Routes through `teamctl send|broadcast` in
637    /// production; tests inject a recorder. Closes the modal +
638    /// triggers a mailbox refresh on success; surfaces error
639    /// inline on failure.
640    pub fn apply_send<S: MessageSender, M: MailboxSource>(
641        &mut self,
642        sender: &S,
643        mailbox_source: &M,
644    ) {
645        let Some(target) = self.compose_target.clone() else {
646            return;
647        };
648        let body = self.compose_editor.body();
649        if body.is_empty() {
650            self.compose_error = Some("body is empty".into());
651            return;
652        }
653        let result = match &target {
654            ComposeTarget::Dm { agent_id, .. } => sender.send_dm(&self.team.root, agent_id, &body),
655            ComposeTarget::Broadcast { channel_id, .. } => {
656                sender.broadcast(&self.team.root, channel_id, &body)
657            }
658        };
659        match result {
660            Ok(()) => {
661                self.close_compose_modal();
662                // Refresh the mailbox so the just-sent row appears
663                // in the relevant tab on the next paint.
664                refresh_mailbox(self, mailbox_source);
665            }
666            Err(err) => {
667                self.compose_error = Some(err.to_string());
668            }
669        }
670    }
671
672    pub fn dismiss_splash(&mut self) {
673        if matches!(self.stage, Stage::Splash) {
674            self.stage = Stage::Triptych;
675            self.previous_stage = Stage::Triptych;
676        }
677    }
678
679    pub fn cycle_focus(&mut self) {
680        self.focused_pane = self.focused_pane.next();
681    }
682
683    /// Move roster selection up by one — wraps at the top. No-op
684    /// when the team is empty. Does not change `focused_pane`.
685    /// Resets mailbox buffers when the resulting agent id differs
686    /// from the prior selection — switching agents should start the
687    /// operator at the head of fresh traffic.
688    pub fn select_prev(&mut self) {
689        if self.team.agents.is_empty() {
690            self.selected_agent = None;
691            return;
692        }
693        let prior = self.selected_agent_id();
694        self.selected_agent = Some(match self.selected_agent {
695            None | Some(0) => self.team.agents.len() - 1,
696            Some(i) => i - 1,
697        });
698        if prior != self.selected_agent_id() {
699            self.mailbox.reset();
700        }
701    }
702
703    /// Move roster selection down by one — wraps at the bottom.
704    /// No-op when the team is empty.
705    pub fn select_next(&mut self) {
706        if self.team.agents.is_empty() {
707            self.selected_agent = None;
708            return;
709        }
710        let prior = self.selected_agent_id();
711        self.selected_agent = Some(match self.selected_agent {
712            None => 0,
713            Some(i) => (i + 1) % self.team.agents.len(),
714        });
715        if prior != self.selected_agent_id() {
716            self.mailbox.reset();
717        }
718    }
719
720    /// `<project>:<agent>` of the currently selected agent, if any.
721    pub fn selected_agent_id(&self) -> Option<String> {
722        self.selected_agent
723            .and_then(|i| self.team.agents.get(i))
724            .map(|a| a.id.clone())
725    }
726
727    pub fn enter_quit_confirm(&mut self) {
728        self.previous_stage = self.stage;
729        self.stage = Stage::QuitConfirm;
730    }
731
732    pub fn cancel_quit(&mut self) {
733        self.stage = self.previous_stage;
734    }
735
736    pub fn confirm_quit(&mut self) {
737        self.running = false;
738    }
739
740    /// Replace the team snapshot. Preserves the current selection
741    /// when the agent at that index still exists; otherwise resets
742    /// to the first agent (or `None` for an empty team). Resets the
743    /// mailbox buffers when the resulting agent id differs from the
744    /// prior selection — same agent-changed contract as
745    /// `select_next` / `select_prev`.
746    pub fn replace_team(&mut self, team: TeamSnapshot) {
747        let prior_id = self.selected_agent_id();
748        self.team = team;
749        self.selected_agent = match (prior_id.clone(), self.team.agents.is_empty()) {
750            (_, true) => None,
751            (Some(id), false) => self.team.agents.iter().position(|a| a.id == id).or(Some(0)),
752            (None, false) => Some(0),
753        };
754        if prior_id != self.selected_agent_id() {
755            self.mailbox.reset();
756        }
757    }
758
759    /// Return the focused agent's tmux session name, if any. Used
760    /// by the run loop to know which session to capture.
761    pub fn focused_session(&self) -> Option<&str> {
762        self.selected_agent
763            .and_then(|i| self.team.agents.get(i))
764            .map(|a| a.tmux_session.as_str())
765    }
766
767    /// Tmux session that stream-keys mode should target. Cell 0 of
768    /// the detail-pane split layout is always the focused agent;
769    /// cells 1..N are the entries in `detail_splits`. When the
770    /// operator has focused a non-zero split, route stream-keys to
771    /// that split's agent — that's the cell visually showing as the
772    /// focus ring, so it's the one the operator expects to type into.
773    pub fn stream_target_session(&self) -> Option<String> {
774        if self.detail_splits.is_empty() || self.selected_split == 0 {
775            return self.focused_session().map(|s| s.to_string());
776        }
777        let split_idx = self.selected_split - 1;
778        let agent_id = self.detail_splits.get(split_idx).map(|(id, _)| id)?;
779        self.team
780            .agents
781            .iter()
782            .find(|a| &a.id == agent_id)
783            .map(|a| a.tmux_session.clone())
784    }
785
786    /// Enter stream-keys mode. No-op unless an agent is selected —
787    /// without a target session there's nothing to forward to.
788    /// Caller is responsible for the focused-pane gate (entry chord
789    /// only fires from `focused_pane == Pane::Detail`).
790    pub fn enter_stream_keys(&mut self) {
791        if self.stream_target_session().is_none() {
792            return;
793        }
794        self.previous_stage = self.stage;
795        self.stage = Stage::StreamKeys;
796    }
797
798    /// Exit stream-keys mode and return to whichever stage opened it.
799    /// `Esc` is the only exit chord per the issue's recommendation —
800    /// every other key (including `Ctrl+C`) forwards to the agent.
801    pub fn exit_stream_keys(&mut self) {
802        self.stage = self.previous_stage;
803    }
804
805    /// Replace the detail buffer, clipped at the recent-line cap.
806    pub fn set_detail_buffer(&mut self, lines: Vec<String>) {
807        let len = lines.len();
808        let start = len.saturating_sub(MAX_DETAIL_LINES);
809        self.detail_buffer = lines[start..].to_vec();
810    }
811}
812
813impl Default for App {
814    fn default() -> Self {
815        Self::new()
816    }
817}
818
819/// Refresh the team snapshot + the focused agent's pane capture +
820/// the mailbox tabs (PR-UI-3). Pulled out so tests can drive a
821/// single tick deterministically against `MockPaneSource` and
822/// `MockMailboxSource` without going through the event loop.
823pub fn refresh<P: PaneSource, M: MailboxSource, A: ApprovalSource>(
824    app: &mut App,
825    pane_source: &P,
826    mailbox_source: &M,
827    approval_source: &A,
828) {
829    if let Ok(Some(snapshot)) = TeamSnapshot::discover_and_load() {
830        app.replace_team(snapshot);
831    }
832    if let Some(session) = app.focused_session().map(|s| s.to_string()) {
833        if let Ok(lines) = pane_source.capture(&session) {
834            app.set_detail_buffer(lines);
835        }
836    } else {
837        app.detail_buffer.clear();
838    }
839    refresh_mailbox(app, mailbox_source);
840    refresh_approvals(app, approval_source);
841    app.last_refresh = Instant::now();
842}
843
844/// Approvals-only refresh. Extracted on the same shape as
845/// `refresh_mailbox` — PR-UI-5+ can call it on its own cadence
846/// (e.g. in response to a `notify` signal) without re-running the
847/// heavier paths. Errors degrade to "no pending" so the stripe
848/// just hides on a transient broker read failure.
849pub fn refresh_approvals<A: ApprovalSource>(app: &mut App, approval_source: &A) {
850    let approvals = approval_source.pending().unwrap_or_default();
851    app.replace_approvals(approvals);
852}
853
854/// Mailbox-only refresh — extracted so PR-UI-4+ can call it on its
855/// own cadence (e.g. in response to a broker INSERT signal) without
856/// re-running the heavier compose + tmux capture path. PR-UI-3
857/// just calls it from the main `refresh` once per tick.
858pub fn refresh_mailbox<M: MailboxSource>(app: &mut App, mailbox_source: &M) {
859    let Some(agent_id) = app.selected_agent_id() else {
860        // No agent focused → nothing to fetch. Buffers were already
861        // reset on selection change so the empty-state hint shows.
862        return;
863    };
864    let project_id = app
865        .selected_agent
866        .and_then(|i| app.team.agents.get(i))
867        .map(|a| a.project.clone())
868        .unwrap_or_default();
869    if let Ok(batch) = mailbox_source.inbox(&agent_id, app.mailbox.inbox_after) {
870        app.mailbox.extend(MailboxTab::Inbox, batch);
871    }
872    if let Ok(batch) = mailbox_source.sent(&agent_id, app.mailbox.sent_after) {
873        app.mailbox.extend(MailboxTab::Sent, batch);
874    }
875    if let Ok(batch) = mailbox_source.channel_feed(&agent_id, app.mailbox.channel_after) {
876        app.mailbox.extend(MailboxTab::Channel, batch);
877    }
878    if let Ok(batch) = mailbox_source.wire(&project_id, app.mailbox.wire_after) {
879        app.mailbox.extend(MailboxTab::Wire, batch);
880    }
881}
882
883pub fn run<B: Backend>(terminal: &mut Terminal<B>) -> Result<()> {
884    let mut app = App::new();
885    let pane_source = TmuxPaneSource;
886    let decider = CliApprovalDecider;
887    let sender = CliMessageSender;
888    let key_sender = TmuxKeySender;
889    // First refresh resolves the team root; only then can we
890    // bring up the file-watcher, which keys on `<root>/state/`.
891    refresh_with_default_sources(&mut app, &pane_source);
892    let mut watch = Watch::try_new(&app.team.root.join("state"));
893    while app.running {
894        terminal.draw(|f| draw(f, &app))?;
895        if event::poll(POLL_INTERVAL)? {
896            // The mailbox source for handle_event mirrors the
897            // refresh path; the same db_path key avoids divergence
898            // between read + write fanout.
899            let db_path = app.team.root.join("state/mailbox.db");
900            let mailbox_source = BrokerMailboxSource::new(db_path);
901            handle_event(
902                &mut app,
903                event::read()?,
904                &decider,
905                &sender,
906                &mailbox_source,
907                &key_sender,
908            );
909        }
910        if matches!(app.stage, Stage::Splash) && app.splash_started.elapsed() >= SPLASH_AUTO_DISMISS
911        {
912            app.dismiss_splash();
913        }
914        // Refresh on either (a) deadline elapsed or (b) the
915        // notify-watcher said the broker DB changed. The watcher
916        // shaves the typical refresh latency from ~1s to ~50ms when
917        // the platform supports it; on platforms without notify
918        // support `take_dirty` always returns false and the
919        // deadline path is the only trigger (PR-UI-3 behaviour).
920        let dirty = watch.take_dirty();
921        if dirty || app.last_refresh.elapsed() >= REFRESH_INTERVAL {
922            let prior_root = app.team.root.clone();
923            refresh_with_default_sources(&mut app, &pane_source);
924            // Team root drifted (operator launched in a different
925            // tree) → swap the watcher to the new state dir.
926            if app.team.root != prior_root {
927                watch = Watch::try_new(&app.team.root.join("state"));
928            }
929        }
930    }
931    Ok(())
932}
933
934/// Build the production `BrokerMailboxSource` + `BrokerApprovalSource`
935/// from the current team root and run a refresh with all three
936/// default sources. Lives here (rather than inline in `run`) so
937/// the team-root → DB-path derivation has one home.
938fn refresh_with_default_sources<P: PaneSource>(app: &mut App, pane_source: &P) {
939    if let Ok(Some(snapshot)) = TeamSnapshot::discover_and_load() {
940        app.replace_team(snapshot);
941    }
942    let db_path = app.team.root.join("state/mailbox.db");
943    let mailbox_source = BrokerMailboxSource::new(db_path.clone());
944    let approval_source = BrokerApprovalSource::new(db_path);
945    if let Some(session) = app.focused_session().map(|s| s.to_string()) {
946        if let Ok(lines) = pane_source.capture(&session) {
947            app.set_detail_buffer(lines);
948        }
949    } else {
950        app.detail_buffer.clear();
951    }
952    refresh_mailbox(app, &mailbox_source);
953    refresh_approvals(app, &approval_source);
954    app.last_refresh = Instant::now();
955}
956
957pub fn draw(f: &mut Frame<'_>, app: &App) {
958    let area = f.area();
959    match app.stage {
960        Stage::Splash => splash::draw(f, app),
961        Stage::Triptych => draw_main(f, area, app),
962        // T-108: stream-keys reuses the Triptych render path — the
963        // detail pane carries the visual indicator (border + title +
964        // statusline shift) via the `app.stage == StreamKeys` branch
965        // in those widgets. No separate modal draw.
966        Stage::StreamKeys => draw_main(f, area, app),
967        Stage::QuitConfirm => {
968            draw_main(f, area, app);
969            draw_quit_confirm(f, area);
970        }
971        Stage::ApprovalsModal => {
972            draw_main(f, area, app);
973            draw_approvals_modal(f, area, app);
974        }
975        Stage::ComposeModal => {
976            draw_main(f, area, app);
977            draw_compose_modal(f, area, app);
978        }
979        Stage::HelpOverlay => {
980            draw_main(f, area, app);
981            let buf = f.buffer_mut();
982            render_help_overlay(area, buf, app);
983        }
984        Stage::Tutorial => {
985            draw_main(f, area, app);
986            let buf = f.buffer_mut();
987            render_tutorial(area, buf, app);
988        }
989    }
990}
991
992fn render_help_overlay(area: Rect, buf: &mut Buffer, app: &App) {
993    let popup_w = 70u16.min(area.width.saturating_sub(4));
994    let popup_h = 24u16.min(area.height.saturating_sub(2));
995    let popup = centered_rect(popup_w, popup_h, area);
996    Clear.render(popup, buf);
997    let block = Block::default()
998        .title("help · ? to close")
999        .borders(Borders::ALL)
1000        .border_style(Style::default().fg(app.capabilities.accent()));
1001    let inner = block.inner(popup);
1002    block.render(popup, buf);
1003    let muted = Style::default().fg(app.capabilities.muted());
1004    let bold = Style::default().add_modifier(Modifier::BOLD);
1005    let mut lines: Vec<ratatui::text::Line<'_>> = Vec::new();
1006    for group in crate::help::ALL_GROUPS {
1007        lines.push(ratatui::text::Line::styled(group.title, bold));
1008        for b in group.bindings {
1009            lines.push(ratatui::text::Line::raw(format!(
1010                "  {:<22}  {}",
1011                b.chord, b.description
1012            )));
1013        }
1014        lines.push(ratatui::text::Line::styled("", muted));
1015    }
1016    Paragraph::new(lines).render(inner, buf);
1017}
1018
1019fn render_tutorial(area: Rect, buf: &mut Buffer, app: &App) {
1020    let popup_w = 64u16.min(area.width.saturating_sub(4));
1021    let popup_h = 14u16.min(area.height.saturating_sub(2));
1022    let popup = centered_rect(popup_w, popup_h, area);
1023    Clear.render(popup, buf);
1024    let total = crate::onboarding::STEPS.len();
1025    let i = app.tutorial_step.min(total.saturating_sub(1));
1026    let step = &crate::onboarding::STEPS[i];
1027    let block = Block::default()
1028        .title(format!("tutorial · {}/{total}", i + 1))
1029        .borders(Borders::ALL)
1030        .border_style(Style::default().fg(app.capabilities.accent()));
1031    let inner = block.inner(popup);
1032    block.render(popup, buf);
1033    let muted = Style::default().fg(app.capabilities.muted());
1034    let lines = vec![
1035        ratatui::text::Line::styled(step.heading, Style::default().add_modifier(Modifier::BOLD)),
1036        ratatui::text::Line::raw(""),
1037        ratatui::text::Line::raw(step.body),
1038        ratatui::text::Line::raw(""),
1039        ratatui::text::Line::styled("any key next  ·  k / ↑ / p back  ·  Esc skip", muted),
1040    ];
1041    // T-074 bug 5: tutorial bodies are prose paragraphs, not pre-
1042    // formatted lines — clip-on-overflow leaves them looking truncated
1043    // on common (≤80 col) terminals. Soft-wrap with `trim: true` so
1044    // long step descriptions reflow into the modal width instead of
1045    // dropping off the right edge.
1046    Paragraph::new(lines)
1047        .wrap(ratatui::widgets::Wrap { trim: true })
1048        .render(inner, buf);
1049}
1050
1051fn draw_main(f: &mut Frame<'_>, area: Rect, app: &App) {
1052    let chunks = Layout::default()
1053        .direction(Direction::Vertical)
1054        .constraints([Constraint::Min(3), Constraint::Length(1)])
1055        .split(area);
1056    let buf = f.buffer_mut();
1057    match app.layout {
1058        crate::triptych::MainLayout::Triptych => {
1059            triptych::Triptych { app }.render(chunks[0], buf);
1060        }
1061        crate::triptych::MainLayout::Wall => {
1062            layouts::Wall { app }.render(chunks[0], buf);
1063        }
1064        crate::triptych::MainLayout::MailboxFirst => {
1065            layouts::MailboxFirst { app }.render(chunks[0], buf);
1066        }
1067    }
1068    statusline::Statusline { app }.render(chunks[1], buf);
1069}
1070
1071fn draw_approvals_modal(f: &mut Frame<'_>, area: Rect, app: &App) {
1072    let buf = f.buffer_mut();
1073    render_approvals_modal(area, buf, app);
1074}
1075
1076fn draw_compose_modal(f: &mut Frame<'_>, area: Rect, app: &App) {
1077    let buf = f.buffer_mut();
1078    render_compose_modal(area, buf, app);
1079}
1080
1081fn render_compose_picker_body(inner: Rect, buf: &mut Buffer, app: &App) {
1082    let muted = Style::default().fg(app.capabilities.muted());
1083    let chunks = Layout::default()
1084        .direction(Direction::Vertical)
1085        .constraints([
1086            Constraint::Min(1),
1087            Constraint::Length(1),
1088            Constraint::Length(1),
1089        ])
1090        .split(inner);
1091    let lines: Vec<ratatui::text::Line<'_>> = if app.team.channels.is_empty() {
1092        vec![ratatui::text::Line::styled(
1093            "(no channels declared in team-compose)",
1094            muted,
1095        )]
1096    } else {
1097        app.team
1098            .channels
1099            .iter()
1100            .enumerate()
1101            .map(|(i, ch)| {
1102                let label = format!("  #{}  ({})", ch.name, ch.project_id);
1103                let style = if i == app.compose_picker_index {
1104                    Style::default()
1105                        .fg(app.capabilities.accent())
1106                        .add_modifier(Modifier::REVERSED)
1107                } else {
1108                    Style::default()
1109                };
1110                ratatui::text::Line::styled(label, style)
1111            })
1112            .collect()
1113    };
1114    Paragraph::new(lines).render(chunks[0], buf);
1115    Paragraph::new("pick a channel to broadcast to")
1116        .style(muted)
1117        .render(chunks[1], buf);
1118    Paragraph::new("Enter pick · j/k navigate · Esc cancel")
1119        .style(muted)
1120        .render(chunks[2], buf);
1121}
1122
1123fn render_compose_modal(area: Rect, buf: &mut Buffer, app: &App) {
1124    let popup_w = 80u16.min(area.width.saturating_sub(4));
1125    let popup_h = 16u16.min(area.height.saturating_sub(2));
1126    let popup = centered_rect(popup_w, popup_h, area);
1127    Clear.render(popup, buf);
1128    let title = app
1129        .compose_target
1130        .as_ref()
1131        .map(|t| t.title(&app.team))
1132        .unwrap_or_else(|| "→ ?".into());
1133    let block = Block::default()
1134        .title(title)
1135        .borders(Borders::ALL)
1136        .border_style(Style::default().fg(app.capabilities.accent()));
1137    let inner = block.inner(popup);
1138    block.render(popup, buf);
1139
1140    if inner.height < 3 {
1141        return;
1142    }
1143    // PR-UI-6: when the broadcast picker is open we render a
1144    // channel-list inside the modal instead of the editor; the
1145    // editor footer stays so operators see the same layout.
1146    if app.compose_picker_open {
1147        render_compose_picker_body(inner, buf, app);
1148        return;
1149    }
1150    if app.compose_attach_input_open {
1151        render_compose_attach_input(inner, buf, app);
1152        return;
1153    }
1154    // Reserve the bottom two rows: an error line (rendered when
1155    // present, blank otherwise) and the footer with key hints.
1156    let chunks = Layout::default()
1157        .direction(Direction::Vertical)
1158        .constraints([
1159            Constraint::Min(1),    // editor body
1160            Constraint::Length(1), // error / status
1161            Constraint::Length(1), // footer
1162        ])
1163        .split(inner);
1164
1165    // Body — render lines with a `▏` cursor marker on the active
1166    // row when in Insert. Skip cursor cell in Normal/Ex modes so
1167    // the operator's eye finds the row by row context, not a
1168    // blinking caret.
1169    let muted = Style::default().fg(app.capabilities.muted());
1170    let body_lines: Vec<ratatui::text::Line<'_>> = app
1171        .compose_editor
1172        .lines
1173        .iter()
1174        .enumerate()
1175        .map(|(row, line)| {
1176            if row == app.compose_editor.cursor_row
1177                && app.compose_editor.mode == crate::compose::VimMode::Insert
1178            {
1179                let col = app.compose_editor.cursor_col.min(line.len());
1180                let (head, tail) = line.split_at(col);
1181                ratatui::text::Line::from(vec![
1182                    ratatui::text::Span::raw(head.to_string()),
1183                    ratatui::text::Span::styled(
1184                        "▏",
1185                        Style::default().fg(app.capabilities.accent()),
1186                    ),
1187                    ratatui::text::Span::raw(tail.to_string()),
1188                ])
1189            } else {
1190                ratatui::text::Line::raw(line.clone())
1191            }
1192        })
1193        .collect();
1194    Paragraph::new(body_lines).render(chunks[0], buf);
1195
1196    let error_line = match (&app.compose_error, app.compose_editor.mode) {
1197        (Some(e), _) => format!("error: {e}"),
1198        (None, crate::compose::VimMode::Ex) => format!(":{}", app.compose_editor.ex_buffer),
1199        (None, crate::compose::VimMode::Normal) => "-- NORMAL --".into(),
1200        (None, crate::compose::VimMode::Insert) => "-- INSERT --".into(),
1201    };
1202    let style = if app.compose_error.is_some() {
1203        Style::default().fg(app.capabilities.accent())
1204    } else {
1205        muted
1206    };
1207    Paragraph::new(error_line)
1208        .style(style)
1209        .render(chunks[1], buf);
1210
1211    Paragraph::new("Alt+Enter send · Esc Esc cancel · Tab attach")
1212        .style(muted)
1213        .render(chunks[2], buf);
1214}
1215
1216/// T-32: render the path-input overlay. Single-line buffer + a
1217/// caret marker, with hints for confirm/cancel. Mirrors the picker
1218/// overlay's layout (body / status line / footer) so the modal's
1219/// vertical rhythm doesn't shift between the two overlays.
1220fn render_compose_attach_input(inner: Rect, buf: &mut Buffer, app: &App) {
1221    let muted = Style::default().fg(app.capabilities.muted());
1222    let chunks = Layout::default()
1223        .direction(Direction::Vertical)
1224        .constraints([
1225            Constraint::Min(1),
1226            Constraint::Length(1),
1227            Constraint::Length(1),
1228        ])
1229        .split(inner);
1230    let line = ratatui::text::Line::from(vec![
1231        ratatui::text::Span::raw(format!("path: {}", app.compose_attach_buffer)),
1232        ratatui::text::Span::styled("▏", Style::default().fg(app.capabilities.accent())),
1233    ]);
1234    Paragraph::new(line).render(chunks[0], buf);
1235    Paragraph::new("type or paste an absolute path; the agent reads it via the broker")
1236        .style(muted)
1237        .render(chunks[1], buf);
1238    Paragraph::new("Enter confirm · Esc cancel")
1239        .style(muted)
1240        .render(chunks[2], buf);
1241}
1242
1243fn render_approvals_modal(area: Rect, buf: &mut Buffer, app: &App) {
1244    let popup_w = 80u16.min(area.width.saturating_sub(4));
1245    let popup_h = 18u16.min(area.height.saturating_sub(2));
1246    let popup = centered_rect(popup_w, popup_h, area);
1247    Clear.render(popup, buf);
1248    let n = app.pending_approvals.len();
1249    let i = app.selected_approval.min(n.saturating_sub(1));
1250    let title = format!("approvals · {}/{n}", i + 1);
1251    let block = Block::default()
1252        .title(title)
1253        .borders(Borders::ALL)
1254        .border_style(Style::default().fg(app.capabilities.accent()));
1255    let inner = block.inner(popup);
1256    block.render(popup, buf);
1257
1258    let muted = Style::default().fg(app.capabilities.muted());
1259    let bold = Style::default().add_modifier(Modifier::BOLD);
1260
1261    let Some(a) = app.focused_approval() else {
1262        Paragraph::new("(no pending approvals)")
1263            .style(muted)
1264            .alignment(Alignment::Center)
1265            .render(inner, buf);
1266        return;
1267    };
1268
1269    let mut lines: Vec<ratatui::text::Line<'_>> = vec![
1270        ratatui::text::Line::styled(format!("#{}  {}", a.id, a.action), bold),
1271        ratatui::text::Line::styled(
1272            format!("from: {}", crate::data::agent_label(&app.team, &a.agent_id)),
1273            muted,
1274        ),
1275        ratatui::text::Line::raw(""),
1276        ratatui::text::Line::raw(a.summary.clone()),
1277    ];
1278    if !a.payload_json.is_empty() && a.payload_json != "{}" {
1279        lines.push(ratatui::text::Line::raw(""));
1280        lines.push(ratatui::text::Line::styled("payload:", muted));
1281        for chunk in a.payload_json.lines().take(4) {
1282            lines.push(ratatui::text::Line::raw(chunk.to_string()));
1283        }
1284    }
1285    if let Some(err) = &app.approval_error {
1286        lines.push(ratatui::text::Line::raw(""));
1287        lines.push(ratatui::text::Line::styled(
1288            format!("error: {err}"),
1289            Style::default().fg(app.capabilities.accent()),
1290        ));
1291    }
1292    lines.push(ratatui::text::Line::raw(""));
1293    lines.push(ratatui::text::Line::styled(
1294        "[y] approve  ·  [Shift-N] deny  ·  [j/k] cycle  ·  [Esc] close",
1295        muted,
1296    ));
1297    Paragraph::new(lines).render(inner, buf);
1298}
1299
1300fn draw_quit_confirm(f: &mut Frame<'_>, area: Rect) {
1301    let popup_w = 36u16.min(area.width.saturating_sub(2));
1302    let popup_h = 5u16.min(area.height.saturating_sub(2));
1303    let popup = centered_rect(popup_w, popup_h, area);
1304    let buf = f.buffer_mut();
1305    Clear.render(popup, buf);
1306    Paragraph::new("Quit teamctl-ui?  [y / n]")
1307        .alignment(Alignment::Center)
1308        .block(Block::default().borders(Borders::ALL).title("confirm"))
1309        .render(popup, buf);
1310}
1311
1312fn centered_rect(w: u16, h: u16, area: Rect) -> Rect {
1313    let x = area.x + area.width.saturating_sub(w) / 2;
1314    let y = area.y + area.height.saturating_sub(h) / 2;
1315    Rect {
1316        x,
1317        y,
1318        width: w,
1319        height: h,
1320    }
1321}
1322
1323pub fn handle_event<D: ApprovalDecider, S: MessageSender, M: MailboxSource, K: KeySender>(
1324    app: &mut App,
1325    ev: Event,
1326    decider: &D,
1327    sender: &S,
1328    mailbox_source: &M,
1329    key_sender: &K,
1330) {
1331    use crossterm::event::KeyModifiers;
1332    match ev {
1333        Event::Key(k) if k.kind == KeyEventKind::Press => match app.stage {
1334            Stage::Splash => app.dismiss_splash(),
1335            Stage::Triptych => match k.code {
1336                // PR-UI-7 chord-prefix follow-ups MUST be tested
1337                // before unguarded `Char('q')` / `Char('o')` arms,
1338                // otherwise the no-modifier `q` quit would shadow
1339                // the `Ctrl+W q` close-split.
1340                KeyCode::Char('q') if app.pending_chord == Some(KeyCode::Char('w')) => {
1341                    app.pending_chord = None;
1342                    app.close_focused_split();
1343                }
1344                KeyCode::Char('o') if app.pending_chord == Some(KeyCode::Char('w')) => {
1345                    app.pending_chord = None;
1346                    if !app.detail_splits.is_empty() {
1347                        let keep = app.selected_split.min(app.detail_splits.len() - 1);
1348                        let kept = app.detail_splits.remove(keep);
1349                        app.detail_splits.clear();
1350                        app.detail_splits.push(kept);
1351                        app.selected_split = 0;
1352                    }
1353                }
1354                KeyCode::Char('q') if k.modifiers.is_empty() => app.enter_quit_confirm(),
1355                // PR-UI-4: `a` opens the approvals modal when there's
1356                // at least one pending row. No-op otherwise so the
1357                // chord doesn't surprise anyone hammering keys.
1358                KeyCode::Char('a') => app.enter_approvals_modal(),
1359                // PR-UI-5: `@` opens DM compose to focused agent.
1360                // PR-UI-6: `!` now opens the broadcast picker so
1361                // operators choose which channel to broadcast to,
1362                // not just the project's `all` wire.
1363                KeyCode::Char('@') => app.enter_compose_dm_for_focused(),
1364                KeyCode::Char('!') => app.enter_compose_broadcast_with_picker(),
1365                // PR-UI-7 chord-prefix: when there's at least one
1366                // detail split, `Ctrl+W` arms the chord-prefix
1367                // (the next key dispatches `q` close-split, `o`
1368                // close-others). Tested BEFORE the wall-layout
1369                // toggle below so the chord-prefix wins when
1370                // relevant. Both casings accepted because CapsLock
1371                // / Shift+Ctrl produce `Char('W')`; armed value is
1372                // normalised to lowercase so the follow-up arms
1373                // above can match a single literal.
1374                KeyCode::Char('w') | KeyCode::Char('W')
1375                    if k.modifiers.contains(KeyModifiers::CONTROL)
1376                        && !app.detail_splits.is_empty() =>
1377                {
1378                    app.pending_chord = Some(KeyCode::Char('w'))
1379                }
1380                // PR-UI-6: layout toggles. `Ctrl+W` for Wall when
1381                // there are no splits to chord on; `Ctrl+M` for
1382                // MailboxFirst (always). Both casings accepted —
1383                // see the chord-arm comment above.
1384                KeyCode::Char('w') | KeyCode::Char('W')
1385                    if k.modifiers.contains(KeyModifiers::CONTROL) =>
1386                {
1387                    app.toggle_wall_layout()
1388                }
1389                KeyCode::Char('m') | KeyCode::Char('M')
1390                    if k.modifiers.contains(KeyModifiers::CONTROL) =>
1391                {
1392                    app.toggle_mailbox_first_layout()
1393                }
1394                // PR-UI-7 splitscreen lift: `Ctrl+|` subdivides
1395                // vertically, `Ctrl+-` horizontally — vim/tmux
1396                // operators' muscle memory matches the visual.
1397                KeyCode::Char('|') if k.modifiers.contains(KeyModifiers::CONTROL) => {
1398                    app.add_detail_split_vertical()
1399                }
1400                KeyCode::Char('-') if k.modifiers.contains(KeyModifiers::CONTROL) => {
1401                    app.add_detail_split_horizontal()
1402                }
1403                // Vim window-motion `Ctrl+H/J/K/L` cycles between
1404                // splits when there's more than one. Both casings
1405                // accepted — see the Ctrl+W chord-arm comment above
1406                // for the CapsLock + Shift+Ctrl rationale.
1407                KeyCode::Char('h')
1408                | KeyCode::Char('H')
1409                | KeyCode::Char('k')
1410                | KeyCode::Char('K')
1411                    if k.modifiers.contains(KeyModifiers::CONTROL) =>
1412                {
1413                    app.cycle_split_prev()
1414                }
1415                KeyCode::Char('l')
1416                | KeyCode::Char('L')
1417                | KeyCode::Char('j')
1418                | KeyCode::Char('J')
1419                    if k.modifiers.contains(KeyModifiers::CONTROL) =>
1420                {
1421                    app.cycle_split_next()
1422                }
1423                // PR-UI-6 alias preserved for back-compat: `Ctrl+Q`
1424                // closes the focused split. PR-UI-7 also wires the
1425                // proper `Ctrl+W q` chord; both work. Both casings
1426                // accepted for the same reason as Ctrl+W/M.
1427                KeyCode::Char('q') | KeyCode::Char('Q')
1428                    if k.modifiers.contains(KeyModifiers::CONTROL) =>
1429                {
1430                    app.close_focused_split()
1431                }
1432                // T-108: `Ctrl+E` activates stream-keys mode when the
1433                // detail pane is focused — every subsequent keystroke
1434                // forwards to the agent's tmux pane. Gated on detail
1435                // focus so operators in the roster / mailbox don't
1436                // get pulled into stream-mode by a stray chord. Both
1437                // casings accepted for the CapsLock/Shift+Ctrl case
1438                // (same rationale as Ctrl+W/M arms above).
1439                KeyCode::Char('e') | KeyCode::Char('E')
1440                    if k.modifiers.contains(KeyModifiers::CONTROL)
1441                        && app.focused_pane == Pane::Detail =>
1442                {
1443                    app.enter_stream_keys()
1444                }
1445                // (chord-prefix follow-ups handled at top of arm
1446                // before unguarded letter-arms — see top of
1447                // `Stage::Triptych` match.)
1448                // PR-UI-7 help + tutorial chords. `?` opens help
1449                // overlay; `t` reopens tutorial. Both no-op if a
1450                // modifier is in flight (so `Shift+?` and `Ctrl+T`
1451                // don't double-bind).
1452                KeyCode::Char('?')
1453                    if k.modifiers.is_empty() || k.modifiers == KeyModifiers::SHIFT =>
1454                {
1455                    app.enter_help_overlay()
1456                }
1457                KeyCode::Char('t') if k.modifiers.is_empty() => app.enter_tutorial(),
1458                // PR-UI-4: Shift+Tab cycles panes backward. Some
1459                // terminals send `BackTab`, others send `Tab` with
1460                // SHIFT — handle both.
1461                KeyCode::BackTab => app.cycle_focus_back(),
1462                KeyCode::Tab if k.modifiers.contains(KeyModifiers::SHIFT) => app.cycle_focus_back(),
1463                // T-074 bug 6: Tab always cycles pane focus, never
1464                // mailbox tabs. Previously Tab routed into mailbox
1465                // tab-cycling when the mailbox pane was focused —
1466                // this stranded operators inside the mailbox with no
1467                // discoverable way out (Alireza's exact report). The
1468                // vim/tmux convention is "Tab moves between panes";
1469                // honour it across every pane uniformly.
1470                KeyCode::Tab => app.cycle_focus(),
1471                // T-124: mailbox sub-navigation uses Left/Right
1472                // arrows (more discoverable than the prior `[`/`]`
1473                // chord). Gated on the mailbox pane being focused
1474                // so the keys stay unsurprising elsewhere — Up/Down
1475                // remain free to scroll layout-specific lists, and
1476                // Left/Right have no other binding today.
1477                KeyCode::Right if app.focused_pane == Pane::Mailbox => app.cycle_mailbox_tab(),
1478                KeyCode::Left if app.focused_pane == Pane::Mailbox => app.cycle_mailbox_tab_back(),
1479                // PR-UI-6: in Wall layout, `j`/`k` (and arrows)
1480                // scroll the tile grid — same vim shape, different
1481                // surface. In Triptych roster focus they still
1482                // navigate the roster.
1483                KeyCode::Up | KeyCode::Char('k') if matches!(app.layout, MainLayout::Wall) => {
1484                    app.wall_scroll_up()
1485                }
1486                KeyCode::Down | KeyCode::Char('j') if matches!(app.layout, MainLayout::Wall) => {
1487                    app.wall_scroll_down()
1488                }
1489                // PR-UI-6: in MailboxFirst, `j`/`k` walk the
1490                // channel list.
1491                KeyCode::Up | KeyCode::Char('k')
1492                    if matches!(app.layout, MainLayout::MailboxFirst) =>
1493                {
1494                    app.select_prev_channel()
1495                }
1496                KeyCode::Down | KeyCode::Char('j')
1497                    if matches!(app.layout, MainLayout::MailboxFirst) =>
1498                {
1499                    app.select_next_channel()
1500                }
1501                // Roster navigation — only when roster is the
1502                // focused pane. j/k mirror Vim; arrows mirror
1503                // every-day navigation.
1504                KeyCode::Up | KeyCode::Char('k') if app.focused_pane == Pane::Roster => {
1505                    app.select_prev()
1506                }
1507                KeyCode::Down | KeyCode::Char('j') if app.focused_pane == Pane::Roster => {
1508                    app.select_next()
1509                }
1510                _ => {}
1511            },
1512            Stage::QuitConfirm => match k.code {
1513                KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => app.confirm_quit(),
1514                KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => app.cancel_quit(),
1515                _ => {}
1516            },
1517            Stage::ApprovalsModal => match k.code {
1518                // Asymmetric chord shape (T-074 bug 4 fix): approve is
1519                // the common path so it accepts both `y` and `Y` —
1520                // matches QuitConfirm's loose convention and the
1521                // muscle-memory most TUI prompts build. Deny is the
1522                // destructive side, so it requires deliberate Shift
1523                // (`N` only); a stray lowercase `n` does nothing.
1524                // Trades cosmetic chord-symmetry for discoverability
1525                // on the load-bearing approve flow.
1526                KeyCode::Char('y') | KeyCode::Char('Y') => {
1527                    app.apply_decision(decider, Decision::Approve, "")
1528                }
1529                KeyCode::Char('N') => app.apply_decision(decider, Decision::Deny, ""),
1530                KeyCode::Char('j') | KeyCode::Down => app.cycle_approval_next(),
1531                KeyCode::Char('k') | KeyCode::Up => app.cycle_approval_prev(),
1532                KeyCode::Esc | KeyCode::Char('q') => app.close_approvals_modal(),
1533                _ => {}
1534            },
1535            Stage::ComposeModal => {
1536                // PR-UI-6: when the broadcast picker is open the
1537                // editor doesn't see keys yet — operator first
1538                // chooses a channel.
1539                if app.compose_picker_open {
1540                    match k.code {
1541                        KeyCode::Down | KeyCode::Char('j') => app.picker_next(),
1542                        KeyCode::Up | KeyCode::Char('k') => app.picker_prev(),
1543                        KeyCode::Enter => app.picker_confirm(),
1544                        // PR-UI-6 fixup (Q6, dev2 review): Esc
1545                        // dismisses the picker overlay only and
1546                        // returns to the editor with whatever the
1547                        // operator already typed; the editor's own
1548                        // Esc-Esc cancel-the-modal flow handles
1549                        // bailing out of the whole compose. Mirrors
1550                        // the overlay-vs-modal symmetry vim users
1551                        // expect.
1552                        KeyCode::Esc => {
1553                            app.compose_picker_open = false;
1554                            app.compose_picker_index = 0;
1555                        }
1556                        _ => {}
1557                    }
1558                } else if app.compose_attach_input_open {
1559                    // T-32: path-input overlay. Keys edit the buffer
1560                    // directly; Enter confirms (appends marker line
1561                    // to the editor body); Esc cancels back to the
1562                    // editor. Same overlay-vs-modal symmetry as the
1563                    // picker — Esc dismisses *the overlay*, not the
1564                    // whole compose.
1565                    match k.code {
1566                        KeyCode::Char(c) => app.compose_attach_buffer.push(c),
1567                        KeyCode::Backspace => {
1568                            app.compose_attach_buffer.pop();
1569                        }
1570                        KeyCode::Enter => app.confirm_compose_attach_input(),
1571                        KeyCode::Esc => app.close_compose_attach_input(),
1572                        _ => {}
1573                    }
1574                } else if k.code == KeyCode::Tab {
1575                    // T-32: Tab opens the path-input overlay. The
1576                    // editor never sees Tab today (apply_insert
1577                    // ignores it), so intercepting here doesn't
1578                    // change the editor's surface.
1579                    app.open_compose_attach_input();
1580                } else {
1581                    // Route every keypress through the editor; the
1582                    // editor returns Send / Cancel / Continue.
1583                    match app.compose_editor.apply_key(k) {
1584                        EditorAction::Continue => {}
1585                        EditorAction::Send => app.apply_send(sender, mailbox_source),
1586                        EditorAction::Cancel => app.close_compose_modal(),
1587                    }
1588                }
1589            }
1590            Stage::HelpOverlay => match k.code {
1591                KeyCode::Esc | KeyCode::Char('?') | KeyCode::Char('q') => app.close_help_overlay(),
1592                _ => {}
1593            },
1594            Stage::Tutorial => match k.code {
1595                KeyCode::Esc => app.close_tutorial(),
1596                KeyCode::Char('k') | KeyCode::Up | KeyCode::Char('p') => app.tutorial_back(),
1597                _ => app.tutorial_advance(),
1598            },
1599            // T-108 stream-keys mode. Esc is the only chord we
1600            // intercept — every other key (including `Ctrl+C`,
1601            // `Ctrl+E`, arrow keys, `Enter`) forwards to the agent's
1602            // tmux pane. The pass-through behaviour is intentional:
1603            // the operator is "effectively attached," and a shell-
1604            // user's Ctrl+C should send SIGINT to the agent, not
1605            // bail them out of the mode they just entered.
1606            Stage::StreamKeys => {
1607                if matches!(k.code, KeyCode::Esc) {
1608                    app.exit_stream_keys();
1609                } else if let Some(session) = app.stream_target_session() {
1610                    if let Some(encoded) = encode_key(k) {
1611                        // Best-effort: a tmux failure (session
1612                        // vanished, target pane gone) is silent in
1613                        // v1; the next refresh tick reflects whatever
1614                        // the agent's pane actually shows.
1615                        let _ = key_sender.send(&session, &encoded);
1616                    }
1617                } else {
1618                    // Target session disappeared mid-stream (agent
1619                    // restarted, team reloaded). Drop back to
1620                    // Triptych so the operator isn't typing into the
1621                    // void with no feedback.
1622                    app.exit_stream_keys();
1623                }
1624            }
1625        },
1626        Event::Resize(_, _) => {
1627            // ratatui redraws on the next loop iteration; nothing to do.
1628        }
1629        // T-158: mouse-wheel routes by focused pane. Detail forwards
1630        // each tick to the agent's tmux pane as a copy-mode scroll —
1631        // wheel-up enters copy-mode and walks history, wheel-down
1632        // walks back toward live. Roster steps the agent selection
1633        // (same step as `j`/`k`). Mailbox is a no-op until T-131
1634        // lands the row-cursor state for the rows to scroll; the
1635        // routing scaffold is in place so that fill-in is local.
1636        // Stages other than Triptych ignore mouse input — modal
1637        // overlays (compose, approvals, picker, help) own the screen
1638        // and shouldn't get a surprise scroll routed past them.
1639        Event::Mouse(m) if matches!(app.stage, Stage::Triptych) => {
1640            use crossterm::event::MouseEventKind;
1641            let direction = match m.kind {
1642                MouseEventKind::ScrollUp => Some(ScrollDirection::Up),
1643                MouseEventKind::ScrollDown => Some(ScrollDirection::Down),
1644                _ => None,
1645            };
1646            if let Some(dir) = direction {
1647                match app.focused_pane {
1648                    Pane::Detail => {
1649                        if let Some(session) = app.focused_session().map(|s| s.to_string()) {
1650                            // Best-effort, same convention as
1651                            // stream-keys: tmux failure (session
1652                            // vanished) is silent; the next refresh
1653                            // reflects reality.
1654                            let _ = key_sender.scroll(&session, dir);
1655                        }
1656                    }
1657                    Pane::Roster => match dir {
1658                        ScrollDirection::Up => app.select_prev(),
1659                        ScrollDirection::Down => app.select_next(),
1660                    },
1661                    Pane::Mailbox => {
1662                        // T-131 will wire row-level scroll state
1663                        // here. v1 ships the routing only.
1664                    }
1665                }
1666            }
1667        }
1668        _ => {}
1669    }
1670}
1671
1672/// Render the entire UI into a `Buffer` at fixed size — used by the
1673/// snapshot tests. Mirrors `draw` exactly but doesn't require a
1674/// `Terminal`. Update both in lockstep when adding new stages.
1675pub fn render_to_buffer(app: &App, width: u16, height: u16) -> Buffer {
1676    let area = Rect::new(0, 0, width, height);
1677    let mut buf = Buffer::empty(area);
1678    match app.stage {
1679        Stage::Splash => splash::Splash { app }.render(area, &mut buf),
1680        Stage::Triptych => render_main(app, area, &mut buf),
1681        Stage::StreamKeys => render_main(app, area, &mut buf),
1682        Stage::QuitConfirm => {
1683            render_main(app, area, &mut buf);
1684            render_quit_confirm(area, &mut buf);
1685        }
1686        Stage::ApprovalsModal => {
1687            render_main(app, area, &mut buf);
1688            render_approvals_modal(area, &mut buf, app);
1689        }
1690        Stage::ComposeModal => {
1691            render_main(app, area, &mut buf);
1692            render_compose_modal(area, &mut buf, app);
1693        }
1694        Stage::HelpOverlay => {
1695            render_main(app, area, &mut buf);
1696            render_help_overlay(area, &mut buf, app);
1697        }
1698        Stage::Tutorial => {
1699            render_main(app, area, &mut buf);
1700            render_tutorial(area, &mut buf, app);
1701        }
1702    }
1703    buf
1704}
1705
1706fn render_main(app: &App, area: Rect, buf: &mut Buffer) {
1707    let chunks = Layout::default()
1708        .direction(Direction::Vertical)
1709        .constraints([Constraint::Min(3), Constraint::Length(1)])
1710        .split(area);
1711    match app.layout {
1712        crate::triptych::MainLayout::Triptych => {
1713            triptych::Triptych { app }.render(chunks[0], buf);
1714        }
1715        crate::triptych::MainLayout::Wall => {
1716            layouts::Wall { app }.render(chunks[0], buf);
1717        }
1718        crate::triptych::MainLayout::MailboxFirst => {
1719            layouts::MailboxFirst { app }.render(chunks[0], buf);
1720        }
1721    }
1722    statusline::Statusline { app }.render(chunks[1], buf);
1723}
1724
1725fn render_quit_confirm(area: Rect, buf: &mut Buffer) {
1726    let popup_w = 36u16.min(area.width.saturating_sub(2));
1727    let popup_h = 5u16.min(area.height.saturating_sub(2));
1728    let popup = centered_rect(popup_w, popup_h, area);
1729    Clear.render(popup, buf);
1730    Paragraph::new("Quit teamctl-ui?  [y / n]")
1731        .alignment(Alignment::Center)
1732        .block(Block::default().borders(Borders::ALL).title("confirm"))
1733        .render(popup, buf);
1734}
1735
1736#[cfg(test)]
1737mod tests {
1738    use super::*;
1739    use crate::data::AgentInfo;
1740    use crossterm::event::{KeyEvent, KeyEventState, KeyModifiers};
1741    use team_core::supervisor::AgentState;
1742
1743    fn key(code: KeyCode) -> Event {
1744        Event::Key(KeyEvent {
1745            code,
1746            modifiers: KeyModifiers::NONE,
1747            kind: KeyEventKind::Press,
1748            state: KeyEventState::NONE,
1749        })
1750    }
1751
1752    fn key_with(code: KeyCode, modifiers: KeyModifiers) -> Event {
1753        Event::Key(KeyEvent {
1754            code,
1755            modifiers,
1756            kind: KeyEventKind::Press,
1757            state: KeyEventState::NONE,
1758        })
1759    }
1760
1761    /// Noop decider for tests that don't exercise approve/deny.
1762    struct NoopDecider;
1763    impl crate::approvals::ApprovalDecider for NoopDecider {
1764        fn decide(
1765            &self,
1766            _root: &std::path::Path,
1767            _id: i64,
1768            _kind: crate::approvals::Decision,
1769            _note: &str,
1770        ) -> anyhow::Result<()> {
1771            Ok(())
1772        }
1773    }
1774
1775    /// Noop sender for tests that don't exercise compose-send.
1776    struct NoopSender;
1777    impl crate::compose::MessageSender for NoopSender {
1778        fn send_dm(
1779            &self,
1780            _root: &std::path::Path,
1781            _agent: &str,
1782            _body: &str,
1783        ) -> anyhow::Result<()> {
1784            Ok(())
1785        }
1786        fn broadcast(
1787            &self,
1788            _root: &std::path::Path,
1789            _channel: &str,
1790            _body: &str,
1791        ) -> anyhow::Result<()> {
1792            Ok(())
1793        }
1794    }
1795
1796    /// Mailbox source that returns nothing — refresh_mailbox after
1797    /// a successful send becomes a no-op.
1798    struct EmptyMailbox;
1799    impl crate::mailbox::MailboxSource for EmptyMailbox {
1800        fn inbox(&self, _id: &str, _after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
1801            Ok(Vec::new())
1802        }
1803        fn sent(&self, _id: &str, _after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
1804            Ok(Vec::new())
1805        }
1806        fn channel_feed(
1807            &self,
1808            _id: &str,
1809            _after: i64,
1810        ) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
1811            Ok(Vec::new())
1812        }
1813        fn wire(&self, _id: &str, _after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
1814            Ok(Vec::new())
1815        }
1816    }
1817
1818    /// Boilerplate-free dispatcher for tests not exercising the
1819    /// decision / send paths.
1820    fn dispatch(app: &mut App, ev: Event) {
1821        super::handle_event(
1822            app,
1823            ev,
1824            &NoopDecider,
1825            &NoopSender,
1826            &EmptyMailbox,
1827            &crate::keysender::test_support::MockKeySender::default(),
1828        );
1829    }
1830
1831    fn agent(id: &str, state: AgentState) -> AgentInfo {
1832        AgentInfo {
1833            id: id.into(),
1834            agent: id
1835                .split_once(':')
1836                .map(|(_, a)| a.to_string())
1837                .unwrap_or_default(),
1838            project: id
1839                .split_once(':')
1840                .map(|(p, _)| p.to_string())
1841                .unwrap_or_default(),
1842            tmux_session: format!("t-{}", id.replace(':', "-")),
1843            state,
1844            unread_mail: 0,
1845            pending_approvals: 0,
1846            is_manager: false,
1847            display_name: None,
1848        }
1849    }
1850
1851    pub fn fixture_team(agents: Vec<AgentInfo>) -> TeamSnapshot {
1852        TeamSnapshot {
1853            root: std::path::PathBuf::from("/fixture"),
1854            team_name: "fixture".into(),
1855            agents,
1856            channels: Vec::new(),
1857        }
1858    }
1859
1860    #[test]
1861    fn splash_dismissed_by_any_key() {
1862        let mut app = App::new();
1863        assert_eq!(app.stage, Stage::Splash);
1864        dispatch(&mut app, key(KeyCode::Char(' ')));
1865        assert_eq!(app.stage, Stage::Triptych);
1866    }
1867
1868    #[test]
1869    fn tab_cycles_panes_uniformly_and_wraps_through_mailbox() {
1870        // T-074 bug 6: Tab cycles pane focus only — Roster → Detail
1871        // → Mailbox → Roster — at every step. The previous "Tab
1872        // cycles tabs once focused on mailbox" shape stranded
1873        // operators inside the mailbox; this test pins the corrected
1874        // uniform cycle so a future refactor can't reintroduce the
1875        // dead-end.
1876        let mut app = App::new();
1877        app.dismiss_splash();
1878        assert_eq!(app.focused_pane, Pane::Roster);
1879        dispatch(&mut app, key(KeyCode::Tab));
1880        assert_eq!(app.focused_pane, Pane::Detail);
1881        dispatch(&mut app, key(KeyCode::Tab));
1882        assert_eq!(app.focused_pane, Pane::Mailbox);
1883        assert_eq!(
1884            app.mailbox_tab,
1885            MailboxTab::Inbox,
1886            "Tab into mailbox does NOT touch the active mailbox tab"
1887        );
1888        dispatch(&mut app, key(KeyCode::Tab));
1889        assert_eq!(
1890            app.focused_pane,
1891            Pane::Roster,
1892            "Tab from mailbox wraps to roster, not into mailbox subtabs"
1893        );
1894        assert_eq!(
1895            app.mailbox_tab,
1896            MailboxTab::Inbox,
1897            "mailbox tab still untouched"
1898        );
1899    }
1900
1901    #[test]
1902    fn arrow_keys_walk_mailbox_tabs_when_mailbox_focused() {
1903        // T-124: Right/Left arrows are the mailbox-tab walker
1904        // (more discoverable than the prior `[`/`]` chord). Gated
1905        // on mailbox being the focused pane so the arrows stay
1906        // unsurprising in every other context.
1907        let mut app = App::new();
1908        app.dismiss_splash();
1909        // Walk into mailbox via Tab.
1910        dispatch(&mut app, key(KeyCode::Tab));
1911        dispatch(&mut app, key(KeyCode::Tab));
1912        assert_eq!(app.focused_pane, Pane::Mailbox);
1913        assert_eq!(app.mailbox_tab, MailboxTab::Inbox);
1914
1915        dispatch(&mut app, key(KeyCode::Right));
1916        assert_eq!(app.mailbox_tab, MailboxTab::Sent);
1917        dispatch(&mut app, key(KeyCode::Right));
1918        assert_eq!(app.mailbox_tab, MailboxTab::Channel);
1919        dispatch(&mut app, key(KeyCode::Right));
1920        assert_eq!(app.mailbox_tab, MailboxTab::Wire);
1921        dispatch(&mut app, key(KeyCode::Right));
1922        assert_eq!(app.mailbox_tab, MailboxTab::Inbox, "→ wraps");
1923
1924        dispatch(&mut app, key(KeyCode::Left));
1925        assert_eq!(app.mailbox_tab, MailboxTab::Wire, "← walks back");
1926    }
1927
1928    #[test]
1929    fn arrow_keys_no_op_when_mailbox_not_focused() {
1930        // The arrows must not surprise an operator scrolling the
1931        // roster — gate is load-bearing.
1932        let mut app = App::new();
1933        app.dismiss_splash();
1934        assert_eq!(app.focused_pane, Pane::Roster);
1935        let initial = app.mailbox_tab;
1936        dispatch(&mut app, key(KeyCode::Right));
1937        dispatch(&mut app, key(KeyCode::Left));
1938        assert_eq!(
1939            app.mailbox_tab, initial,
1940            "←/→ from non-mailbox panes must not flip the active tab"
1941        );
1942    }
1943
1944    #[test]
1945    fn brackets_no_longer_cycle_mailbox_tabs() {
1946        // T-124 regression: `[` / `]` were the previous binding;
1947        // hard-swap means they are now fully inert in the mailbox
1948        // pane. Pin the no-op so a future binding can't quietly
1949        // re-introduce the old chord.
1950        let mut app = App::new();
1951        app.dismiss_splash();
1952        dispatch(&mut app, key(KeyCode::Tab));
1953        dispatch(&mut app, key(KeyCode::Tab));
1954        assert_eq!(app.focused_pane, Pane::Mailbox);
1955        let initial = app.mailbox_tab;
1956
1957        dispatch(&mut app, key(KeyCode::Char(']')));
1958        dispatch(&mut app, key(KeyCode::Char('[')));
1959        assert_eq!(
1960            app.mailbox_tab, initial,
1961            "`[` / `]` must no longer cycle mailbox tabs (T-124 hard-swap)",
1962        );
1963    }
1964
1965    #[test]
1966    fn q_opens_confirm_then_n_cancels() {
1967        let mut app = App::new();
1968        app.dismiss_splash();
1969        dispatch(&mut app, key(KeyCode::Char('q')));
1970        assert_eq!(app.stage, Stage::QuitConfirm);
1971        dispatch(&mut app, key(KeyCode::Char('n')));
1972        assert_eq!(app.stage, Stage::Triptych);
1973        assert!(app.running, "n must not exit");
1974    }
1975
1976    #[test]
1977    fn q_then_y_exits() {
1978        let mut app = App::new();
1979        app.dismiss_splash();
1980        dispatch(&mut app, key(KeyCode::Char('q')));
1981        dispatch(&mut app, key(KeyCode::Char('y')));
1982        assert!(!app.running);
1983    }
1984
1985    #[test]
1986    fn esc_cancels_quit_confirm() {
1987        let mut app = App::new();
1988        app.dismiss_splash();
1989        app.enter_quit_confirm();
1990        dispatch(&mut app, key(KeyCode::Esc));
1991        assert_eq!(app.stage, Stage::Triptych);
1992    }
1993
1994    #[test]
1995    fn render_does_not_panic_at_minimal_size() {
1996        let app = App::new();
1997        let _ = render_to_buffer(&app, 20, 8);
1998    }
1999
2000    #[test]
2001    fn render_does_not_panic_at_huge_size() {
2002        let app = App::new();
2003        let _ = render_to_buffer(&app, 240, 80);
2004    }
2005
2006    #[test]
2007    fn select_next_wraps_through_team() {
2008        let mut app = App::new();
2009        app.replace_team(fixture_team(vec![
2010            agent("p:a", AgentState::Running),
2011            agent("p:b", AgentState::Running),
2012            agent("p:c", AgentState::Running),
2013        ]));
2014        assert_eq!(app.selected_agent, Some(0));
2015        app.select_next();
2016        assert_eq!(app.selected_agent, Some(1));
2017        app.select_next();
2018        assert_eq!(app.selected_agent, Some(2));
2019        app.select_next();
2020        assert_eq!(app.selected_agent, Some(0)); // wraps
2021    }
2022
2023    #[test]
2024    fn select_prev_wraps_at_top() {
2025        let mut app = App::new();
2026        app.replace_team(fixture_team(vec![
2027            agent("p:a", AgentState::Running),
2028            agent("p:b", AgentState::Running),
2029        ]));
2030        app.selected_agent = Some(0);
2031        app.select_prev();
2032        assert_eq!(app.selected_agent, Some(1));
2033    }
2034
2035    #[test]
2036    fn select_no_op_on_empty_team() {
2037        let mut app = App::new();
2038        app.select_next();
2039        assert_eq!(app.selected_agent, None);
2040        app.select_prev();
2041        assert_eq!(app.selected_agent, None);
2042    }
2043
2044    #[test]
2045    fn replace_team_preserves_selection_when_agent_still_present() {
2046        let mut app = App::new();
2047        app.replace_team(fixture_team(vec![
2048            agent("p:a", AgentState::Running),
2049            agent("p:b", AgentState::Running),
2050        ]));
2051        app.selected_agent = Some(1);
2052        app.replace_team(fixture_team(vec![
2053            agent("p:a", AgentState::Running),
2054            agent("p:b", AgentState::Stopped), // same id, new state
2055        ]));
2056        assert_eq!(app.selected_agent, Some(1), "selection follows the id");
2057    }
2058
2059    #[test]
2060    fn replace_team_resets_selection_when_agent_disappears() {
2061        let mut app = App::new();
2062        app.replace_team(fixture_team(vec![
2063            agent("p:a", AgentState::Running),
2064            agent("p:gone", AgentState::Running),
2065        ]));
2066        app.selected_agent = Some(1);
2067        app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
2068        assert_eq!(app.selected_agent, Some(0), "falls back to first agent");
2069    }
2070
2071    #[test]
2072    fn switching_agent_resets_mailbox_buffers() {
2073        // The mailbox cursors are per-agent context; switching to a
2074        // new agent must clear them so we don't skip historical
2075        // rows that landed before the new agent's first refresh.
2076        let mut app = App::new();
2077        app.replace_team(fixture_team(vec![
2078            agent("p:a", AgentState::Running),
2079            agent("p:b", AgentState::Running),
2080        ]));
2081        app.mailbox.extend(
2082            crate::mailbox::MailboxTab::Inbox,
2083            vec![crate::mailbox::MessageRow {
2084                id: 7,
2085                sender: "p:b".into(),
2086                recipient: "p:a".into(),
2087                text: "hi".into(),
2088                sent_at: 0.0,
2089            }],
2090        );
2091        assert_eq!(app.mailbox.inbox.len(), 1);
2092        assert_eq!(app.mailbox.inbox_after, 7);
2093        // Move selection to p:b — different agent id, mailbox resets.
2094        app.select_next();
2095        assert_eq!(app.selected_agent_id().as_deref(), Some("p:b"));
2096        assert!(app.mailbox.inbox.is_empty());
2097        assert_eq!(app.mailbox.inbox_after, 0);
2098    }
2099
2100    /// Tiny single-call mailbox stub for the refresh-fanout test —
2101    /// keeps the assertion local without depending on
2102    /// `mailbox::tests::MockMailboxSource` (which lives behind a
2103    /// private `tests` module).
2104    struct TripleFilterMock {
2105        inbox: Vec<crate::mailbox::MessageRow>,
2106        sent: Vec<crate::mailbox::MessageRow>,
2107        channel: Vec<crate::mailbox::MessageRow>,
2108        wire: Vec<crate::mailbox::MessageRow>,
2109        calls: std::sync::Mutex<Vec<(&'static str, String, i64)>>,
2110    }
2111    impl crate::mailbox::MailboxSource for TripleFilterMock {
2112        fn inbox(&self, id: &str, after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
2113            self.calls.lock().unwrap().push(("inbox", id.into(), after));
2114            Ok(self.inbox.clone())
2115        }
2116        fn sent(&self, id: &str, after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
2117            self.calls.lock().unwrap().push(("sent", id.into(), after));
2118            Ok(self.sent.clone())
2119        }
2120        fn channel_feed(
2121            &self,
2122            id: &str,
2123            after: i64,
2124        ) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
2125            self.calls
2126                .lock()
2127                .unwrap()
2128                .push(("channel", id.into(), after));
2129            Ok(self.channel.clone())
2130        }
2131        fn wire(&self, id: &str, after: i64) -> anyhow::Result<Vec<crate::mailbox::MessageRow>> {
2132            self.calls.lock().unwrap().push(("wire", id.into(), after));
2133            Ok(self.wire.clone())
2134        }
2135    }
2136
2137    #[test]
2138    fn refresh_mailbox_fans_out_to_four_filters() {
2139        use crate::mailbox::MessageRow;
2140        let mut app = App::new();
2141        app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
2142        let mock = TripleFilterMock {
2143            inbox: vec![MessageRow {
2144                id: 1,
2145                sender: "p:b".into(),
2146                recipient: "p:a".into(),
2147                text: "dm".into(),
2148                sent_at: 0.0,
2149            }],
2150            sent: vec![MessageRow {
2151                id: 4,
2152                sender: "p:a".into(),
2153                recipient: "p:b".into(),
2154                text: "outgoing dm".into(),
2155                sent_at: 0.0,
2156            }],
2157            channel: vec![MessageRow {
2158                id: 2,
2159                sender: "p:b".into(),
2160                recipient: "channel:p:editorial".into(),
2161                text: "ch".into(),
2162                sent_at: 0.0,
2163            }],
2164            wire: vec![MessageRow {
2165                id: 3,
2166                sender: "p:b".into(),
2167                recipient: "channel:p:all".into(),
2168                text: "wire".into(),
2169                sent_at: 0.0,
2170            }],
2171            calls: std::sync::Mutex::new(Vec::new()),
2172        };
2173        super::refresh_mailbox(&mut app, &mock);
2174        assert_eq!(app.mailbox.inbox.len(), 1);
2175        assert_eq!(app.mailbox.sent.len(), 1);
2176        assert_eq!(app.mailbox.channel.len(), 1);
2177        assert_eq!(app.mailbox.wire.len(), 1);
2178        let calls = mock.calls.lock().unwrap();
2179        // The selected agent is p:a (auto-set by replace_team to
2180        // index 0); the wire filter takes the project id `p`.
2181        assert!(calls.contains(&("inbox", "p:a".into(), 0)));
2182        assert!(calls.contains(&("sent", "p:a".into(), 0)));
2183        assert!(calls.contains(&("channel", "p:a".into(), 0)));
2184        assert!(calls.contains(&("wire", "p".into(), 0)));
2185    }
2186
2187    fn ap(id: i64) -> crate::approvals::Approval {
2188        crate::approvals::Approval {
2189            id,
2190            project_id: "p".into(),
2191            agent_id: "p:m".into(),
2192            action: "publish".into(),
2193            summary: format!("approval #{id}"),
2194            payload_json: String::new(),
2195        }
2196    }
2197
2198    #[test]
2199    fn has_pending_approvals_tracks_replace_calls() {
2200        let mut app = App::new();
2201        assert!(!app.has_pending_approvals());
2202        app.replace_approvals(vec![ap(1), ap(2)]);
2203        assert!(app.has_pending_approvals());
2204        app.replace_approvals(vec![]);
2205        assert!(!app.has_pending_approvals());
2206    }
2207
2208    #[test]
2209    fn enter_approvals_modal_no_op_when_queue_empty() {
2210        let mut app = App::new();
2211        app.dismiss_splash();
2212        app.enter_approvals_modal();
2213        assert_eq!(app.stage, Stage::Triptych, "no pending → no modal");
2214    }
2215
2216    #[test]
2217    fn a_chord_opens_modal_when_pending() {
2218        let mut app = App::new();
2219        app.dismiss_splash();
2220        app.replace_approvals(vec![ap(1), ap(2)]);
2221        dispatch(&mut app, key(KeyCode::Char('a')));
2222        assert_eq!(app.stage, Stage::ApprovalsModal);
2223        assert_eq!(app.selected_approval, 0);
2224    }
2225
2226    #[test]
2227    fn modal_cycle_jk_walks_approvals() {
2228        let mut app = App::new();
2229        app.dismiss_splash();
2230        app.replace_approvals(vec![ap(1), ap(2), ap(3)]);
2231        app.enter_approvals_modal();
2232        dispatch(&mut app, key(KeyCode::Char('j')));
2233        assert_eq!(app.selected_approval, 1);
2234        dispatch(&mut app, key(KeyCode::Char('j')));
2235        assert_eq!(app.selected_approval, 2);
2236        dispatch(&mut app, key(KeyCode::Char('j')));
2237        assert_eq!(app.selected_approval, 0, "wraps");
2238        dispatch(&mut app, key(KeyCode::Char('k')));
2239        assert_eq!(app.selected_approval, 2, "k wraps too");
2240    }
2241
2242    #[test]
2243    fn capital_y_routes_approve_through_decider() {
2244        use crate::approvals::test_support::MockApprovalDecider;
2245        let dec = MockApprovalDecider::default();
2246        let mut app = App::new();
2247        app.dismiss_splash();
2248        app.replace_approvals(vec![ap(7), ap(8)]);
2249        app.enter_approvals_modal();
2250        super::handle_event(
2251            &mut app,
2252            key(KeyCode::Char('Y')),
2253            &dec,
2254            &NoopSender,
2255            &EmptyMailbox,
2256            &crate::keysender::test_support::MockKeySender::default(),
2257        );
2258        let calls = dec.calls.lock().unwrap().clone();
2259        assert_eq!(calls.len(), 1);
2260        assert_eq!(calls[0].0, 7);
2261        assert_eq!(calls[0].1, crate::approvals::Decision::Approve);
2262        // Optimistic local removal — approval id 7 dropped.
2263        assert_eq!(app.pending_approvals.len(), 1);
2264        assert_eq!(app.pending_approvals[0].id, 8);
2265    }
2266
2267    #[test]
2268    fn capital_n_routes_deny_through_decider() {
2269        use crate::approvals::test_support::MockApprovalDecider;
2270        let dec = MockApprovalDecider::default();
2271        let mut app = App::new();
2272        app.dismiss_splash();
2273        app.replace_approvals(vec![ap(7)]);
2274        app.enter_approvals_modal();
2275        super::handle_event(
2276            &mut app,
2277            key(KeyCode::Char('N')),
2278            &dec,
2279            &NoopSender,
2280            &EmptyMailbox,
2281            &crate::keysender::test_support::MockKeySender::default(),
2282        );
2283        let calls = dec.calls.lock().unwrap().clone();
2284        assert_eq!(calls.len(), 1);
2285        assert_eq!(calls[0].1, crate::approvals::Decision::Deny);
2286        // Queue empty after the only approval resolves → modal closes.
2287        assert_eq!(app.stage, Stage::Triptych);
2288    }
2289
2290    #[test]
2291    fn esc_closes_approvals_modal() {
2292        let mut app = App::new();
2293        app.dismiss_splash();
2294        app.replace_approvals(vec![ap(1)]);
2295        app.enter_approvals_modal();
2296        dispatch(&mut app, key(KeyCode::Esc));
2297        assert_eq!(app.stage, Stage::Triptych);
2298    }
2299
2300    #[test]
2301    fn lowercase_y_routes_approve_through_decider() {
2302        // T-074 bug 4: discoverable approve. Most operators try
2303        // lowercase first; the modal must accept it on the
2304        // approve (low-risk) side. Deny stays Shift-gated.
2305        use crate::approvals::test_support::MockApprovalDecider;
2306        let dec = MockApprovalDecider::default();
2307        let mut app = App::new();
2308        app.dismiss_splash();
2309        app.replace_approvals(vec![ap(7)]);
2310        app.enter_approvals_modal();
2311        super::handle_event(
2312            &mut app,
2313            key(KeyCode::Char('y')),
2314            &dec,
2315            &NoopSender,
2316            &EmptyMailbox,
2317            &crate::keysender::test_support::MockKeySender::default(),
2318        );
2319        let calls = dec.calls.lock().unwrap().clone();
2320        assert_eq!(calls.len(), 1);
2321        assert_eq!(calls[0].1, crate::approvals::Decision::Approve);
2322    }
2323
2324    #[test]
2325    fn lowercase_n_does_not_deny() {
2326        // Asymmetry guard: deny is destructive — `n` lowercase must
2327        // NOT fire the decider. A future "symmetric loose" refactor
2328        // would silently regress the destructive-deny Shift-gate;
2329        // this test pins it.
2330        use crate::approvals::test_support::MockApprovalDecider;
2331        let dec = MockApprovalDecider::default();
2332        let mut app = App::new();
2333        app.dismiss_splash();
2334        app.replace_approvals(vec![ap(7)]);
2335        app.enter_approvals_modal();
2336        super::handle_event(
2337            &mut app,
2338            key(KeyCode::Char('n')),
2339            &dec,
2340            &NoopSender,
2341            &EmptyMailbox,
2342            &crate::keysender::test_support::MockKeySender::default(),
2343        );
2344        assert!(
2345            dec.calls.lock().unwrap().is_empty(),
2346            "lowercase n must not route through the decider"
2347        );
2348        assert_eq!(
2349            app.stage,
2350            Stage::ApprovalsModal,
2351            "stale lowercase n leaves the modal open"
2352        );
2353    }
2354
2355    #[test]
2356    fn shift_tab_cycles_panes_backward() {
2357        use crossterm::event::KeyModifiers;
2358        let mut app = App::new();
2359        app.dismiss_splash();
2360        assert_eq!(app.focused_pane, Pane::Roster);
2361        // Shift+Tab from Roster → Mailbox (the "back out of mailbox"
2362        // direction's mirror).
2363        dispatch(&mut app, key(KeyCode::BackTab));
2364        assert_eq!(app.focused_pane, Pane::Mailbox);
2365        // Some terminals send Tab + SHIFT instead of BackTab.
2366        dispatch(&mut app, key_with(KeyCode::Tab, KeyModifiers::SHIFT));
2367        assert_eq!(app.focused_pane, Pane::Detail);
2368    }
2369
2370    #[test]
2371    fn at_chord_opens_compose_dm_to_focused_agent() {
2372        let mut app = App::new();
2373        app.replace_team(fixture_team(vec![
2374            agent("writing:manager", AgentState::Running),
2375            agent("writing:dev1", AgentState::Running),
2376        ]));
2377        app.dismiss_splash();
2378        app.select_next();
2379        dispatch(&mut app, key(KeyCode::Char('@')));
2380        assert_eq!(app.stage, Stage::ComposeModal);
2381        match app.compose_target.as_ref() {
2382            Some(crate::compose::ComposeTarget::Dm { agent_id, .. }) => {
2383                assert_eq!(agent_id, "writing:dev1");
2384            }
2385            other => panic!("expected DM target, got {other:?}"),
2386        }
2387    }
2388
2389    #[test]
2390    fn bang_chord_opens_compose_broadcast_to_all_channel() {
2391        let mut app = App::new();
2392        app.replace_team(fixture_team(vec![agent(
2393            "writing:manager",
2394            AgentState::Running,
2395        )]));
2396        app.dismiss_splash();
2397        dispatch(&mut app, key(KeyCode::Char('!')));
2398        assert_eq!(app.stage, Stage::ComposeModal);
2399        match app.compose_target.as_ref() {
2400            Some(crate::compose::ComposeTarget::Broadcast { channel_id, .. }) => {
2401                assert_eq!(channel_id, "writing:all");
2402            }
2403            other => panic!("expected Broadcast target, got {other:?}"),
2404        }
2405    }
2406
2407    #[test]
2408    fn send_routes_dm_through_mock_sender() {
2409        use crate::compose::test_support::MockMessageSender;
2410        let sender = MockMessageSender::default();
2411        let mailbox = EmptyMailbox;
2412        let mut app = App::new();
2413        app.replace_team(fixture_team(vec![agent(
2414            "writing:dev1",
2415            AgentState::Running,
2416        )]));
2417        app.dismiss_splash();
2418        app.enter_compose_dm_for_focused();
2419        for c in "ship it".chars() {
2420            super::handle_event(
2421                &mut app,
2422                key(KeyCode::Char(c)),
2423                &NoopDecider,
2424                &sender,
2425                &mailbox,
2426                &crate::keysender::test_support::MockKeySender::default(),
2427            );
2428        }
2429        super::handle_event(
2430            &mut app,
2431            key_with(KeyCode::Enter, crossterm::event::KeyModifiers::CONTROL),
2432            &NoopDecider,
2433            &sender,
2434            &mailbox,
2435            &crate::keysender::test_support::MockKeySender::default(),
2436        );
2437        let calls = sender.dm_calls.lock().unwrap().clone();
2438        assert_eq!(calls.len(), 1);
2439        assert_eq!(calls[0].0, "writing:dev1");
2440        assert_eq!(calls[0].1, "ship it");
2441        assert_eq!(app.stage, Stage::Triptych, "modal closes on send");
2442    }
2443
2444    #[test]
2445    fn esc_esc_cancels_compose_without_send() {
2446        use crate::compose::test_support::MockMessageSender;
2447        let sender = MockMessageSender::default();
2448        let mailbox = EmptyMailbox;
2449        let mut app = App::new();
2450        app.replace_team(fixture_team(vec![agent(
2451            "writing:dev1",
2452            AgentState::Running,
2453        )]));
2454        app.dismiss_splash();
2455        app.enter_compose_dm_for_focused();
2456        for c in "draft".chars() {
2457            super::handle_event(
2458                &mut app,
2459                key(KeyCode::Char(c)),
2460                &NoopDecider,
2461                &sender,
2462                &mailbox,
2463                &crate::keysender::test_support::MockKeySender::default(),
2464            );
2465        }
2466        super::handle_event(
2467            &mut app,
2468            key(KeyCode::Esc),
2469            &NoopDecider,
2470            &sender,
2471            &mailbox,
2472            &crate::keysender::test_support::MockKeySender::default(),
2473        );
2474        super::handle_event(
2475            &mut app,
2476            key(KeyCode::Esc),
2477            &NoopDecider,
2478            &sender,
2479            &mailbox,
2480            &crate::keysender::test_support::MockKeySender::default(),
2481        );
2482        assert_eq!(app.stage, Stage::Triptych);
2483        assert!(sender.dm_calls.lock().unwrap().is_empty());
2484    }
2485
2486    #[test]
2487    fn send_failure_surfaces_error_inline_keeps_modal_open() {
2488        use crate::compose::test_support::MockMessageSender;
2489        let sender = MockMessageSender::default();
2490        *sender.fail_next.lock().unwrap() = Some("rate limit".into());
2491        let mailbox = EmptyMailbox;
2492        let mut app = App::new();
2493        app.replace_team(fixture_team(vec![agent(
2494            "writing:dev1",
2495            AgentState::Running,
2496        )]));
2497        app.dismiss_splash();
2498        app.enter_compose_dm_for_focused();
2499        super::handle_event(
2500            &mut app,
2501            key(KeyCode::Char('x')),
2502            &NoopDecider,
2503            &sender,
2504            &mailbox,
2505            &crate::keysender::test_support::MockKeySender::default(),
2506        );
2507        super::handle_event(
2508            &mut app,
2509            key_with(KeyCode::Enter, crossterm::event::KeyModifiers::CONTROL),
2510            &NoopDecider,
2511            &sender,
2512            &mailbox,
2513            &crate::keysender::test_support::MockKeySender::default(),
2514        );
2515        assert_eq!(app.stage, Stage::ComposeModal, "modal stays open on err");
2516        assert!(app
2517            .compose_error
2518            .as_deref()
2519            .unwrap_or_default()
2520            .contains("rate limit"));
2521    }
2522
2523    fn channel(id: &str, project: &str) -> crate::data::ChannelInfo {
2524        crate::data::ChannelInfo {
2525            id: id.into(),
2526            name: id
2527                .rsplit_once(':')
2528                .map(|(_, n)| n.to_string())
2529                .unwrap_or_default(),
2530            project_id: project.into(),
2531        }
2532    }
2533
2534    fn fixture_team_with_channels(
2535        agents: Vec<AgentInfo>,
2536        channels: Vec<crate::data::ChannelInfo>,
2537    ) -> TeamSnapshot {
2538        TeamSnapshot {
2539            root: std::path::PathBuf::from("/fixture"),
2540            team_name: "fixture".into(),
2541            agents,
2542            channels,
2543        }
2544    }
2545
2546    #[test]
2547    fn ctrl_w_toggles_wall_layout() {
2548        use crossterm::event::KeyModifiers;
2549        let mut app = App::new();
2550        app.dismiss_splash();
2551        assert_eq!(app.layout, MainLayout::Triptych);
2552        dispatch(
2553            &mut app,
2554            key_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
2555        );
2556        assert_eq!(app.layout, MainLayout::Wall);
2557        dispatch(
2558            &mut app,
2559            key_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
2560        );
2561        assert_eq!(app.layout, MainLayout::Triptych);
2562    }
2563
2564    #[test]
2565    fn ctrl_m_toggles_mailbox_first_layout() {
2566        use crossterm::event::KeyModifiers;
2567        let mut app = App::new();
2568        app.dismiss_splash();
2569        dispatch(
2570            &mut app,
2571            key_with(KeyCode::Char('m'), KeyModifiers::CONTROL),
2572        );
2573        assert_eq!(app.layout, MainLayout::MailboxFirst);
2574        dispatch(
2575            &mut app,
2576            key_with(KeyCode::Char('m'), KeyModifiers::CONTROL),
2577        );
2578        assert_eq!(app.layout, MainLayout::Triptych);
2579    }
2580
2581    #[test]
2582    fn wall_scroll_pages_through_overflow_agents() {
2583        let mut app = App::new();
2584        let mut agents: Vec<_> = (1..=10)
2585            .map(|i| agent(&format!("p:agent-{i:02}"), AgentState::Running))
2586            .collect();
2587        // managers-first sort would otherwise reorder; mark all as workers.
2588        for a in agents.iter_mut() {
2589            a.is_manager = false;
2590        }
2591        app.replace_team(fixture_team(agents));
2592        app.dismiss_splash();
2593        app.toggle_wall_layout();
2594        assert_eq!(app.wall_scroll, 0);
2595        app.wall_scroll_down();
2596        assert_eq!(app.wall_scroll, 4);
2597        app.wall_scroll_down();
2598        assert_eq!(app.wall_scroll, 8);
2599        // Past 10-1 = 9; cap blocks 12.
2600        app.wall_scroll_down();
2601        assert_eq!(app.wall_scroll, 8, "scroll capped at last full window");
2602        app.wall_scroll_up();
2603        assert_eq!(app.wall_scroll, 4);
2604    }
2605
2606    #[test]
2607    fn ctrl_pipe_adds_detail_split_capped_at_four() {
2608        use crossterm::event::KeyModifiers;
2609        let mut app = App::new();
2610        app.replace_team(fixture_team(vec![
2611            agent("p:a", AgentState::Running),
2612            agent("p:b", AgentState::Running),
2613        ]));
2614        app.dismiss_splash();
2615        for _ in 0..6 {
2616            dispatch(
2617                &mut app,
2618                key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
2619            );
2620        }
2621        assert_eq!(app.detail_splits.len(), 4, "split count capped at 4");
2622    }
2623
2624    #[test]
2625    fn ctrl_q_closes_focused_split() {
2626        use crossterm::event::KeyModifiers;
2627        let mut app = App::new();
2628        app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
2629        app.dismiss_splash();
2630        dispatch(
2631            &mut app,
2632            key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
2633        );
2634        dispatch(
2635            &mut app,
2636            key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
2637        );
2638        assert_eq!(app.detail_splits.len(), 2);
2639        dispatch(
2640            &mut app,
2641            key_with(KeyCode::Char('Q'), KeyModifiers::CONTROL),
2642        );
2643        assert_eq!(app.detail_splits.len(), 1);
2644    }
2645
2646    #[test]
2647    fn ctrl_hjkl_cycles_splits() {
2648        use crossterm::event::KeyModifiers;
2649        let mut app = App::new();
2650        app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
2651        app.dismiss_splash();
2652        for _ in 0..3 {
2653            dispatch(
2654                &mut app,
2655                key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
2656            );
2657        }
2658        assert_eq!(app.selected_split, 2);
2659        dispatch(
2660            &mut app,
2661            key_with(KeyCode::Char('l'), KeyModifiers::CONTROL),
2662        );
2663        assert_eq!(app.selected_split, 0, "wraps");
2664        dispatch(
2665            &mut app,
2666            key_with(KeyCode::Char('h'), KeyModifiers::CONTROL),
2667        );
2668        assert_eq!(app.selected_split, 2);
2669    }
2670
2671    #[test]
2672    fn wall_scroll_at_exactly_cap_agents_does_not_scroll() {
2673        // PR-UI-6 fixup (qa Gap 1a): with exactly WALL_TILE_CAP=4
2674        // agents the entire team fits in one window — scrolling
2675        // is a no-op in both directions. Pinning this catches a
2676        // future `<` → `<=` slip in `wall_scroll_down`.
2677        let mut app = App::new();
2678        let agents: Vec<_> = (1..=4)
2679            .map(|i| agent(&format!("p:agent-{i}"), AgentState::Running))
2680            .collect();
2681        app.replace_team(fixture_team(agents));
2682        app.dismiss_splash();
2683        app.toggle_wall_layout();
2684        assert_eq!(app.wall_scroll, 0);
2685        app.wall_scroll_down();
2686        assert_eq!(app.wall_scroll, 0, "exactly-cap should not advance");
2687        app.wall_scroll_up();
2688        assert_eq!(app.wall_scroll, 0);
2689    }
2690
2691    #[test]
2692    fn wall_scroll_at_cap_plus_one_advances_then_stops() {
2693        // PR-UI-6 fixup (qa Gap 1b): exactly 5 agents → 4 fit in
2694        // window-0, the 5th lives at window-4. One scroll
2695        // advances; the next caps. Pins the off-by-one between 4
2696        // and 5 agents.
2697        let mut app = App::new();
2698        let agents: Vec<_> = (1..=5)
2699            .map(|i| agent(&format!("p:agent-{i}"), AgentState::Running))
2700            .collect();
2701        app.replace_team(fixture_team(agents));
2702        app.dismiss_splash();
2703        app.toggle_wall_layout();
2704        app.wall_scroll_down();
2705        assert_eq!(app.wall_scroll, 4, "first scroll exposes agent 5");
2706        app.wall_scroll_down();
2707        assert_eq!(app.wall_scroll, 4, "second scroll caps; nothing past");
2708    }
2709
2710    #[test]
2711    fn esc_in_picker_dismisses_overlay_only_keeps_modal_open() {
2712        // PR-UI-6 fixup (Q6 dev2 review + qa Gap 3): Esc inside
2713        // the broadcast picker should close the picker overlay
2714        // and return to the editor in its current state — NOT
2715        // close the whole compose modal. Editor's Esc-Esc
2716        // already handles cancel-the-modal.
2717        let mut app = App::new();
2718        app.replace_team(fixture_team_with_channels(
2719            vec![agent("writing:manager", AgentState::Running)],
2720            vec![
2721                channel("writing:all", "writing"),
2722                channel("writing:editorial", "writing"),
2723            ],
2724        ));
2725        app.dismiss_splash();
2726        dispatch(&mut app, key(KeyCode::Char('!')));
2727        assert!(app.compose_picker_open);
2728        assert_eq!(app.stage, Stage::ComposeModal);
2729        dispatch(&mut app, key(KeyCode::Esc));
2730        assert!(!app.compose_picker_open, "picker dismissed");
2731        assert_eq!(app.stage, Stage::ComposeModal, "compose modal stays open");
2732    }
2733
2734    #[test]
2735    fn send_routes_broadcast_through_mock_sender_via_picker() {
2736        // PR-UI-6 fixup (qa Gap 4): the broadcast path needs the
2737        // same MockMessageSender pin the DM path got in PR-UI-5.
2738        // Pins both per-channel-correct-id (picker selection
2739        // flows through to the send call) AND routes-through-
2740        // `broadcast()`-not-`send()` (no DM call recorded).
2741        use crate::compose::test_support::MockMessageSender;
2742        let sender = MockMessageSender::default();
2743        let mailbox = EmptyMailbox;
2744        let mut app = App::new();
2745        app.replace_team(fixture_team_with_channels(
2746            vec![agent("writing:manager", AgentState::Running)],
2747            vec![
2748                channel("writing:all", "writing"),
2749                channel("writing:editorial", "writing"),
2750                channel("writing:critique", "writing"),
2751            ],
2752        ));
2753        app.dismiss_splash();
2754        // Open picker, walk to channel index 1 (`editorial`),
2755        // confirm, type a body, Ctrl+Enter to send.
2756        super::handle_event(
2757            &mut app,
2758            key(KeyCode::Char('!')),
2759            &NoopDecider,
2760            &sender,
2761            &mailbox,
2762            &crate::keysender::test_support::MockKeySender::default(),
2763        );
2764        super::handle_event(
2765            &mut app,
2766            key(KeyCode::Char('j')),
2767            &NoopDecider,
2768            &sender,
2769            &mailbox,
2770            &crate::keysender::test_support::MockKeySender::default(),
2771        );
2772        super::handle_event(
2773            &mut app,
2774            key(KeyCode::Enter),
2775            &NoopDecider,
2776            &sender,
2777            &mailbox,
2778            &crate::keysender::test_support::MockKeySender::default(),
2779        );
2780        for c in "ship docs".chars() {
2781            super::handle_event(
2782                &mut app,
2783                key(KeyCode::Char(c)),
2784                &NoopDecider,
2785                &sender,
2786                &mailbox,
2787                &crate::keysender::test_support::MockKeySender::default(),
2788            );
2789        }
2790        super::handle_event(
2791            &mut app,
2792            key_with(KeyCode::Enter, crossterm::event::KeyModifiers::CONTROL),
2793            &NoopDecider,
2794            &sender,
2795            &mailbox,
2796            &crate::keysender::test_support::MockKeySender::default(),
2797        );
2798        let dm_calls = sender.dm_calls.lock().unwrap().clone();
2799        let bcast_calls = sender.broadcast_calls.lock().unwrap().clone();
2800        assert!(dm_calls.is_empty(), "broadcast must not route via send_dm");
2801        assert_eq!(bcast_calls.len(), 1);
2802        assert_eq!(
2803            bcast_calls[0].0, "writing:editorial",
2804            "channel id from picker selection"
2805        );
2806        assert_eq!(bcast_calls[0].1, "ship docs");
2807        assert_eq!(app.stage, Stage::Triptych, "modal closes on send");
2808    }
2809
2810    #[test]
2811    fn bang_chord_opens_picker_when_channels_available() {
2812        let mut app = App::new();
2813        app.replace_team(fixture_team_with_channels(
2814            vec![agent("writing:manager", AgentState::Running)],
2815            vec![
2816                channel("writing:all", "writing"),
2817                channel("writing:editorial", "writing"),
2818                channel("writing:critique", "writing"),
2819            ],
2820        ));
2821        app.dismiss_splash();
2822        dispatch(&mut app, key(KeyCode::Char('!')));
2823        assert_eq!(app.stage, Stage::ComposeModal);
2824        assert!(app.compose_picker_open);
2825        // Walk the picker.
2826        dispatch(&mut app, key(KeyCode::Char('j')));
2827        assert_eq!(app.compose_picker_index, 1);
2828        // Confirm pulls into compose target.
2829        dispatch(&mut app, key(KeyCode::Enter));
2830        assert!(!app.compose_picker_open, "picker closes on confirm");
2831        match app.compose_target.as_ref() {
2832            Some(crate::compose::ComposeTarget::Broadcast { channel_id, .. }) => {
2833                assert_eq!(channel_id, "writing:editorial");
2834            }
2835            other => panic!("expected Broadcast target, got {other:?}"),
2836        }
2837    }
2838
2839    #[test]
2840    fn mailbox_first_layout_seeds_channel_selection_on_entry() {
2841        let mut app = App::new();
2842        app.replace_team(fixture_team_with_channels(
2843            vec![agent("writing:manager", AgentState::Running)],
2844            vec![
2845                channel("writing:all", "writing"),
2846                channel("writing:editorial", "writing"),
2847            ],
2848        ));
2849        app.dismiss_splash();
2850        assert!(app.selected_channel.is_none());
2851        app.toggle_mailbox_first_layout();
2852        assert_eq!(app.selected_channel, Some(0));
2853    }
2854
2855    #[test]
2856    fn help_overlay_opens_on_question_mark_closes_on_esc() {
2857        let mut app = App::new();
2858        app.dismiss_splash();
2859        dispatch(&mut app, key(KeyCode::Char('?')));
2860        assert_eq!(app.stage, Stage::HelpOverlay);
2861        dispatch(&mut app, key(KeyCode::Esc));
2862        assert_eq!(app.stage, Stage::Triptych);
2863    }
2864
2865    #[test]
2866    fn tutorial_opens_on_t_advances_and_closes() {
2867        let mut app = App::new();
2868        app.dismiss_splash();
2869        dispatch(&mut app, key(KeyCode::Char('t')));
2870        assert_eq!(app.stage, Stage::Tutorial);
2871        assert_eq!(app.tutorial_step, 0);
2872        // Any non-Esc/back key advances.
2873        dispatch(&mut app, key(KeyCode::Char(' ')));
2874        assert_eq!(app.tutorial_step, 1);
2875        // `k` walks back.
2876        dispatch(&mut app, key(KeyCode::Char('k')));
2877        assert_eq!(app.tutorial_step, 0);
2878        // Esc closes from any step.
2879        dispatch(&mut app, key(KeyCode::Esc));
2880        assert_eq!(app.stage, Stage::Triptych);
2881    }
2882
2883    #[test]
2884    fn tutorial_walk_back_at_step_zero_is_no_op() {
2885        // qa Gap C fold: pin the chosen behaviour for `k`/`Up`/`p`
2886        // at step 0 — saturating decrement keeps `tutorial_step`
2887        // at 0 rather than wrapping. Any future shift to
2888        // wrap-to-end would break this test, which is the point.
2889        let mut app = App::new();
2890        app.dismiss_splash();
2891        app.enter_tutorial();
2892        assert_eq!(app.tutorial_step, 0);
2893        dispatch(&mut app, key(KeyCode::Char('k')));
2894        assert_eq!(app.tutorial_step, 0, "step-0 walk-back is no-op");
2895        // The walk-back keypress must NOT close the tutorial
2896        // either — the Stage stays.
2897        assert_eq!(app.stage, Stage::Tutorial);
2898    }
2899
2900    #[test]
2901    fn ctrl_pipe_adds_vertical_split_ctrl_minus_adds_horizontal() {
2902        use crossterm::event::KeyModifiers;
2903        let mut app = App::new();
2904        app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
2905        app.dismiss_splash();
2906        dispatch(
2907            &mut app,
2908            key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
2909        );
2910        dispatch(
2911            &mut app,
2912            key_with(KeyCode::Char('-'), KeyModifiers::CONTROL),
2913        );
2914        assert_eq!(app.detail_splits.len(), 2);
2915        assert_eq!(app.detail_splits[0].1, SplitOrientation::Vertical);
2916        assert_eq!(app.detail_splits[1].1, SplitOrientation::Horizontal);
2917    }
2918
2919    #[test]
2920    fn ctrl_w_q_chord_prefix_closes_focused_split() {
2921        use crossterm::event::KeyModifiers;
2922        let mut app = App::new();
2923        app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
2924        app.dismiss_splash();
2925        // Two splits — `Ctrl+W` arms only when there's something
2926        // to close.
2927        dispatch(
2928            &mut app,
2929            key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
2930        );
2931        dispatch(
2932            &mut app,
2933            key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
2934        );
2935        dispatch(
2936            &mut app,
2937            key_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
2938        );
2939        assert_eq!(app.pending_chord, Some(KeyCode::Char('w')));
2940        // Plain `q` (no modifier) is now interpreted as the
2941        // chord-prefix follow-up — close split, NOT quit.
2942        dispatch(&mut app, key(KeyCode::Char('q')));
2943        assert_eq!(app.detail_splits.len(), 1);
2944        assert_eq!(app.stage, Stage::Triptych, "must not enter quit confirm");
2945        assert_eq!(app.pending_chord, None, "chord cleared");
2946    }
2947
2948    #[test]
2949    fn ctrl_w_o_chord_keeps_only_focused_split() {
2950        use crossterm::event::KeyModifiers;
2951        let mut app = App::new();
2952        app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
2953        app.dismiss_splash();
2954        for _ in 0..3 {
2955            dispatch(
2956                &mut app,
2957                key_with(KeyCode::Char('|'), KeyModifiers::CONTROL),
2958            );
2959        }
2960        // Focus the middle split.
2961        app.selected_split = 1;
2962        let kept_id = app.detail_splits[1].0.clone();
2963        dispatch(
2964            &mut app,
2965            key_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
2966        );
2967        dispatch(&mut app, key(KeyCode::Char('o')));
2968        assert_eq!(app.detail_splits.len(), 1);
2969        assert_eq!(app.detail_splits[0].0, kept_id);
2970        assert_eq!(app.selected_split, 0);
2971    }
2972
2973    #[test]
2974    fn add_detail_split_saturates_at_four_with_explicit_4_and_5_calls() {
2975        // qa Gap 2 fold: pin the cap explicitly. Reaching exactly
2976        // 4 must stick; the 5th call must be a no-op (not panic,
2977        // not silently grow). If `add_detail_split` ever returns
2978        // a Result, this test catches the silent-success regression.
2979        let mut app = App::new();
2980        app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
2981        for _ in 0..4 {
2982            app.add_detail_split();
2983        }
2984        assert_eq!(app.detail_splits.len(), 4);
2985        let snapshot_len = app.detail_splits.len();
2986        app.add_detail_split();
2987        assert_eq!(app.detail_splits.len(), snapshot_len, "5th call rejected");
2988    }
2989
2990    #[test]
2991    fn replace_approvals_clamps_selection_in_range() {
2992        let mut app = App::new();
2993        app.replace_approvals(vec![ap(1), ap(2), ap(3)]);
2994        app.selected_approval = 2;
2995        // Approval id 3 resolved out-of-band; new snapshot has 2 rows.
2996        app.replace_approvals(vec![ap(1), ap(2)]);
2997        assert_eq!(app.selected_approval, 1, "clamps to last index");
2998    }
2999
3000    #[test]
3001    fn arrow_keys_navigate_only_when_roster_focused() {
3002        let mut app = App::new();
3003        app.replace_team(fixture_team(vec![
3004            agent("p:a", AgentState::Running),
3005            agent("p:b", AgentState::Running),
3006        ]));
3007        app.dismiss_splash();
3008        // Focused pane is Roster → arrow cycles selection.
3009        app.selected_agent = Some(0);
3010        dispatch(&mut app, key(KeyCode::Down));
3011        assert_eq!(app.selected_agent, Some(1));
3012        // Cycle to Detail → arrow no longer touches selection.
3013        app.cycle_focus();
3014        dispatch(&mut app, key(KeyCode::Down));
3015        assert_eq!(
3016            app.selected_agent,
3017            Some(1),
3018            "non-roster focus ignores arrows"
3019        );
3020    }
3021
3022    // ---- T-108 stream-keys mode -------------------------------------------
3023
3024    /// Spin up a Triptych-stage app with one agent selected and the
3025    /// detail pane focused — the standard precondition for entering
3026    /// stream-keys mode.
3027    fn stream_keys_fixture() -> App {
3028        let mut app = App::new();
3029        app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3030        app.dismiss_splash();
3031        app.cycle_focus(); // Roster → Detail
3032        assert_eq!(app.focused_pane, Pane::Detail);
3033        assert_eq!(app.selected_agent, Some(0));
3034        app
3035    }
3036
3037    fn stream_dispatch(
3038        app: &mut App,
3039        ev: Event,
3040        key_sender: &crate::keysender::test_support::MockKeySender,
3041    ) {
3042        super::handle_event(
3043            app,
3044            ev,
3045            &NoopDecider,
3046            &NoopSender,
3047            &EmptyMailbox,
3048            key_sender,
3049        );
3050    }
3051
3052    #[test]
3053    fn ctrl_e_enters_stream_keys_when_detail_focused() {
3054        use crate::keysender::test_support::MockKeySender;
3055        use crossterm::event::KeyModifiers;
3056        let mut app = stream_keys_fixture();
3057        let ks = MockKeySender::default();
3058        stream_dispatch(
3059            &mut app,
3060            key_with(KeyCode::Char('e'), KeyModifiers::CONTROL),
3061            &ks,
3062        );
3063        assert_eq!(app.stage, Stage::StreamKeys);
3064        assert!(
3065            ks.calls.lock().unwrap().is_empty(),
3066            "the activation chord itself never forwards a keystroke"
3067        );
3068    }
3069
3070    #[test]
3071    fn ctrl_e_no_op_when_detail_not_focused() {
3072        // Activation gate: stream-mode never triggers from Roster /
3073        // Mailbox focus, so a stray `Ctrl+E` while scrolling the
3074        // roster doesn't yank the operator into a modal they didn't
3075        // ask for.
3076        use crate::keysender::test_support::MockKeySender;
3077        use crossterm::event::KeyModifiers;
3078        let mut app = App::new();
3079        app.replace_team(fixture_team(vec![agent("p:a", AgentState::Running)]));
3080        app.dismiss_splash();
3081        assert_eq!(app.focused_pane, Pane::Roster);
3082        let ks = MockKeySender::default();
3083        stream_dispatch(
3084            &mut app,
3085            key_with(KeyCode::Char('e'), KeyModifiers::CONTROL),
3086            &ks,
3087        );
3088        assert_eq!(app.stage, Stage::Triptych);
3089    }
3090
3091    #[test]
3092    fn ctrl_e_no_op_when_no_agent_selected() {
3093        // No target session → entering stream-mode would type into
3094        // the void. The guard short-circuits.
3095        use crate::keysender::test_support::MockKeySender;
3096        use crossterm::event::KeyModifiers;
3097        let mut app = App::new();
3098        app.dismiss_splash();
3099        app.cycle_focus(); // Detail
3100        assert_eq!(app.selected_agent, None);
3101        let ks = MockKeySender::default();
3102        stream_dispatch(
3103            &mut app,
3104            key_with(KeyCode::Char('e'), KeyModifiers::CONTROL),
3105            &ks,
3106        );
3107        assert_eq!(app.stage, Stage::Triptych);
3108    }
3109
3110    #[test]
3111    fn esc_exits_stream_keys() {
3112        use crate::keysender::test_support::MockKeySender;
3113        let mut app = stream_keys_fixture();
3114        app.enter_stream_keys();
3115        assert_eq!(app.stage, Stage::StreamKeys);
3116        let ks = MockKeySender::default();
3117        stream_dispatch(&mut app, key(KeyCode::Esc), &ks);
3118        assert_eq!(app.stage, Stage::Triptych);
3119        assert!(
3120            ks.calls.lock().unwrap().is_empty(),
3121            "Esc is the exit chord — it must not forward as a keystroke"
3122        );
3123    }
3124
3125    #[test]
3126    fn stream_mode_forwards_printable_chars_to_target_session() {
3127        use crate::keysender::test_support::MockKeySender;
3128        let mut app = stream_keys_fixture();
3129        app.enter_stream_keys();
3130        let ks = MockKeySender::default();
3131        for c in "hi".chars() {
3132            stream_dispatch(&mut app, key(KeyCode::Char(c)), &ks);
3133        }
3134        let calls = ks.calls.lock().unwrap();
3135        assert_eq!(calls.len(), 2, "one tmux send-keys per keystroke");
3136        // Target session = the focused agent's tmux_session (set by
3137        // the fixture to `t-p-a`).
3138        assert_eq!(calls[0].0, "t-p-a");
3139        assert_eq!(calls[0].1.args, vec!["-l".to_string(), "h".to_string()]);
3140        assert_eq!(calls[1].1.args, vec!["-l".to_string(), "i".to_string()]);
3141    }
3142
3143    #[test]
3144    fn stream_mode_passes_ctrl_c_through_to_agent() {
3145        // Issue #108 design point: Ctrl+C is shell-SIGINT semantics,
3146        // not a stream-mode escape. Pin the contract so a future
3147        // "intercept Ctrl+C as bail" refactor doesn't regress it.
3148        use crate::keysender::test_support::MockKeySender;
3149        use crossterm::event::KeyModifiers;
3150        let mut app = stream_keys_fixture();
3151        app.enter_stream_keys();
3152        let ks = MockKeySender::default();
3153        stream_dispatch(
3154            &mut app,
3155            key_with(KeyCode::Char('c'), KeyModifiers::CONTROL),
3156            &ks,
3157        );
3158        assert_eq!(app.stage, Stage::StreamKeys, "Ctrl+C does NOT exit");
3159        let calls = ks.calls.lock().unwrap();
3160        assert_eq!(calls.len(), 1);
3161        assert_eq!(calls[0].1.args, vec!["C-c".to_string()]);
3162    }
3163
3164    #[test]
3165    fn stream_mode_forwards_enter_and_arrows() {
3166        use crate::keysender::test_support::MockKeySender;
3167        let mut app = stream_keys_fixture();
3168        app.enter_stream_keys();
3169        let ks = MockKeySender::default();
3170        stream_dispatch(&mut app, key(KeyCode::Enter), &ks);
3171        stream_dispatch(&mut app, key(KeyCode::Up), &ks);
3172        let calls = ks.calls.lock().unwrap();
3173        assert_eq!(calls[0].1.args, vec!["Enter".to_string()]);
3174        assert_eq!(calls[1].1.args, vec!["Up".to_string()]);
3175    }
3176
3177    #[test]
3178    fn stream_target_session_uses_focused_split_when_present() {
3179        // Splits change which agent the operator is "looking at."
3180        // The selected_split index drives the focus ring in
3181        // render_detail_splits; stream_target_session must mirror
3182        // that so typing lands in the right pane.
3183        let mut app = App::new();
3184        app.replace_team(fixture_team(vec![
3185            agent("p:a", AgentState::Running),
3186            agent("p:b", AgentState::Running),
3187        ]));
3188        app.dismiss_splash();
3189        app.cycle_focus(); // Detail
3190        app.selected_agent = Some(0);
3191        // Manually push a split for `p:b` and focus it.
3192        app.detail_splits
3193            .push(("p:b".into(), crate::app::SplitOrientation::Vertical));
3194        app.selected_split = 1; // cell index 0 = focused agent, 1 = first split
3195        let target = app.stream_target_session();
3196        assert_eq!(
3197            target.as_deref(),
3198            Some("t-p-b"),
3199            "selected split's agent drives the target"
3200        );
3201    }
3202
3203    #[test]
3204    fn stream_mode_drops_back_when_target_session_disappears() {
3205        // If the team gets reloaded mid-stream and the focused
3206        // agent's index points off the end, the next keystroke
3207        // can't resolve a session. Drop back to Triptych so the
3208        // operator isn't silently typing into the void.
3209        use crate::keysender::test_support::MockKeySender;
3210        let mut app = stream_keys_fixture();
3211        app.enter_stream_keys();
3212        // Simulate the agent disappearing.
3213        app.selected_agent = None;
3214        app.team.agents.clear();
3215        let ks = MockKeySender::default();
3216        stream_dispatch(&mut app, key(KeyCode::Char('a')), &ks);
3217        assert_eq!(app.stage, Stage::Triptych);
3218        assert!(ks.calls.lock().unwrap().is_empty());
3219    }
3220}