1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum MainLayout {
32 Triptych,
33 Wall,
34 MailboxFirst,
35}
36
37impl MainLayout {
38 pub fn toggle_wall(self) -> Self {
41 if matches!(self, MainLayout::Wall) {
42 MainLayout::Triptych
43 } else {
44 MainLayout::Wall
45 }
46 }
47
48 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 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 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 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), Constraint::Min(0), Constraint::Length(32), ])
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 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 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 let cap = inner.height as usize;
229 let start = app.detail_buffer.len().saturating_sub(cap);
230 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
247fn render_detail_splits(buf: &mut Buffer, area: Rect, app: &App) {
267 use ratatui::layout::Direction as Dir;
268
269 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 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 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 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 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 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 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}