Skip to main content

teamctl_ui/
triptych.rs

1//! Triptych — the default Layout A. Three resizable panes (roster,
2//! detail, mailbox) with an Approvals stripe reserved at the top
3//! (rendered only when there's something to surface — empty in
4//! PR-UI-2 still) and a focus ring on the active pane.
5//!
6//! PR-UI-2 wires the roster + detail panes to live data:
7//! - Roster lists `app.team.agents` with single-cell state glyphs
8//!   driven by `data::state_glyph`. Selection is highlighted with
9//!   the focus accent.
10//! - Detail shows the last-N lines of `app.detail_buffer` (the
11//!   tmux capture-pane scrollback for the focused agent), or an
12//!   empty-state hint when no agent is selected.
13//! - Mailbox stays empty-state — wiring lands in PR-UI-3.
14
15use ratatui::buffer::Buffer;
16use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
17use ratatui::style::{Modifier, Style};
18use ratatui::text::Line;
19use ratatui::widgets::{Block, Borders, Paragraph, Widget};
20
21use crate::app::App;
22use crate::data::{state_glyph, AgentInfo};
23use crate::mailbox::{render_row, MailboxTab};
24use crate::theme::ColorMode;
25
26/// Top-level layout selector for the main view (Stage::Triptych).
27/// PR-UI-1..5 used the Triptych shape exclusively; PR-UI-6 adds
28/// Wall (orchestrator overview, up to 4 tiles + scroll) and
29/// MailboxFirst (channel-feed centric for cross-team triage).
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum MainLayout {
32    Triptych,
33    Wall,
34    MailboxFirst,
35}
36
37impl MainLayout {
38    /// `Ctrl+W` (or standalone `w` from the SPEC chord map)
39    /// toggles between Triptych ↔ Wall.
40    pub fn toggle_wall(self) -> Self {
41        if matches!(self, MainLayout::Wall) {
42            MainLayout::Triptych
43        } else {
44            MainLayout::Wall
45        }
46    }
47
48    /// `Ctrl+M` toggles between Triptych ↔ MailboxFirst.
49    pub fn toggle_mailbox_first(self) -> Self {
50        if matches!(self, MainLayout::MailboxFirst) {
51            MainLayout::Triptych
52        } else {
53            MainLayout::MailboxFirst
54        }
55    }
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum Pane {
60    Roster,
61    Detail,
62    Mailbox,
63}
64
65impl Pane {
66    /// `Tab` cycles in roster → detail → mailbox → roster order.
67    pub fn next(self) -> Self {
68        match self {
69            Pane::Roster => Pane::Detail,
70            Pane::Detail => Pane::Mailbox,
71            Pane::Mailbox => Pane::Roster,
72        }
73    }
74
75    /// `Shift+Tab` cycles backward — roster → mailbox → detail →
76    /// roster. Closes the no-easy-exit-from-mailbox UX gap PR-UI-3
77    /// surfaced: operator Tabs into mailbox, then with Shift+Tab
78    /// they back out cleanly without the `q`-confirm round-trip.
79    pub fn prev(self) -> Self {
80        match self {
81            Pane::Roster => Pane::Mailbox,
82            Pane::Detail => Pane::Roster,
83            Pane::Mailbox => Pane::Detail,
84        }
85    }
86}
87
88pub fn draw(f: &mut ratatui::Frame<'_>, area: Rect, app: &App) {
89    Triptych { app }.render(area, f.buffer_mut());
90}
91
92pub struct Triptych<'a> {
93    pub app: &'a App,
94}
95
96impl Widget for Triptych<'_> {
97    fn render(self, area: Rect, buf: &mut Buffer) {
98        // PR-UI-4: the approvals stripe takes one line at the top
99        // when there's at least one pending approval. The
100        // `stripe_visible` const PR-UI-1 scaffolded as `false` is
101        // now `app.has_pending_approvals()`.
102        let stripe_visible = self.app.has_pending_approvals();
103        let body = if stripe_visible {
104            let v = Layout::default()
105                .direction(Direction::Vertical)
106                .constraints([Constraint::Length(1), Constraint::Min(0)])
107                .split(area);
108            render_approvals_stripe(buf, v[0], self.app);
109            v[1]
110        } else {
111            area
112        };
113
114        let columns = Layout::default()
115            .direction(Direction::Horizontal)
116            .constraints([
117                Constraint::Length(28), // roster
118                Constraint::Min(0),     // detail
119                Constraint::Length(32), // mailbox
120            ])
121            .split(body);
122
123        render_roster(buf, columns[0], self.app);
124        render_detail(buf, columns[1], self.app);
125        render_mailbox(buf, columns[2], self.app);
126    }
127}
128
129fn render_approvals_stripe(buf: &mut Buffer, area: Rect, app: &App) {
130    let n = app.pending_approvals.len();
131    let plural = if n == 1 { "" } else { "s" };
132    let text = format!("⚠  approvals: {n} pending{plural} — `a` to review");
133    // Bright accent + reversed for the stripe — same affordance
134    // pattern as the focused-pane border, applied to a full row so
135    // the warning reads in any colour mode.
136    let style = Style::default()
137        .fg(app.capabilities.accent())
138        .add_modifier(Modifier::REVERSED | Modifier::BOLD);
139    Paragraph::new(text)
140        .style(style)
141        .alignment(Alignment::Left)
142        .render(area, buf);
143}
144
145fn render_roster(buf: &mut Buffer, area: Rect, app: &App) {
146    let focused = app.focused_pane == Pane::Roster;
147    let block = pane_block("ROSTER", focused, app);
148    let inner = block.inner(area);
149    block.render(area, buf);
150
151    if app.team.agents.is_empty() {
152        let empty = Paragraph::new("(no agents)")
153            .style(Style::default().fg(app.capabilities.muted()))
154            .alignment(Alignment::Center);
155        empty.render(inner, buf);
156        return;
157    }
158
159    let ascii = matches!(app.capabilities.color, ColorMode::Monochrome);
160    let lines: Vec<Line<'_>> = app
161        .team
162        .agents
163        .iter()
164        .enumerate()
165        .map(|(i, info)| roster_line(info, Some(i) == app.selected_agent, ascii, app))
166        .collect();
167    let para = Paragraph::new(lines).alignment(Alignment::Left);
168    para.render(inner, buf);
169}
170
171fn roster_line<'a>(info: &'a AgentInfo, selected: bool, ascii: bool, app: &App) -> Line<'a> {
172    let glyph = state_glyph(info, ascii);
173    let display = format!(" {glyph}  {}", info.agent);
174    let style = if selected {
175        Style::default()
176            .fg(app.capabilities.accent())
177            .add_modifier(Modifier::REVERSED)
178    } else {
179        Style::default()
180    };
181    Line::styled(display, style)
182}
183
184fn render_detail(buf: &mut Buffer, area: Rect, app: &App) {
185    let focused_pane = app.focused_pane == Pane::Detail;
186    let title = match app
187        .selected_agent
188        .and_then(|i| app.team.agents.get(i))
189        .map(|a| a.id.as_str())
190    {
191        Some(id) => format!("DETAIL · {id}"),
192        None => "DETAIL".to_string(),
193    };
194    let outer_block = pane_block(&title, focused_pane, app);
195    let inner = outer_block.inner(area);
196    outer_block.render(area, buf);
197
198    if app.selected_agent.is_none() || app.team.agents.is_empty() {
199        let muted = Style::default().fg(app.capabilities.muted());
200        Paragraph::new("(select an agent on the left to follow its session)")
201            .style(muted)
202            .alignment(Alignment::Center)
203            .render(inner, buf);
204        return;
205    }
206
207    // PR-UI-7 fixup (qa Gap D): when `detail_splits` is non-empty
208    // the detail pane subdivides — primary cell shows the focused
209    // agent, additional cells show each split's agent. Operators
210    // see the actual visual effect of `Ctrl+|` / `Ctrl+-`.
211    if !app.detail_splits.is_empty() {
212        render_detail_splits(buf, inner, app);
213        return;
214    }
215
216    if app.detail_buffer.is_empty() {
217        let muted = Style::default().fg(app.capabilities.muted());
218        Paragraph::new("(no scrollback yet — agent may be starting up)")
219            .style(muted)
220            .alignment(Alignment::Center)
221            .render(inner, buf);
222        return;
223    }
224
225    // Tail the buffer to whatever fits; ratatui already clips lines
226    // that overrun the rect, but pre-trimming saves a render-time
227    // copy of thousands of lines we'd never see.
228    let cap = inner.height as usize;
229    let start = app.detail_buffer.len().saturating_sub(cap);
230    // T-074 bug 3: parse the ANSI escape sequences captured by
231    // `tmux capture-pane -e` into styled spans. `Line::raw` would
232    // render the escapes as literal `\x1b[...` garbage; `into_text`
233    // turns SGR codes (colours, bold, dim, …) into ratatui spans
234    // so the agent's terminal output renders coloured. Lines that
235    // contain no ANSI degrade gracefully to plain spans.
236    use ansi_to_tui::IntoText;
237    let lines: Vec<Line<'_>> = app.detail_buffer[start..]
238        .iter()
239        .flat_map(|s| match s.as_bytes().into_text() {
240            Ok(text) => text.lines.into_iter().collect::<Vec<_>>(),
241            Err(_) => vec![Line::raw(s.clone())],
242        })
243        .collect();
244    Paragraph::new(lines).render(inner, buf);
245}
246
247/// Subdivide the detail-pane area when `detail_splits` is
248/// non-empty. Composition (qa Gap D fixup):
249///
250/// - Cell 0 always shows the focused agent (the original detail
251///   stream); cells 1..=N show each split's agent in order.
252/// - The operator's mental model is "vertical adds a column,
253///   horizontal adds a row." We honour that by folding vertical
254///   splits into columns first, then horizontal splits subdivide
255///   each column. With all-vertical or all-horizontal splits the
256///   layout is straightforward; with a mix the columns grow
257///   left-to-right and the horizontal splits stack within their
258///   column.
259/// - Each cell renders the agent's id + state glyph in the title
260///   bar and the focused agent's `detail_buffer` lines as content.
261///   Non-focused splits show a `(focus this split to stream)`
262///   placeholder — multi-stream pane captures land in T-068
263///   alongside the per-tile Wall captures.
264/// - The focused split (per `app.selected_split`) gets the accent
265///   focus-ring border; others get the muted border.
266fn render_detail_splits(buf: &mut Buffer, area: Rect, app: &App) {
267    use ratatui::layout::Direction as Dir;
268
269    // Build the cell list: [focused, split_0, split_1, ...].
270    // Each cell carries (agent_id, orientation_hint, is_focused_split).
271    // `orientation_hint` for the focused agent defaults to Vertical
272    // so the first split's chord choice drives the layout.
273    let focused_id = app
274        .selected_agent_id()
275        .unwrap_or_else(|| "<no agent>".into());
276    let mut cells: Vec<(String, crate::app::SplitOrientation, bool)> = Vec::new();
277    cells.push((
278        focused_id,
279        // Match whatever the first split orientation is (or Vertical
280        // if no splits — the no-splits path is short-circuited
281        // above this fn's caller).
282        app.detail_splits
283            .first()
284            .map(|(_, o)| *o)
285            .unwrap_or(crate::app::SplitOrientation::Vertical),
286        app.selected_split == 0 && app.focused_pane == Pane::Detail,
287    ));
288    for (i, (id, orientation)) in app.detail_splits.iter().enumerate() {
289        cells.push((
290            id.clone(),
291            *orientation,
292            app.selected_split == i + 1 && app.focused_pane == Pane::Detail,
293        ));
294    }
295
296    // Group cells into columns: a Vertical split starts a new
297    // column; Horizontal splits stack within the current column.
298    let mut columns: Vec<Vec<usize>> = vec![vec![0]];
299    for (idx, (_, orientation, _)) in cells.iter().enumerate().skip(1) {
300        match orientation {
301            crate::app::SplitOrientation::Vertical => columns.push(vec![idx]),
302            crate::app::SplitOrientation::Horizontal => {
303                columns.last_mut().expect("seed column").push(idx);
304            }
305        }
306    }
307
308    let col_count = columns.len();
309    let col_constraints: Vec<Constraint> = (0..col_count)
310        .map(|_| Constraint::Ratio(1, col_count as u32))
311        .collect();
312    let col_areas = ratatui::layout::Layout::default()
313        .direction(Dir::Horizontal)
314        .constraints(col_constraints)
315        .split(area);
316
317    for (col_idx, col_cells) in columns.iter().enumerate() {
318        let col_area = col_areas[col_idx];
319        let row_count = col_cells.len();
320        let row_constraints: Vec<Constraint> = (0..row_count)
321            .map(|_| Constraint::Ratio(1, row_count as u32))
322            .collect();
323        let row_areas = ratatui::layout::Layout::default()
324            .direction(Dir::Vertical)
325            .constraints(row_constraints)
326            .split(col_area);
327        for (row_idx, &cell_idx) in col_cells.iter().enumerate() {
328            let cell_area = row_areas[row_idx];
329            let (agent_id, _, is_focused_split) = &cells[cell_idx];
330            render_split_cell(buf, cell_area, app, agent_id, *is_focused_split);
331        }
332    }
333}
334
335fn render_split_cell(
336    buf: &mut Buffer,
337    area: Rect,
338    app: &App,
339    agent_id: &str,
340    is_focused_split: bool,
341) {
342    let ascii = matches!(app.capabilities.color, ColorMode::Monochrome);
343    let glyph = app
344        .team
345        .agents
346        .iter()
347        .find(|a| a.id == agent_id)
348        .map(|info| crate::data::state_glyph(info, ascii))
349        .unwrap_or("?");
350    let title = format!(" {glyph} {agent_id} ");
351    let border = if is_focused_split {
352        Style::default()
353            .fg(app.capabilities.accent())
354            .add_modifier(Modifier::BOLD)
355    } else {
356        Style::default().fg(app.capabilities.muted())
357    };
358    let block = Block::default()
359        .title(title)
360        .borders(Borders::ALL)
361        .border_style(border);
362    let inner = block.inner(area);
363    block.render(area, buf);
364
365    // Only the focused split streams the live detail buffer.
366    // Non-focused splits show the placeholder — multi-stream
367    // captures land in T-068 alongside Wall's per-tile streaming.
368    let muted = Style::default().fg(app.capabilities.muted());
369    if !is_focused_split {
370        Paragraph::new("(focus this split to stream)")
371            .style(muted)
372            .alignment(Alignment::Center)
373            .render(inner, buf);
374        return;
375    }
376    if app.detail_buffer.is_empty() {
377        Paragraph::new("(no scrollback yet)")
378            .style(muted)
379            .alignment(Alignment::Center)
380            .render(inner, buf);
381        return;
382    }
383    let cap = inner.height as usize;
384    let start = app.detail_buffer.len().saturating_sub(cap);
385    let lines: Vec<Line<'_>> = app.detail_buffer[start..]
386        .iter()
387        .map(|s| Line::raw(s.clone()))
388        .collect();
389    Paragraph::new(lines).render(inner, buf);
390}
391
392fn render_mailbox(buf: &mut Buffer, area: Rect, app: &App) {
393    let focused = app.focused_pane == Pane::Mailbox;
394    let block = pane_block("MAILBOX", focused, app);
395    let inner = block.inner(area);
396    block.render(area, buf);
397
398    if inner.height == 0 {
399        return;
400    }
401
402    // Reserve the top line for the tab indicator; everything below
403    // is rows from the active tab's buffer.
404    let layout = Layout::default()
405        .direction(Direction::Vertical)
406        .constraints([Constraint::Length(1), Constraint::Min(0)])
407        .split(inner);
408
409    render_mailbox_tabs(buf, layout[0], app);
410    render_mailbox_body(buf, layout[1], app);
411}
412
413fn render_mailbox_tabs(buf: &mut Buffer, area: Rect, app: &App) {
414    // `Inbox  Channel  Wire` — active tab gets the focus accent
415    // (REVERSED so it reads as a highlight bar even in monochrome
416    // terminals where colour alone wouldn't carry the signal).
417    let active_style = Style::default()
418        .fg(app.capabilities.accent())
419        .add_modifier(Modifier::REVERSED);
420    let muted = Style::default().fg(app.capabilities.muted());
421    let mut spans: Vec<ratatui::text::Span<'_>> = Vec::with_capacity(7);
422    for (i, tab) in MailboxTab::ALL.iter().enumerate() {
423        if i > 0 {
424            spans.push(ratatui::text::Span::styled("  ", muted));
425        }
426        let label = format!(" {} ", tab.label());
427        let style = if app.mailbox_tab == *tab {
428            active_style
429        } else {
430            muted
431        };
432        spans.push(ratatui::text::Span::styled(label, style));
433    }
434    Paragraph::new(Line::from(spans)).render(area, buf);
435}
436
437fn render_mailbox_body(buf: &mut Buffer, area: Rect, app: &App) {
438    if app.selected_agent_id().is_none() {
439        let muted = Style::default().fg(app.capabilities.muted());
440        Paragraph::new("(select an agent)")
441            .style(muted)
442            .alignment(Alignment::Center)
443            .render(area, buf);
444        return;
445    }
446
447    let rows = app.mailbox.rows(app.mailbox_tab);
448    if rows.is_empty() {
449        let muted = Style::default().fg(app.capabilities.muted());
450        Paragraph::new(app.mailbox_tab.empty_hint())
451            .style(muted)
452            .alignment(Alignment::Center)
453            .render(area, buf);
454        return;
455    }
456
457    // Tail to whatever fits — same shape as the detail pane.
458    let cap = area.height as usize;
459    let start = rows.len().saturating_sub(cap);
460    let lines: Vec<Line<'_>> = rows[start..]
461        .iter()
462        .map(|r| Line::raw(render_row(r)))
463        .collect();
464    Paragraph::new(lines).render(area, buf);
465}
466
467fn pane_block<'a>(title: &'a str, focused: bool, app: &App) -> Block<'a> {
468    let border = if focused {
469        Style::default()
470            .fg(app.capabilities.accent())
471            .add_modifier(Modifier::BOLD)
472    } else {
473        Style::default().fg(app.capabilities.muted())
474    };
475    Block::default()
476        .title(title)
477        .borders(Borders::ALL)
478        .border_style(border)
479}