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, AgentInfo};
25use crate::mailbox::{render_row, 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    let lines: Vec<Line<'_>> = app
175        .team
176        .agents
177        .iter()
178        .enumerate()
179        .map(|(i, info)| agent_line(info, Some(i) == app.selected_agent, ascii, app))
180        .collect();
181    let para = Paragraph::new(lines).alignment(Alignment::Left);
182    para.render(inner, buf);
183}
184
185fn agent_line<'a>(info: &'a AgentInfo, selected: bool, ascii: bool, app: &App) -> Line<'a> {
186    let glyph = state_glyph(info, ascii);
187    // T-160: roster prefers the operator's display_name when set,
188    // falling back to the YAML key (`info.agent`) — the canonical id
189    // would over-prefix the project and is reserved for cross-project
190    // surfaces like the detail header and wall-tile title.
191    let label = info.display_name.as_deref().unwrap_or(&info.agent);
192    let display = format!(" {glyph}  {label}");
193    let style = if selected {
194        Style::default()
195            .fg(app.capabilities.accent())
196            .add_modifier(Modifier::REVERSED)
197    } else {
198        Style::default()
199    };
200    Line::styled(display, style)
201}
202
203fn render_detail(buf: &mut Buffer, area: Rect, app: &App) {
204    let focused_pane = app.focused_pane == Pane::Detail;
205    // T-108: when stream-mode is active, mark the border title with a
206    // bright `[STREAM-KEYS]` tag so the pane the operator's typing
207    // into is unambiguous even at a glance away from the statusline.
208    let stream = matches!(app.stage, crate::app::Stage::StreamKeys);
209    let title = match app
210        .selected_agent
211        .and_then(|i| app.team.agents.get(i))
212        .map(|a| crate::data::agent_label(&app.team, &a.id))
213    {
214        Some(label) if stream => format!("DETAIL · {label}  [STREAM-KEYS]"),
215        Some(label) => format!("DETAIL · {label}"),
216        None if stream => "DETAIL  [STREAM-KEYS]".to_string(),
217        None => "DETAIL".to_string(),
218    };
219    // While stream-mode is active, force the focus-ring style on the
220    // detail pane regardless of `focused_pane`. Pane focus and stream-
221    // mode are aligned in practice (entry is gated on detail focus),
222    // but a future refactor that lets focus drift mid-mode shouldn't
223    // visually downgrade the active stream pane.
224    let outer_block = pane_block(&title, focused_pane || stream, app);
225    let inner = outer_block.inner(area);
226    outer_block.render(area, buf);
227
228    if app.selected_agent.is_none() || app.team.agents.is_empty() {
229        let muted = Style::default().fg(app.capabilities.muted());
230        Paragraph::new("(select an agent on the left to follow its session)")
231            .style(muted)
232            .alignment(Alignment::Center)
233            .render(inner, buf);
234        return;
235    }
236
237    // PR-UI-7 fixup (qa Gap D): when `detail_splits` is non-empty
238    // the detail pane subdivides — primary cell shows the focused
239    // agent, additional cells show each split's agent. Operators
240    // see the actual visual effect of `Ctrl+|` / `Ctrl+-`.
241    if !app.detail_splits.is_empty() {
242        render_detail_splits(buf, inner, app);
243        return;
244    }
245
246    if app.detail_buffer.is_empty() {
247        let muted = Style::default().fg(app.capabilities.muted());
248        Paragraph::new("(no scrollback yet — agent may be starting up)")
249            .style(muted)
250            .alignment(Alignment::Center)
251            .render(inner, buf);
252        return;
253    }
254
255    // Tail the buffer to whatever fits; ratatui already clips lines
256    // that overrun the rect, but pre-trimming saves a render-time
257    // copy of thousands of lines we'd never see.
258    let cap = inner.height as usize;
259    let start = app.detail_buffer.len().saturating_sub(cap);
260    // T-074 bug 3: parse the ANSI escape sequences captured by
261    // `tmux capture-pane -e` into styled spans. `Line::raw` would
262    // render the escapes as literal `\x1b[...` garbage; `into_text`
263    // turns SGR codes (colours, bold, dim, …) into ratatui spans
264    // so the agent's terminal output renders coloured. Lines that
265    // contain no ANSI degrade gracefully to plain spans.
266    use ansi_to_tui::IntoText;
267    let lines: Vec<Line<'_>> = app.detail_buffer[start..]
268        .iter()
269        .flat_map(|s| match s.as_bytes().into_text() {
270            Ok(text) => text.lines.into_iter().collect::<Vec<_>>(),
271            Err(_) => vec![Line::raw(s.clone())],
272        })
273        .collect();
274    Paragraph::new(lines).render(inner, buf);
275}
276
277/// Subdivide the detail-pane area when `detail_splits` is
278/// non-empty. Composition (qa Gap D fixup):
279///
280/// - Cell 0 always shows the focused agent (the original detail
281///   stream); cells 1..=N show each split's agent in order.
282/// - The operator's mental model is "vertical adds a column,
283///   horizontal adds a row." We honour that by folding vertical
284///   splits into columns first, then horizontal splits subdivide
285///   each column. With all-vertical or all-horizontal splits the
286///   layout is straightforward; with a mix the columns grow
287///   left-to-right and the horizontal splits stack within their
288///   column.
289/// - Each cell renders the agent's id + state glyph in the title
290///   bar and the focused agent's `detail_buffer` lines as content.
291///   Non-focused splits show a `(focus this split to stream)`
292///   placeholder — multi-stream pane captures land in T-068
293///   alongside the per-tile Wall captures.
294/// - The focused split (per `app.selected_split`) gets the accent
295///   focus-ring border; others get the muted border.
296fn render_detail_splits(buf: &mut Buffer, area: Rect, app: &App) {
297    use ratatui::layout::Direction as Dir;
298
299    // Build the cell list: [focused, split_0, split_1, ...].
300    // Each cell carries (agent_id, orientation_hint, is_focused_split).
301    // `orientation_hint` for the focused agent defaults to Vertical
302    // so the first split's chord choice drives the layout.
303    let focused_id = app
304        .selected_agent_id()
305        .unwrap_or_else(|| "<no agent>".into());
306    let mut cells: Vec<(String, crate::app::SplitOrientation, bool)> = Vec::new();
307    cells.push((
308        focused_id,
309        // Match whatever the first split orientation is (or Vertical
310        // if no splits — the no-splits path is short-circuited
311        // above this fn's caller).
312        app.detail_splits
313            .first()
314            .map(|(_, o)| *o)
315            .unwrap_or(crate::app::SplitOrientation::Vertical),
316        app.selected_split == 0 && app.focused_pane == Pane::Detail,
317    ));
318    for (i, (id, orientation)) in app.detail_splits.iter().enumerate() {
319        cells.push((
320            id.clone(),
321            *orientation,
322            app.selected_split == i + 1 && app.focused_pane == Pane::Detail,
323        ));
324    }
325
326    // Group cells into columns: a Vertical split starts a new
327    // column; Horizontal splits stack within the current column.
328    let mut columns: Vec<Vec<usize>> = vec![vec![0]];
329    for (idx, (_, orientation, _)) in cells.iter().enumerate().skip(1) {
330        match orientation {
331            crate::app::SplitOrientation::Vertical => columns.push(vec![idx]),
332            crate::app::SplitOrientation::Horizontal => {
333                columns.last_mut().expect("seed column").push(idx);
334            }
335        }
336    }
337
338    let col_count = columns.len();
339    let col_constraints: Vec<Constraint> = (0..col_count)
340        .map(|_| Constraint::Ratio(1, col_count as u32))
341        .collect();
342    let col_areas = ratatui::layout::Layout::default()
343        .direction(Dir::Horizontal)
344        .constraints(col_constraints)
345        .split(area);
346
347    for (col_idx, col_cells) in columns.iter().enumerate() {
348        let col_area = col_areas[col_idx];
349        let row_count = col_cells.len();
350        let row_constraints: Vec<Constraint> = (0..row_count)
351            .map(|_| Constraint::Ratio(1, row_count as u32))
352            .collect();
353        let row_areas = ratatui::layout::Layout::default()
354            .direction(Dir::Vertical)
355            .constraints(row_constraints)
356            .split(col_area);
357        for (row_idx, &cell_idx) in col_cells.iter().enumerate() {
358            let cell_area = row_areas[row_idx];
359            let (agent_id, _, is_focused_split) = &cells[cell_idx];
360            render_split_cell(buf, cell_area, app, agent_id, *is_focused_split);
361        }
362    }
363}
364
365fn render_split_cell(
366    buf: &mut Buffer,
367    area: Rect,
368    app: &App,
369    agent_id: &str,
370    is_focused_split: bool,
371) {
372    let ascii = matches!(app.capabilities.color, ColorMode::Monochrome);
373    let glyph = app
374        .team
375        .agents
376        .iter()
377        .find(|a| a.id == agent_id)
378        .map(|info| crate::data::state_glyph(info, ascii))
379        .unwrap_or("?");
380    let label = crate::data::agent_label(&app.team, agent_id);
381    let title = format!(" {glyph} {label} ");
382    let border = if is_focused_split {
383        Style::default()
384            .fg(app.capabilities.accent())
385            .add_modifier(Modifier::BOLD)
386    } else {
387        Style::default().fg(app.capabilities.muted())
388    };
389    let block = Block::default()
390        .title(title)
391        .borders(Borders::ALL)
392        .border_style(border);
393    let inner = block.inner(area);
394    block.render(area, buf);
395
396    // Only the focused split streams the live detail buffer.
397    // Non-focused splits show the placeholder — multi-stream
398    // captures land in T-068 alongside Wall's per-tile streaming.
399    let muted = Style::default().fg(app.capabilities.muted());
400    if !is_focused_split {
401        Paragraph::new("(focus this split to stream)")
402            .style(muted)
403            .alignment(Alignment::Center)
404            .render(inner, buf);
405        return;
406    }
407    if app.detail_buffer.is_empty() {
408        Paragraph::new("(no scrollback yet)")
409            .style(muted)
410            .alignment(Alignment::Center)
411            .render(inner, buf);
412        return;
413    }
414    let cap = inner.height as usize;
415    let start = app.detail_buffer.len().saturating_sub(cap);
416    let lines: Vec<Line<'_>> = app.detail_buffer[start..]
417        .iter()
418        .map(|s| Line::raw(s.clone()))
419        .collect();
420    Paragraph::new(lines).render(inner, buf);
421}
422
423fn render_mailbox(buf: &mut Buffer, area: Rect, app: &App) {
424    let focused = app.focused_pane == Pane::Mailbox;
425    let block = pane_block("MAILBOX", focused, app);
426    let inner = block.inner(area);
427    block.render(area, buf);
428
429    if inner.height == 0 {
430        return;
431    }
432
433    // Reserve the top line for the tab indicator; everything below
434    // is rows from the active tab's buffer.
435    let layout = Layout::default()
436        .direction(Direction::Vertical)
437        .constraints([Constraint::Length(1), Constraint::Min(0)])
438        .split(inner);
439
440    render_mailbox_tabs(buf, layout[0], app);
441    render_mailbox_body(buf, layout[1], app);
442}
443
444fn render_mailbox_tabs(buf: &mut Buffer, area: Rect, app: &App) {
445    // `Inbox  Sent  Channel  Wire` — active tab gets the focus
446    // accent (REVERSED so it reads as a highlight bar even in
447    // monochrome terminals where colour alone wouldn't carry the
448    // signal).
449    let active_style = Style::default()
450        .fg(app.capabilities.accent())
451        .add_modifier(Modifier::REVERSED);
452    let muted = Style::default().fg(app.capabilities.muted());
453    let mut spans: Vec<ratatui::text::Span<'_>> = Vec::with_capacity(7);
454    for (i, tab) in MailboxTab::ALL.iter().enumerate() {
455        if i > 0 {
456            spans.push(ratatui::text::Span::styled("  ", muted));
457        }
458        let label = format!(" {} ", tab.label());
459        let style = if app.mailbox_tab == *tab {
460            active_style
461        } else {
462            muted
463        };
464        spans.push(ratatui::text::Span::styled(label, style));
465    }
466    Paragraph::new(Line::from(spans)).render(area, buf);
467}
468
469fn render_mailbox_body(buf: &mut Buffer, area: Rect, app: &App) {
470    if app.selected_agent_id().is_none() {
471        let muted = Style::default().fg(app.capabilities.muted());
472        Paragraph::new("(select an agent)")
473            .style(muted)
474            .alignment(Alignment::Center)
475            .render(area, buf);
476        return;
477    }
478
479    let rows = app.mailbox.rows(app.mailbox_tab);
480    if rows.is_empty() {
481        let muted = Style::default().fg(app.capabilities.muted());
482        Paragraph::new(app.mailbox_tab.empty_hint())
483            .style(muted)
484            .alignment(Alignment::Center)
485            .render(area, buf);
486        return;
487    }
488
489    // Tail to whatever fits — same shape as the detail pane.
490    let cap = area.height as usize;
491    let start = rows.len().saturating_sub(cap);
492    let lines: Vec<Line<'_>> = rows[start..]
493        .iter()
494        .map(|r| Line::raw(render_row(r, &app.team)))
495        .collect();
496    Paragraph::new(lines).render(area, buf);
497}
498
499fn pane_block<'a>(title: &'a str, focused: bool, app: &App) -> Block<'a> {
500    let border = if focused {
501        Style::default()
502            .fg(app.capabilities.accent())
503            .add_modifier(Modifier::BOLD)
504    } else {
505        Style::default().fg(app.capabilities.muted())
506    };
507    Block::default()
508        .title(title)
509        .borders(Borders::ALL)
510        .border_style(border)
511}