Skip to main content

teamctl_ui/
triptych.rs

1//! Triptych — the default Layout A. Sidebar + right-stack: an
2//! Agents column on the left (current sidebar width), with Detail
3//! stacked above Mailbox at 50/50 on the right. An Approvals stripe
4//! is reserved at the top (rendered only when there's something to
5//! surface) and a focus ring on the active pane.
6//!
7//! The Agents + Detail panes wire to live data:
8//! - Agents lists `app.team.agents` with single-cell state glyphs
9//!   driven by `data::state_glyph`. Selection is highlighted with
10//!   the focus accent.
11//! - Detail shows the last-N lines of `app.detail_buffer` (the
12//!   tmux capture-pane scrollback for the focused agent), or an
13//!   empty-state hint when no agent is selected.
14//! - Mailbox stacks below Detail and pulls from the focused
15//!   agent's mailbox tab buffer.
16
17use ratatui::buffer::Buffer;
18use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
19use ratatui::style::{Modifier, Style};
20use ratatui::text::Line;
21use ratatui::widgets::{Block, Borders, Paragraph, Widget};
22
23use crate::app::App;
24use crate::data::{state_glyph, tree_row_meta, AgentInfo, TreeRowMeta};
25use crate::mailbox::{render_row, MailboxInputKind, MailboxTab};
26use crate::theme::ColorMode;
27
28/// Top-level layout selector for the main view (Stage::Triptych).
29/// PR-UI-1..5 used the Triptych shape exclusively; PR-UI-6 adds
30/// Wall (orchestrator overview, up to 4 tiles + scroll) and
31/// MailboxFirst (channel-feed centric for cross-team triage).
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum MainLayout {
34    Triptych,
35    Wall,
36    MailboxFirst,
37}
38
39impl MainLayout {
40    /// `Ctrl+W` (or standalone `w` from the SPEC chord map)
41    /// toggles between Triptych ↔ Wall.
42    pub fn toggle_wall(self) -> Self {
43        if matches!(self, MainLayout::Wall) {
44            MainLayout::Triptych
45        } else {
46            MainLayout::Wall
47        }
48    }
49
50    /// `Ctrl+M` toggles between Triptych ↔ MailboxFirst.
51    pub fn toggle_mailbox_first(self) -> Self {
52        if matches!(self, MainLayout::MailboxFirst) {
53            MainLayout::Triptych
54        } else {
55            MainLayout::MailboxFirst
56        }
57    }
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum Pane {
62    Roster,
63    Detail,
64    Mailbox,
65}
66
67impl Pane {
68    /// `Tab` cycles `Agents → Detail → Mailbox → Agents`. With the
69    /// sidebar + right-stack geometry that reads spatially as
70    /// left → top-right → bottom-right → wrap to left.
71    pub fn next(self) -> Self {
72        match self {
73            Pane::Roster => Pane::Detail,
74            Pane::Detail => Pane::Mailbox,
75            Pane::Mailbox => Pane::Roster,
76        }
77    }
78
79    /// `Shift+Tab` cycles backward — `Agents → Mailbox → Detail →
80    /// Agents`. Closes the no-easy-exit-from-mailbox UX gap: the
81    /// operator Tabs into Mailbox, then Shift+Tab backs out cleanly
82    /// without the `q`-confirm round-trip.
83    pub fn prev(self) -> Self {
84        match self {
85            Pane::Roster => Pane::Mailbox,
86            Pane::Detail => Pane::Roster,
87            Pane::Mailbox => Pane::Detail,
88        }
89    }
90}
91
92pub fn draw(f: &mut ratatui::Frame<'_>, area: Rect, app: &App) {
93    Triptych { app }.render(area, f.buffer_mut());
94}
95
96pub struct Triptych<'a> {
97    pub app: &'a App,
98}
99
100impl Widget for Triptych<'_> {
101    fn render(self, area: Rect, buf: &mut Buffer) {
102        // PR-UI-4: the approvals stripe takes one line at the top
103        // when there's at least one pending approval. The
104        // `stripe_visible` const PR-UI-1 scaffolded as `false` is
105        // now `app.has_pending_approvals()`.
106        let stripe_visible = self.app.has_pending_approvals();
107        let body = if stripe_visible {
108            let v = Layout::default()
109                .direction(Direction::Vertical)
110                .constraints([Constraint::Length(1), Constraint::Min(0)])
111                .split(area);
112            render_approvals_stripe(buf, v[0], self.app);
113            v[1]
114        } else {
115            area
116        };
117
118        // Outer split: Agents sidebar on the left at the same
119        // 28-cell width the previous Triptych Roster column used,
120        // then a right-hand stack that fills the rest of the row.
121        let outer = Layout::default()
122            .direction(Direction::Horizontal)
123            .constraints([
124                Constraint::Length(28), // agents sidebar
125                Constraint::Min(0),     // right-stack
126            ])
127            .split(body);
128
129        // Inner split inside the right stack: normally Detail above at
130        // 60% / Mailbox below at 40%, but in stream-keys mode Detail
131        // expands to near-full height and the Mailbox shrinks to a ~2-row
132        // strip (#459). The constraints come from the shared
133        // `right_stack_constraints` so the tmux-sync path
134        // (`pane_resize::triptych_detail_area`) applies the identical
135        // split — if the two diverged the agent pane would keep the old
136        // split and the on-screen shrink would be cosmetic.
137        let stream = matches!(self.app.stage, crate::app::Stage::StreamKeys);
138        let right_stack = Layout::default()
139            .direction(Direction::Vertical)
140            .constraints(crate::pane_resize::right_stack_constraints(stream))
141            .split(outer[1]);
142
143        render_agents(buf, outer[0], self.app);
144        render_detail(buf, right_stack[0], self.app);
145        render_mailbox(buf, right_stack[1], self.app);
146    }
147}
148
149fn render_approvals_stripe(buf: &mut Buffer, area: Rect, app: &App) {
150    let n = app.pending_approvals.len();
151    let plural = if n == 1 { "" } else { "s" };
152    let text = format!("⚠  approvals: {n} pending{plural} — `a` to review");
153    // Bright accent + reversed for the stripe — same affordance
154    // pattern as the focused-pane border, applied to a full row so
155    // the warning reads in any colour mode.
156    let style = Style::default()
157        .fg(app.capabilities.accent())
158        .add_modifier(Modifier::REVERSED | Modifier::BOLD);
159    Paragraph::new(text)
160        .style(style)
161        .alignment(Alignment::Left)
162        .render(area, buf);
163}
164
165fn render_agents(buf: &mut Buffer, area: Rect, app: &App) {
166    let focused = app.focused_pane == Pane::Roster;
167    let block = pane_block("AGENTS", focused, app);
168    let inner = block.inner(area);
169    block.render(area, buf);
170
171    if app.team.agents.is_empty() {
172        let empty = Paragraph::new("(no agents)")
173            .style(Style::default().fg(app.capabilities.muted()))
174            .alignment(Alignment::Center);
175        empty.render(inner, buf);
176        return;
177    }
178
179    let ascii = matches!(app.capabilities.color, ColorMode::Monochrome);
180    // T-211: pair each row with tree-render metadata so `agent_line`
181    // can draw `├─` / `└─` glyphs for `reports_to` children. Teams
182    // with no `reports_to` usage produce all-depth-0 metas and the
183    // existing flat-render output is byte-identical (no prefix bytes
184    // for depth 0).
185    let metas = tree_row_meta(&app.team.agents);
186    let lines: Vec<Line<'_>> = app
187        .team
188        .agents
189        .iter()
190        .zip(metas.iter())
191        .enumerate()
192        .map(|(i, (info, meta))| agent_line(info, *meta, Some(i) == app.selected_agent, ascii, app))
193        .collect();
194    let para = Paragraph::new(lines).alignment(Alignment::Left);
195    para.render(inner, buf);
196}
197
198/// Render the indent + branch glyph for a tree row at the given
199/// depth. Depth 0 produces a single leading space — same as today's
200/// flat-list shape. Depth 1 produces ` ├─ ` / ` └─ ` (or ` |- ` /
201/// `` `- `` in ASCII), with a matching leading space so the branch
202/// glyph sits one indent past the depth-0 leading column (owner
203/// alignment review, tg msg 1892). Depth >= 2 isn't a v1 goal per
204/// the issue's non-goal list; the renderer clamps to the depth-1
205/// shape behind a single extra indent so a chained schema (if one
206/// ever slips past validation) at least lays out top-to-bottom
207/// without crashing.
208fn tree_prefix(meta: TreeRowMeta, ascii: bool) -> String {
209    let branch = match (meta.is_last_sibling, ascii) {
210        (false, false) => "├─",
211        (true, false) => "└─",
212        (false, true) => "|-",
213        (true, true) => "`-",
214    };
215    match meta.depth {
216        0 => " ".to_string(),
217        1 => format!(" {branch} "),
218        // Defensive clamp for any chain past v1's schema scope.
219        _ => format!("   {branch} "),
220    }
221}
222
223fn agent_line<'a>(
224    info: &'a AgentInfo,
225    meta: TreeRowMeta,
226    selected: bool,
227    ascii: bool,
228    app: &App,
229) -> Line<'a> {
230    let glyph = state_glyph(info, ascii, app.now_secs);
231    // T-160: roster prefers the operator's display_name when set,
232    // falling back to the YAML key (`info.agent`) — the canonical id
233    // would over-prefix the project and is reserved for cross-project
234    // surfaces like the detail header and wall-tile title.
235    let label = info.display_name.as_deref().unwrap_or(&info.agent);
236    let prefix = tree_prefix(meta, ascii);
237    let display = format!("{prefix}{glyph}  {label}");
238    let style = if selected {
239        Style::default()
240            .fg(app.capabilities.accent())
241            .add_modifier(Modifier::REVERSED)
242    } else {
243        Style::default()
244    };
245    Line::styled(display, style)
246}
247
248fn render_detail(buf: &mut Buffer, area: Rect, app: &App) {
249    let focused_pane = app.focused_pane == Pane::Detail;
250    // T-108: when stream-mode is active, mark the border title with a
251    // bright `[STREAM-KEYS]` tag so the pane the operator's typing
252    // into is unambiguous even at a glance away from the statusline.
253    let stream = matches!(app.stage, crate::app::Stage::StreamKeys);
254    let title = match app
255        .selected_agent
256        .and_then(|i| app.team.agents.get(i))
257        .map(|a| crate::data::agent_label(&app.team, &a.id))
258    {
259        Some(label) if stream => format!("DETAIL · {label}  [STREAM-KEYS]"),
260        Some(label) => format!("DETAIL · {label}"),
261        None if stream => "DETAIL  [STREAM-KEYS]".to_string(),
262        None => "DETAIL".to_string(),
263    };
264    // While stream-mode is active, force the focus-ring style on the
265    // detail pane regardless of `focused_pane`. Pane focus and stream-
266    // mode are aligned in practice (entry is gated on detail focus),
267    // but a future refactor that lets focus drift mid-mode shouldn't
268    // visually downgrade the active stream pane.
269    let outer_block = pane_block(&title, focused_pane || stream, app);
270    let inner = outer_block.inner(area);
271    outer_block.render(area, buf);
272
273    if app.selected_agent.is_none() || app.team.agents.is_empty() {
274        let muted = Style::default().fg(app.capabilities.muted());
275        Paragraph::new("(select an agent on the left to follow its session)")
276            .style(muted)
277            .alignment(Alignment::Center)
278            .render(inner, buf);
279        return;
280    }
281
282    // PR-UI-7 fixup (qa Gap D): when `detail_splits` is non-empty
283    // the detail pane subdivides — primary cell shows the focused
284    // agent, additional cells show each split's agent. Operators
285    // see the actual visual effect of `Ctrl+|` / `Ctrl+-`.
286    if !app.detail_splits.is_empty() {
287        render_detail_splits(buf, inner, app);
288        return;
289    }
290
291    if app.detail_buffer.is_empty() {
292        let muted = Style::default().fg(app.capabilities.muted());
293        Paragraph::new("(no scrollback yet — agent may be starting up)")
294            .style(muted)
295            .alignment(Alignment::Center)
296            .render(inner, buf);
297        return;
298    }
299
300    // Tail the buffer to whatever fits; ratatui already clips lines
301    // that overrun the rect, but pre-trimming saves a render-time
302    // copy of thousands of lines we'd never see.
303    let cap = inner.height as usize;
304    let start = app.detail_buffer.len().saturating_sub(cap);
305    // T-074 bug 3: parse the ANSI escape sequences captured by
306    // `tmux capture-pane -e` into styled spans. `Line::raw` would
307    // render the escapes as literal `\x1b[...` garbage; `into_text`
308    // turns SGR codes (colours, bold, dim, …) into ratatui spans
309    // so the agent's terminal output renders coloured. Lines that
310    // contain no ANSI degrade gracefully to plain spans.
311    use ansi_to_tui::IntoText;
312    let lines: Vec<Line<'_>> = app.detail_buffer[start..]
313        .iter()
314        .flat_map(|s| match s.as_bytes().into_text() {
315            Ok(text) => text.lines.into_iter().collect::<Vec<_>>(),
316            Err(_) => vec![Line::raw(s.clone())],
317        })
318        .collect();
319    Paragraph::new(lines).render(inner, buf);
320}
321
322/// Subdivide the detail-pane area when `detail_splits` is
323/// non-empty. Composition (qa Gap D fixup):
324///
325/// - Cell 0 always shows the focused agent (the original detail
326///   stream); cells 1..=N show each split's agent in order.
327/// - The operator's mental model is "vertical adds a column,
328///   horizontal adds a row." We honour that by folding vertical
329///   splits into columns first, then horizontal splits subdivide
330///   each column. With all-vertical or all-horizontal splits the
331///   layout is straightforward; with a mix the columns grow
332///   left-to-right and the horizontal splits stack within their
333///   column.
334/// - Each cell renders the agent's id + state glyph in the title
335///   bar and the focused agent's `detail_buffer` lines as content.
336///   Non-focused splits show a `(focus this split to stream)`
337///   placeholder — multi-stream pane captures land in T-068
338///   alongside the per-tile Wall captures.
339/// - The focused split (per `app.selected_split`) gets the accent
340///   focus-ring border; others get the muted border.
341fn render_detail_splits(buf: &mut Buffer, area: Rect, app: &App) {
342    use ratatui::layout::Direction as Dir;
343
344    // Build the cell list: [focused, split_0, split_1, ...].
345    // Each cell carries (agent_id, orientation_hint, is_focused_split).
346    // `orientation_hint` for the focused agent defaults to Vertical
347    // so the first split's chord choice drives the layout.
348    let focused_id = app
349        .selected_agent_id()
350        .unwrap_or_else(|| "<no agent>".into());
351    let mut cells: Vec<(String, crate::app::SplitOrientation, bool)> = Vec::new();
352    cells.push((
353        focused_id,
354        // Match whatever the first split orientation is (or Vertical
355        // if no splits — the no-splits path is short-circuited
356        // above this fn's caller).
357        app.detail_splits
358            .first()
359            .map(|(_, o)| *o)
360            .unwrap_or(crate::app::SplitOrientation::Vertical),
361        app.selected_split == 0 && app.focused_pane == Pane::Detail,
362    ));
363    for (i, (id, orientation)) in app.detail_splits.iter().enumerate() {
364        cells.push((
365            id.clone(),
366            *orientation,
367            app.selected_split == i + 1 && app.focused_pane == Pane::Detail,
368        ));
369    }
370
371    // Group cells into columns: a Vertical split starts a new
372    // column; Horizontal splits stack within the current column.
373    let mut columns: Vec<Vec<usize>> = vec![vec![0]];
374    for (idx, (_, orientation, _)) in cells.iter().enumerate().skip(1) {
375        match orientation {
376            crate::app::SplitOrientation::Vertical => columns.push(vec![idx]),
377            crate::app::SplitOrientation::Horizontal => {
378                columns.last_mut().expect("seed column").push(idx);
379            }
380        }
381    }
382
383    let col_count = columns.len();
384    let col_constraints: Vec<Constraint> = (0..col_count)
385        .map(|_| Constraint::Ratio(1, col_count as u32))
386        .collect();
387    let col_areas = ratatui::layout::Layout::default()
388        .direction(Dir::Horizontal)
389        .constraints(col_constraints)
390        .split(area);
391
392    for (col_idx, col_cells) in columns.iter().enumerate() {
393        let col_area = col_areas[col_idx];
394        let row_count = col_cells.len();
395        let row_constraints: Vec<Constraint> = (0..row_count)
396            .map(|_| Constraint::Ratio(1, row_count as u32))
397            .collect();
398        let row_areas = ratatui::layout::Layout::default()
399            .direction(Dir::Vertical)
400            .constraints(row_constraints)
401            .split(col_area);
402        for (row_idx, &cell_idx) in col_cells.iter().enumerate() {
403            let cell_area = row_areas[row_idx];
404            let (agent_id, _, is_focused_split) = &cells[cell_idx];
405            render_split_cell(buf, cell_area, app, agent_id, *is_focused_split);
406        }
407    }
408}
409
410fn render_split_cell(
411    buf: &mut Buffer,
412    area: Rect,
413    app: &App,
414    agent_id: &str,
415    is_focused_split: bool,
416) {
417    let ascii = matches!(app.capabilities.color, ColorMode::Monochrome);
418    let glyph = app
419        .team
420        .agents
421        .iter()
422        .find(|a| a.id == agent_id)
423        .map(|info| crate::data::state_glyph(info, ascii, app.now_secs))
424        .unwrap_or("?");
425    let label = crate::data::agent_label(&app.team, agent_id);
426    let title = format!(" {glyph} {label} ");
427    let border = if is_focused_split {
428        Style::default()
429            .fg(app.capabilities.accent())
430            .add_modifier(Modifier::BOLD)
431    } else {
432        Style::default().fg(app.capabilities.muted())
433    };
434    let block = Block::default()
435        .title(title)
436        .borders(Borders::ALL)
437        .border_style(border);
438    let inner = block.inner(area);
439    block.render(area, buf);
440
441    // Only the focused split streams the live detail buffer.
442    // Non-focused splits show the placeholder — multi-stream
443    // captures land in T-068 alongside Wall's per-tile streaming.
444    let muted = Style::default().fg(app.capabilities.muted());
445    if !is_focused_split {
446        Paragraph::new("(focus this split to stream)")
447            .style(muted)
448            .alignment(Alignment::Center)
449            .render(inner, buf);
450        return;
451    }
452    if app.detail_buffer.is_empty() {
453        Paragraph::new("(no scrollback yet)")
454            .style(muted)
455            .alignment(Alignment::Center)
456            .render(inner, buf);
457        return;
458    }
459    let cap = inner.height as usize;
460    let start = app.detail_buffer.len().saturating_sub(cap);
461    let lines: Vec<Line<'_>> = app.detail_buffer[start..]
462        .iter()
463        .map(|s| Line::raw(s.clone()))
464        .collect();
465    Paragraph::new(lines).render(inner, buf);
466}
467
468fn render_mailbox(buf: &mut Buffer, area: Rect, app: &App) {
469    let focused = app.focused_pane == Pane::Mailbox;
470    let block = pane_block("MAILBOX", focused, app);
471    let inner = block.inner(area);
472    block.render(area, buf);
473
474    if inner.height == 0 {
475        return;
476    }
477
478    // T-131 PR-2: between tabs and body, conditionally reserve one
479    // line for either the open filter / search input bar OR the
480    // always-visible filter-state indicator when filter / search are
481    // non-empty but the input is closed. Neither condition active →
482    // height 0, body gets all the space (matches PR-1 layout).
483    let tab = app.mailbox_tab;
484    let input_open = app.mailbox_input_mode.is_some();
485    let filter = app.mailbox.filter_text(tab);
486    let search = app.mailbox.search_text(tab);
487    let indicator_visible = !input_open && (!filter.is_empty() || !search.is_empty());
488    let aux_height = if input_open || indicator_visible {
489        1
490    } else {
491        0
492    };
493
494    let layout = Layout::default()
495        .direction(Direction::Vertical)
496        .constraints([
497            Constraint::Length(1),          // tabs
498            Constraint::Length(aux_height), // input bar OR state indicator (0 when neither)
499            Constraint::Min(0),             // body
500        ])
501        .split(inner);
502
503    render_mailbox_tabs(buf, layout[0], app);
504    if aux_height == 1 {
505        render_mailbox_aux(buf, layout[1], app);
506    }
507    render_mailbox_body(buf, layout[2], app);
508}
509
510fn render_mailbox_aux(buf: &mut Buffer, area: Rect, app: &App) {
511    // T-131 PR-2: one-line auxiliary row between mailbox tabs and
512    // body. Either:
513    //   - the active input bar (`filter: foo█` / `search: bar█`,
514    //     with a faux cursor block at the end of the typed text),
515    //     when an input is open; or
516    //   - the always-visible filter-state indicator (`filter: foo
517    //     search: bar`) when input is closed but at least one axis
518    //     is non-empty — without this, closing the input leaves a
519    //     shorter row list with no signal why rows disappeared,
520    //     the UX bug the issue flagged.
521    let tab = app.mailbox_tab;
522    let muted = Style::default().fg(app.capabilities.muted());
523    let text = match app.mailbox_input_mode {
524        Some(MailboxInputKind::Filter) => {
525            format!("filter: {}\u{2588}", app.mailbox.filter_text(tab))
526        }
527        Some(MailboxInputKind::Search) => {
528            format!("search: {}\u{2588}", app.mailbox.search_text(tab))
529        }
530        None => {
531            // Closed input but at least one axis non-empty (caller
532            // gated; this branch is the indicator only).
533            let filter = app.mailbox.filter_text(tab);
534            let search = app.mailbox.search_text(tab);
535            match (filter.is_empty(), search.is_empty()) {
536                (false, false) => format!("filter: {filter}  search: {search}"),
537                (false, true) => format!("filter: {filter}"),
538                (true, false) => format!("search: {search}"),
539                (true, true) => String::new(), // unreachable per gate
540            }
541        }
542    };
543    Paragraph::new(text).style(muted).render(area, buf);
544}
545
546fn render_mailbox_tabs(buf: &mut Buffer, area: Rect, app: &App) {
547    // `Inbox  Sent  Channel  Wire` — active tab gets the focus
548    // accent (REVERSED so it reads as a highlight bar even in
549    // monochrome terminals where colour alone wouldn't carry the
550    // signal).
551    let active_style = Style::default()
552        .fg(app.capabilities.accent())
553        .add_modifier(Modifier::REVERSED);
554    let muted = Style::default().fg(app.capabilities.muted());
555    let mut spans: Vec<ratatui::text::Span<'_>> = Vec::with_capacity(7);
556    for (i, tab) in MailboxTab::ALL.iter().enumerate() {
557        if i > 0 {
558            spans.push(ratatui::text::Span::styled("  ", muted));
559        }
560        let label = format!(" {} ", tab.label());
561        let style = if app.mailbox_tab == *tab {
562            active_style
563        } else {
564            muted
565        };
566        spans.push(ratatui::text::Span::styled(label, style));
567    }
568    Paragraph::new(Line::from(spans)).render(area, buf);
569}
570
571fn render_mailbox_body(buf: &mut Buffer, area: Rect, app: &App) {
572    if app.selected_agent_id().is_none() {
573        let muted = Style::default().fg(app.capabilities.muted());
574        Paragraph::new("(select an agent)")
575            .style(muted)
576            .alignment(Alignment::Center)
577            .render(area, buf);
578        return;
579    }
580
581    let rows = app.mailbox.rows(app.mailbox_tab);
582    if rows.is_empty() {
583        let muted = Style::default().fg(app.capabilities.muted());
584        Paragraph::new(app.mailbox_tab.empty_hint())
585            .style(muted)
586            .alignment(Alignment::Center)
587            .render(area, buf);
588        return;
589    }
590
591    // T-131 PR-1: cursor-aware window + selected-row highlight.
592    // `visible_indices` is identity in PR-1 (every row visible); PR-2
593    // (filter+search) swaps its body — the rest of this render path
594    // is the abstraction's call site and does not need changing.
595    // #462: reuse the `rows` we already materialised above so the
596    // (non-trivial) Inbox merge runs once per frame, not twice.
597    let visible = app.mailbox.visible_indices_in(&rows, app.mailbox_tab);
598    if visible.is_empty() {
599        // PR-1: unreachable (rows non-empty implies visible non-empty),
600        // but stays here as the PR-2 empty-filter-result branch.
601        return;
602    }
603    let cap = area.height as usize;
604    let selected = app
605        .mailbox
606        .cursor(app.mailbox_tab)
607        .selected_idx
608        .min(visible.len() - 1);
609    // Tail-anchored: when selected sits in the last `cap` rows, anchor
610    // to the tail so the freshly-appended row stays visible — matches
611    // the pre-T-131 default. Otherwise keep selected near the bottom
612    // of the window (vim-like). On terminals taller than the row count
613    // the whole list fits and `start` is 0.
614    let start = if visible.len() <= cap {
615        0
616    } else if visible.len() - selected <= cap {
617        visible.len() - cap
618    } else {
619        selected.saturating_sub(cap.saturating_sub(1))
620    };
621    let end = (start + cap).min(visible.len());
622    let focused = app.focused_pane == Pane::Mailbox;
623    let highlight = Style::default().add_modifier(Modifier::REVERSED);
624    let muted = Style::default().fg(app.capabilities.muted());
625    // T-131 PR-4: per-row absolute-time indicator, right-aligned at
626    // the pane edge. Computed at render time from `app.now_secs` so
627    // values stay fresh across the 1s refresh tick without an
628    // explicit event AND so test snapshots are deterministic (the
629    // wall-clock read lives in the run loop, not here). Owner
630    // ratified the today-fold + 24h shape (tg 3388): same-day rows
631    // render `HH:MM` (5 chars), prior-day rows render `%b %d %H:%M`
632    // (12 chars). Reserve the worst case (12 cols) + 1 col gutter;
633    // truncate the left content to fit so the right-side indicator
634    // never wraps onto a new line.
635    let now_secs = app.now_secs;
636    const TIME_INDICATOR_WIDTH: usize = 12;
637    const TIME_INDICATOR_GUTTER: usize = 1;
638    let row_width = area.width as usize;
639    // T-231: pass the active tab so render_row can pick the right
640    // prefix (sender for Inbox/Channel/Wire, recipient for Sent).
641    let lines: Vec<Line<'_>> = visible[start..end]
642        .iter()
643        .map(|&row_idx| {
644            let row = &rows[row_idx];
645            let left = render_row(row, &app.team, app.mailbox_tab);
646            let rtime = crate::mailbox::row_timestamp(now_secs, row.sent_at);
647            // Right-pad the left content so the indicator sits at
648            // the pane edge. Truncate when the body would overflow
649            // the reserved indicator space (chars-not-bytes to keep
650            // multi-byte glyphs sane).
651            let reserved = TIME_INDICATOR_WIDTH + TIME_INDICATOR_GUTTER;
652            let left_chars = left.chars().count();
653            let max_left = row_width.saturating_sub(reserved);
654            let left_trimmed = if left_chars > max_left {
655                left.chars().take(max_left).collect::<String>()
656            } else {
657                left
658            };
659            let pad_n = max_left.saturating_sub(left_trimmed.chars().count());
660            let pad = " ".repeat(pad_n);
661            // Pad/truncate the indicator to exactly TIME_INDICATOR_WIDTH
662            // so right-alignment is stable across `now` / `2m` / `123d`.
663            let indicator = format!("{rtime:>width$}", width = TIME_INDICATOR_WIDTH);
664            let line = Line::from(vec![
665                ratatui::text::Span::raw(left_trimmed),
666                ratatui::text::Span::raw(pad),
667                ratatui::text::Span::raw(" ".repeat(TIME_INDICATOR_GUTTER)),
668                ratatui::text::Span::styled(indicator, muted),
669            ]);
670            if focused && row_idx == visible[selected] {
671                line.style(highlight)
672            } else {
673                line
674            }
675        })
676        .collect();
677    Paragraph::new(lines).render(area, buf);
678}
679
680fn pane_block<'a>(title: &'a str, focused: bool, app: &App) -> Block<'a> {
681    let border = if focused {
682        Style::default()
683            .fg(app.capabilities.accent())
684            .add_modifier(Modifier::BOLD)
685    } else {
686        Style::default().fg(app.capabilities.muted())
687    };
688    Block::default()
689        .title(title)
690        .borders(Borders::ALL)
691        .border_style(border)
692}