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 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 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
137pub 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 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 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
265pub 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 let rows = vec![
322 row(1, "writing:editorial"), 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}