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 title = format!(" {glyph} {} ", info.id);
103    let border_style = if selected {
104        Style::default()
105            .fg(app.capabilities.accent())
106            .add_modifier(Modifier::BOLD)
107    } else {
108        Style::default().fg(app.capabilities.muted())
109    };
110    let block = Block::default()
111        .title(title)
112        .borders(Borders::ALL)
113        .border_style(border_style);
114    let inner = block.inner(area);
115    block.render(area, buf);
116
117    // Last 4 lines from the focused-agent's detail buffer when
118    // this tile is the focused agent; otherwise an empty hint
119    // (real per-tile pane captures are not in PR-UI-6 scope —
120    // SPEC explicitly defers to a future cycle).
121    let lines: Vec<Line<'_>> = if selected && !app.detail_buffer.is_empty() {
122        let cap = (inner.height as usize).min(4);
123        let start = app.detail_buffer.len().saturating_sub(cap);
124        app.detail_buffer[start..]
125            .iter()
126            .map(|s| Line::raw(s.clone()))
127            .collect()
128    } else {
129        vec![Line::styled(
130            "(focus this tile to stream)",
131            Style::default().fg(app.capabilities.muted()),
132        )]
133    };
134    Paragraph::new(lines).render(inner, buf);
135}
136
137/// `MailboxFirst` — channel-list (left, ~26 cols) / feed (middle,
138/// flex) / participants (right, ~24 cols).
139pub struct MailboxFirst<'a> {
140    pub app: &'a App,
141}
142
143impl Widget for MailboxFirst<'_> {
144    fn render(self, area: Rect, buf: &mut Buffer) {
145        let columns = RtLayout::default()
146            .direction(Direction::Horizontal)
147            .constraints([
148                Constraint::Length(26),
149                Constraint::Min(0),
150                Constraint::Length(24),
151            ])
152            .split(area);
153        render_channels_list(buf, columns[0], self.app);
154        render_channel_feed(buf, columns[1], self.app);
155        render_participants(buf, columns[2], self.app);
156    }
157}
158
159fn render_channels_list(buf: &mut Buffer, area: Rect, app: &App) {
160    let block = Block::default()
161        .title("CHANNELS")
162        .borders(Borders::ALL)
163        .border_style(Style::default().fg(app.capabilities.muted()));
164    let inner = block.inner(area);
165    block.render(area, buf);
166    if app.team.channels.is_empty() {
167        Paragraph::new("(no channels)")
168            .style(Style::default().fg(app.capabilities.muted()))
169            .alignment(Alignment::Center)
170            .render(inner, buf);
171        return;
172    }
173    let lines: Vec<Line<'_>> = app
174        .team
175        .channels
176        .iter()
177        .enumerate()
178        .map(|(i, ch)| {
179            let label = format!("  #{}", ch.name);
180            let style = if Some(i) == app.selected_channel {
181                Style::default()
182                    .fg(app.capabilities.accent())
183                    .add_modifier(Modifier::REVERSED)
184            } else {
185                Style::default()
186            };
187            Line::styled(label, style)
188        })
189        .collect();
190    Paragraph::new(lines).render(inner, buf);
191}
192
193fn render_channel_feed(buf: &mut Buffer, area: Rect, app: &App) {
194    let selected = app.selected_channel.and_then(|i| app.team.channels.get(i));
195    let title = match selected {
196        Some(ch) => format!("FEED · #{}", ch.name),
197        None => "FEED".into(),
198    };
199    let block = Block::default()
200        .title(title)
201        .borders(Borders::ALL)
202        .border_style(Style::default().fg(app.capabilities.muted()));
203    let inner = block.inner(area);
204    block.render(area, buf);
205    // PR-UI-6 fixup (Q3, dev2 review): the rolled-up
206    // `mailbox.channel` buffer carries every channel row the
207    // focused agent receives; filter to the selected channel so
208    // the title's `FEED · #editorial` reads truthfully. Rows
209    // whose `recipient` doesn't match `channel:<channel.id>` get
210    // dropped on the floor.
211    let all_rows = app.mailbox.rows(crate::mailbox::MailboxTab::Channel);
212    let filtered: Vec<&crate::mailbox::MessageRow> = match selected {
213        Some(ch) => filter_rows_for_channel(all_rows, &ch.id),
214        None => all_rows.iter().collect(),
215    };
216    if filtered.is_empty() {
217        Paragraph::new("(no channel traffic)")
218            .style(Style::default().fg(app.capabilities.muted()))
219            .alignment(Alignment::Center)
220            .render(inner, buf);
221        return;
222    }
223    let cap = inner.height as usize;
224    let start = filtered.len().saturating_sub(cap);
225    let lines: Vec<Line<'_>> = filtered[start..]
226        .iter()
227        .map(|r| Line::raw(crate::mailbox::render_row(r)))
228        .collect();
229    Paragraph::new(lines).render(inner, buf);
230}
231
232fn render_participants(buf: &mut Buffer, area: Rect, app: &App) {
233    let block = Block::default()
234        .title("PARTICIPANTS")
235        .borders(Borders::ALL)
236        .border_style(Style::default().fg(app.capabilities.muted()));
237    let inner = block.inner(area);
238    block.render(area, buf);
239    // PR-UI-6 derives "participants" as every agent in the
240    // focused channel's project — a serviceable approximation of
241    // membership without a dedicated query. PR-UI-7's polish cycle
242    // can wire `channel_members` table proper.
243    let project = app
244        .selected_channel
245        .and_then(|i| app.team.channels.get(i))
246        .map(|c| c.project_id.clone());
247    let participants: Vec<&AgentInfo> = match project {
248        Some(p) => app.team.agents.iter().filter(|a| a.project == p).collect(),
249        None => Vec::new(),
250    };
251    if participants.is_empty() {
252        Paragraph::new("(none)")
253            .style(Style::default().fg(app.capabilities.muted()))
254            .alignment(Alignment::Center)
255            .render(inner, buf);
256        return;
257    }
258    let lines: Vec<Line<'_>> = participants
259        .iter()
260        .map(|info| Line::raw(format!("  {}", info.agent)))
261        .collect();
262    Paragraph::new(lines).render(inner, buf);
263}
264
265/// Drop every row whose `recipient` doesn't match
266/// `channel:<channel_id>`. Pulled out as a free function so unit
267/// tests can pin the contract without rendering — feed pane Q3
268/// fixup per dev2's PR-UI-6 review.
269pub fn filter_rows_for_channel<'a>(
270    rows: &'a [crate::mailbox::MessageRow],
271    channel_id: &str,
272) -> Vec<&'a crate::mailbox::MessageRow> {
273    let target = format!("channel:{channel_id}");
274    rows.iter().filter(|r| r.recipient == target).collect()
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280    use crate::mailbox::MessageRow;
281
282    fn row(id: i64, recipient: &str) -> MessageRow {
283        MessageRow {
284            id,
285            sender: "p:m".into(),
286            recipient: recipient.into(),
287            text: format!("body {id}"),
288            sent_at: 0.0,
289        }
290    }
291
292    #[test]
293    fn filter_keeps_only_matching_channel_rows() {
294        let rows = vec![
295            row(1, "channel:writing:editorial"),
296            row(2, "channel:writing:critique"),
297            row(3, "channel:writing:editorial"),
298            row(4, "channel:writing:all"),
299        ];
300        let kept = filter_rows_for_channel(&rows, "writing:editorial");
301        let ids: Vec<i64> = kept.iter().map(|r| r.id).collect();
302        assert_eq!(ids, vec![1, 3]);
303    }
304
305    #[test]
306    fn filter_returns_empty_when_no_rows_match() {
307        let rows = vec![
308            row(1, "channel:writing:critique"),
309            row(2, "channel:writing:all"),
310        ];
311        let kept = filter_rows_for_channel(&rows, "writing:editorial");
312        assert!(kept.is_empty());
313    }
314
315    #[test]
316    fn filter_does_not_match_dm_rows_with_same_id_suffix() {
317        // A DM to `<project>:<agent>` must never leak into a
318        // channel-feed view, even when the agent name happens to
319        // collide with a channel name. The `channel:` prefix in
320        // the target string keeps that disjoint.
321        let rows = vec![
322            row(1, "writing:editorial"), // looks like an agent id
323            row(2, "channel:writing:editorial"),
324        ];
325        let kept = filter_rows_for_channel(&rows, "writing:editorial");
326        assert_eq!(kept.len(), 1);
327        assert_eq!(kept[0].id, 2);
328    }
329}