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