Skip to main content

teamctl_ui/
layouts.rs

1//! Alternate main-view layouts (PR-UI-6).
2//!
3//! - `Wall` — orchestrator overview. Up to 4 agent tiles in a 2×2
4//!   grid (or 1×N stack on narrow terminals). >4 agents scroll
5//!   the grid vertically per Alireza's v2-locked answer.
6//! - `MailboxFirst` — channel-list / feed / participants
7//!   horizontal split, for triaging mailbox traffic across the team
8//!   when the operator's focus is communication-first rather than
9//!   one-agent-deep.
10//!
11//! Both layouts share the same statusline below them; both are
12//! reachable from the Triptych layout via `Ctrl+W` / `Ctrl+M`.
13
14use ratatui::buffer::Buffer;
15use ratatui::layout::{Alignment, Constraint, Direction, Layout as RtLayout, Rect};
16use ratatui::style::{Modifier, Style};
17use ratatui::text::Line;
18use ratatui::widgets::{Block, Borders, Paragraph, Widget};
19
20use crate::app::App;
21use crate::data::{state_glyph, AgentInfo};
22use crate::theme::ColorMode;
23
24/// 4 visible tiles + vertical scroll for >4 agents — pin matches
25/// SPEC §3 / Alireza v2-locked answer.
26pub const WALL_TILE_CAP: usize = 4;
27
28pub struct Wall<'a> {
29    pub app: &'a App,
30}
31
32impl Widget for Wall<'_> {
33    fn render(self, area: Rect, buf: &mut Buffer) {
34        let agents = &self.app.team.agents;
35        if agents.is_empty() {
36            Paragraph::new("(no agents)")
37                .style(Style::default().fg(self.app.capabilities.muted()))
38                .alignment(Alignment::Center)
39                .render(area, buf);
40            return;
41        }
42
43        // Scroll window: starting from `wall_scroll`, take up to
44        // `WALL_TILE_CAP` agents. Operator scrolls with `J`/`K` /
45        // PageUp/PageDown via App::wall_scroll_*.
46        let start = self.app.wall_scroll.min(agents.len().saturating_sub(1));
47        let end = (start + WALL_TILE_CAP).min(agents.len());
48        let window: Vec<&AgentInfo> = agents[start..end].iter().collect();
49
50        // 2×2 grid: split the area into 2 rows, each row into 2
51        // cols. Narrow terminals (height < 12) collapse to a 1×N
52        // vertical stack so each tile keeps a readable footprint.
53        let stack_vertically = area.height < 12;
54        let ascii = matches!(self.app.capabilities.color, ColorMode::Monochrome);
55
56        if stack_vertically {
57            let rows = RtLayout::default()
58                .direction(Direction::Vertical)
59                .constraints(vec![
60                    Constraint::Ratio(1, window.len().max(1) as u32);
61                    window.len().max(1)
62                ])
63                .split(area);
64            for (i, info) in window.iter().enumerate() {
65                let selected = (start + i) == self.app.selected_agent.unwrap_or(usize::MAX);
66                render_tile(buf, rows[i], info, selected, ascii, self.app);
67            }
68            return;
69        }
70
71        let rows = RtLayout::default()
72            .direction(Direction::Vertical)
73            .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
74            .split(area);
75        for (row_idx, row_area) in rows.iter().enumerate() {
76            let cells = RtLayout::default()
77                .direction(Direction::Horizontal)
78                .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
79                .split(*row_area);
80            for (col_idx, cell_area) in cells.iter().enumerate() {
81                let tile_idx = row_idx * 2 + col_idx;
82                if tile_idx < window.len() {
83                    let info = window[tile_idx];
84                    let selected =
85                        (start + tile_idx) == self.app.selected_agent.unwrap_or(usize::MAX);
86                    render_tile(buf, *cell_area, info, selected, ascii, self.app);
87                }
88            }
89        }
90    }
91}
92
93fn render_tile(
94    buf: &mut Buffer,
95    area: Rect,
96    info: &AgentInfo,
97    selected: bool,
98    ascii: bool,
99    app: &App,
100) {
101    let glyph = state_glyph(info, ascii);
102    let label = crate::data::agent_label(&app.team, &info.id);
103    let title = format!(" {glyph} {label} ");
104    let border_style = if selected {
105        Style::default()
106            .fg(app.capabilities.accent())
107            .add_modifier(Modifier::BOLD)
108    } else {
109        Style::default().fg(app.capabilities.muted())
110    };
111    let block = Block::default()
112        .title(title)
113        .borders(Borders::ALL)
114        .border_style(border_style);
115    let inner = block.inner(area);
116    block.render(area, buf);
117
118    // Last 4 lines from the focused-agent's detail buffer when
119    // this tile is the focused agent; otherwise an empty hint
120    // (real per-tile pane captures are not in PR-UI-6 scope —
121    // SPEC explicitly defers to a future cycle).
122    let lines: Vec<Line<'_>> = if selected && !app.detail_buffer.is_empty() {
123        let cap = (inner.height as usize).min(4);
124        let start = app.detail_buffer.len().saturating_sub(cap);
125        app.detail_buffer[start..]
126            .iter()
127            .map(|s| Line::raw(s.clone()))
128            .collect()
129    } else {
130        vec![Line::styled(
131            "(focus this tile to stream)",
132            Style::default().fg(app.capabilities.muted()),
133        )]
134    };
135    Paragraph::new(lines).render(inner, buf);
136}
137
138/// `MailboxFirst` — channel-list (left, ~26 cols) / feed (middle,
139/// flex) / participants (right, ~24 cols).
140pub struct MailboxFirst<'a> {
141    pub app: &'a App,
142}
143
144impl Widget for MailboxFirst<'_> {
145    fn render(self, area: Rect, buf: &mut Buffer) {
146        let columns = RtLayout::default()
147            .direction(Direction::Horizontal)
148            .constraints([
149                Constraint::Length(26),
150                Constraint::Min(0),
151                Constraint::Length(24),
152            ])
153            .split(area);
154        render_channels_list(buf, columns[0], self.app);
155        render_channel_feed(buf, columns[1], self.app);
156        render_participants(buf, columns[2], self.app);
157    }
158}
159
160fn render_channels_list(buf: &mut Buffer, area: Rect, app: &App) {
161    let block = Block::default()
162        .title("CHANNELS")
163        .borders(Borders::ALL)
164        .border_style(Style::default().fg(app.capabilities.muted()));
165    let inner = block.inner(area);
166    block.render(area, buf);
167    if app.team.channels.is_empty() {
168        Paragraph::new("(no channels)")
169            .style(Style::default().fg(app.capabilities.muted()))
170            .alignment(Alignment::Center)
171            .render(inner, buf);
172        return;
173    }
174    let lines: Vec<Line<'_>> = app
175        .team
176        .channels
177        .iter()
178        .enumerate()
179        .map(|(i, ch)| {
180            let label = format!("  #{}", ch.name);
181            let style = if Some(i) == app.selected_channel {
182                Style::default()
183                    .fg(app.capabilities.accent())
184                    .add_modifier(Modifier::REVERSED)
185            } else {
186                Style::default()
187            };
188            Line::styled(label, style)
189        })
190        .collect();
191    Paragraph::new(lines).render(inner, buf);
192}
193
194fn render_channel_feed(buf: &mut Buffer, area: Rect, app: &App) {
195    let selected = app.selected_channel.and_then(|i| app.team.channels.get(i));
196    let title = match selected {
197        Some(ch) => format!("FEED · #{}", ch.name),
198        None => "FEED".into(),
199    };
200    let block = Block::default()
201        .title(title)
202        .borders(Borders::ALL)
203        .border_style(Style::default().fg(app.capabilities.muted()));
204    let inner = block.inner(area);
205    block.render(area, buf);
206    // PR-UI-6 fixup (Q3, dev2 review): the rolled-up
207    // `mailbox.channel` buffer carries every channel row the
208    // focused agent receives; filter to the selected channel so
209    // the title's `FEED · #editorial` reads truthfully. Rows
210    // whose `recipient` doesn't match `channel:<channel.id>` get
211    // dropped on the floor.
212    let all_rows = app.mailbox.rows(crate::mailbox::MailboxTab::Channel);
213    let filtered: Vec<&crate::mailbox::MessageRow> = match selected {
214        Some(ch) => filter_rows_for_channel(all_rows, &ch.id),
215        None => all_rows.iter().collect(),
216    };
217    if filtered.is_empty() {
218        Paragraph::new("(no channel traffic)")
219            .style(Style::default().fg(app.capabilities.muted()))
220            .alignment(Alignment::Center)
221            .render(inner, buf);
222        return;
223    }
224    let cap = inner.height as usize;
225    let start = filtered.len().saturating_sub(cap);
226    let lines: Vec<Line<'_>> = filtered[start..]
227        .iter()
228        .map(|r| Line::raw(crate::mailbox::render_row(r, &app.team)))
229        .collect();
230    Paragraph::new(lines).render(inner, buf);
231}
232
233fn render_participants(buf: &mut Buffer, area: Rect, app: &App) {
234    let block = Block::default()
235        .title("PARTICIPANTS")
236        .borders(Borders::ALL)
237        .border_style(Style::default().fg(app.capabilities.muted()));
238    let inner = block.inner(area);
239    block.render(area, buf);
240    // PR-UI-6 derives "participants" as every agent in the
241    // focused channel's project — a serviceable approximation of
242    // membership without a dedicated query. PR-UI-7's polish cycle
243    // can wire `channel_members` table proper.
244    let project = app
245        .selected_channel
246        .and_then(|i| app.team.channels.get(i))
247        .map(|c| c.project_id.clone());
248    let participants: Vec<&AgentInfo> = match project {
249        Some(p) => app.team.agents.iter().filter(|a| a.project == p).collect(),
250        None => Vec::new(),
251    };
252    if participants.is_empty() {
253        Paragraph::new("(none)")
254            .style(Style::default().fg(app.capabilities.muted()))
255            .alignment(Alignment::Center)
256            .render(inner, buf);
257        return;
258    }
259    let lines: Vec<Line<'_>> = participants
260        .iter()
261        .map(|info| Line::raw(format!("  {}", info.agent)))
262        .collect();
263    Paragraph::new(lines).render(inner, buf);
264}
265
266/// Drop every row whose `recipient` doesn't match
267/// `channel:<channel_id>`. Pulled out as a free function so unit
268/// tests can pin the contract without rendering — feed pane Q3
269/// fixup per dev2's PR-UI-6 review.
270pub fn filter_rows_for_channel<'a>(
271    rows: &'a [crate::mailbox::MessageRow],
272    channel_id: &str,
273) -> Vec<&'a crate::mailbox::MessageRow> {
274    let target = format!("channel:{channel_id}");
275    rows.iter().filter(|r| r.recipient == target).collect()
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use crate::mailbox::MessageRow;
282
283    fn row(id: i64, recipient: &str) -> MessageRow {
284        MessageRow {
285            id,
286            sender: "p:m".into(),
287            recipient: recipient.into(),
288            text: format!("body {id}"),
289            sent_at: 0.0,
290        }
291    }
292
293    #[test]
294    fn filter_keeps_only_matching_channel_rows() {
295        let rows = vec![
296            row(1, "channel:writing:editorial"),
297            row(2, "channel:writing:critique"),
298            row(3, "channel:writing:editorial"),
299            row(4, "channel:writing:all"),
300        ];
301        let kept = filter_rows_for_channel(&rows, "writing:editorial");
302        let ids: Vec<i64> = kept.iter().map(|r| r.id).collect();
303        assert_eq!(ids, vec![1, 3]);
304    }
305
306    #[test]
307    fn filter_returns_empty_when_no_rows_match() {
308        let rows = vec![
309            row(1, "channel:writing:critique"),
310            row(2, "channel:writing:all"),
311        ];
312        let kept = filter_rows_for_channel(&rows, "writing:editorial");
313        assert!(kept.is_empty());
314    }
315
316    #[test]
317    fn filter_does_not_match_dm_rows_with_same_id_suffix() {
318        // A DM to `<project>:<agent>` must never leak into a
319        // channel-feed view, even when the agent name happens to
320        // collide with a channel name. The `channel:` prefix in
321        // the target string keeps that disjoint.
322        let rows = vec![
323            row(1, "writing:editorial"), // looks like an agent id
324            row(2, "channel:writing:editorial"),
325        ];
326        let kept = filter_rows_for_channel(&rows, "writing:editorial");
327        assert_eq!(kept.len(), 1);
328        assert_eq!(kept[0].id, 2);
329    }
330}