1use 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
24pub 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 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 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 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
138pub 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 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 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
266pub 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 let rows = vec![
323 row(1, "writing:editorial"), 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}