1use ratatui::buffer::Buffer;
18use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
19use ratatui::style::{Modifier, Style};
20use ratatui::text::Line;
21use ratatui::widgets::{Block, Borders, Paragraph, Widget};
22
23use crate::app::App;
24use crate::data::{state_glyph, AgentInfo};
25use crate::mailbox::{render_row, MailboxTab};
26use crate::theme::ColorMode;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum MainLayout {
34 Triptych,
35 Wall,
36 MailboxFirst,
37}
38
39impl MainLayout {
40 pub fn toggle_wall(self) -> Self {
43 if matches!(self, MainLayout::Wall) {
44 MainLayout::Triptych
45 } else {
46 MainLayout::Wall
47 }
48 }
49
50 pub fn toggle_mailbox_first(self) -> Self {
52 if matches!(self, MainLayout::MailboxFirst) {
53 MainLayout::Triptych
54 } else {
55 MainLayout::MailboxFirst
56 }
57 }
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum Pane {
62 Roster,
63 Detail,
64 Mailbox,
65}
66
67impl Pane {
68 pub fn next(self) -> Self {
72 match self {
73 Pane::Roster => Pane::Detail,
74 Pane::Detail => Pane::Mailbox,
75 Pane::Mailbox => Pane::Roster,
76 }
77 }
78
79 pub fn prev(self) -> Self {
84 match self {
85 Pane::Roster => Pane::Mailbox,
86 Pane::Detail => Pane::Roster,
87 Pane::Mailbox => Pane::Detail,
88 }
89 }
90}
91
92pub fn draw(f: &mut ratatui::Frame<'_>, area: Rect, app: &App) {
93 Triptych { app }.render(area, f.buffer_mut());
94}
95
96pub struct Triptych<'a> {
97 pub app: &'a App,
98}
99
100impl Widget for Triptych<'_> {
101 fn render(self, area: Rect, buf: &mut Buffer) {
102 let stripe_visible = self.app.has_pending_approvals();
107 let body = if stripe_visible {
108 let v = Layout::default()
109 .direction(Direction::Vertical)
110 .constraints([Constraint::Length(1), Constraint::Min(0)])
111 .split(area);
112 render_approvals_stripe(buf, v[0], self.app);
113 v[1]
114 } else {
115 area
116 };
117
118 let outer = Layout::default()
122 .direction(Direction::Horizontal)
123 .constraints([
124 Constraint::Length(28), Constraint::Min(0), ])
127 .split(body);
128
129 let right_stack = Layout::default()
133 .direction(Direction::Vertical)
134 .constraints([Constraint::Ratio(3, 5), Constraint::Ratio(2, 5)])
135 .split(outer[1]);
136
137 render_agents(buf, outer[0], self.app);
138 render_detail(buf, right_stack[0], self.app);
139 render_mailbox(buf, right_stack[1], self.app);
140 }
141}
142
143fn render_approvals_stripe(buf: &mut Buffer, area: Rect, app: &App) {
144 let n = app.pending_approvals.len();
145 let plural = if n == 1 { "" } else { "s" };
146 let text = format!("⚠ approvals: {n} pending{plural} — `a` to review");
147 let style = Style::default()
151 .fg(app.capabilities.accent())
152 .add_modifier(Modifier::REVERSED | Modifier::BOLD);
153 Paragraph::new(text)
154 .style(style)
155 .alignment(Alignment::Left)
156 .render(area, buf);
157}
158
159fn render_agents(buf: &mut Buffer, area: Rect, app: &App) {
160 let focused = app.focused_pane == Pane::Roster;
161 let block = pane_block("AGENTS", focused, app);
162 let inner = block.inner(area);
163 block.render(area, buf);
164
165 if app.team.agents.is_empty() {
166 let empty = Paragraph::new("(no agents)")
167 .style(Style::default().fg(app.capabilities.muted()))
168 .alignment(Alignment::Center);
169 empty.render(inner, buf);
170 return;
171 }
172
173 let ascii = matches!(app.capabilities.color, ColorMode::Monochrome);
174 let lines: Vec<Line<'_>> = app
175 .team
176 .agents
177 .iter()
178 .enumerate()
179 .map(|(i, info)| agent_line(info, Some(i) == app.selected_agent, ascii, app))
180 .collect();
181 let para = Paragraph::new(lines).alignment(Alignment::Left);
182 para.render(inner, buf);
183}
184
185fn agent_line<'a>(info: &'a AgentInfo, selected: bool, ascii: bool, app: &App) -> Line<'a> {
186 let glyph = state_glyph(info, ascii);
187 let label = info.display_name.as_deref().unwrap_or(&info.agent);
192 let display = format!(" {glyph} {label}");
193 let style = if selected {
194 Style::default()
195 .fg(app.capabilities.accent())
196 .add_modifier(Modifier::REVERSED)
197 } else {
198 Style::default()
199 };
200 Line::styled(display, style)
201}
202
203fn render_detail(buf: &mut Buffer, area: Rect, app: &App) {
204 let focused_pane = app.focused_pane == Pane::Detail;
205 let stream = matches!(app.stage, crate::app::Stage::StreamKeys);
209 let title = match app
210 .selected_agent
211 .and_then(|i| app.team.agents.get(i))
212 .map(|a| crate::data::agent_label(&app.team, &a.id))
213 {
214 Some(label) if stream => format!("DETAIL · {label} [STREAM-KEYS]"),
215 Some(label) => format!("DETAIL · {label}"),
216 None if stream => "DETAIL [STREAM-KEYS]".to_string(),
217 None => "DETAIL".to_string(),
218 };
219 let outer_block = pane_block(&title, focused_pane || stream, app);
225 let inner = outer_block.inner(area);
226 outer_block.render(area, buf);
227
228 if app.selected_agent.is_none() || app.team.agents.is_empty() {
229 let muted = Style::default().fg(app.capabilities.muted());
230 Paragraph::new("(select an agent on the left to follow its session)")
231 .style(muted)
232 .alignment(Alignment::Center)
233 .render(inner, buf);
234 return;
235 }
236
237 if !app.detail_splits.is_empty() {
242 render_detail_splits(buf, inner, app);
243 return;
244 }
245
246 if app.detail_buffer.is_empty() {
247 let muted = Style::default().fg(app.capabilities.muted());
248 Paragraph::new("(no scrollback yet — agent may be starting up)")
249 .style(muted)
250 .alignment(Alignment::Center)
251 .render(inner, buf);
252 return;
253 }
254
255 let cap = inner.height as usize;
259 let start = app.detail_buffer.len().saturating_sub(cap);
260 use ansi_to_tui::IntoText;
267 let lines: Vec<Line<'_>> = app.detail_buffer[start..]
268 .iter()
269 .flat_map(|s| match s.as_bytes().into_text() {
270 Ok(text) => text.lines.into_iter().collect::<Vec<_>>(),
271 Err(_) => vec![Line::raw(s.clone())],
272 })
273 .collect();
274 Paragraph::new(lines).render(inner, buf);
275}
276
277fn render_detail_splits(buf: &mut Buffer, area: Rect, app: &App) {
297 use ratatui::layout::Direction as Dir;
298
299 let focused_id = app
304 .selected_agent_id()
305 .unwrap_or_else(|| "<no agent>".into());
306 let mut cells: Vec<(String, crate::app::SplitOrientation, bool)> = Vec::new();
307 cells.push((
308 focused_id,
309 app.detail_splits
313 .first()
314 .map(|(_, o)| *o)
315 .unwrap_or(crate::app::SplitOrientation::Vertical),
316 app.selected_split == 0 && app.focused_pane == Pane::Detail,
317 ));
318 for (i, (id, orientation)) in app.detail_splits.iter().enumerate() {
319 cells.push((
320 id.clone(),
321 *orientation,
322 app.selected_split == i + 1 && app.focused_pane == Pane::Detail,
323 ));
324 }
325
326 let mut columns: Vec<Vec<usize>> = vec![vec![0]];
329 for (idx, (_, orientation, _)) in cells.iter().enumerate().skip(1) {
330 match orientation {
331 crate::app::SplitOrientation::Vertical => columns.push(vec![idx]),
332 crate::app::SplitOrientation::Horizontal => {
333 columns.last_mut().expect("seed column").push(idx);
334 }
335 }
336 }
337
338 let col_count = columns.len();
339 let col_constraints: Vec<Constraint> = (0..col_count)
340 .map(|_| Constraint::Ratio(1, col_count as u32))
341 .collect();
342 let col_areas = ratatui::layout::Layout::default()
343 .direction(Dir::Horizontal)
344 .constraints(col_constraints)
345 .split(area);
346
347 for (col_idx, col_cells) in columns.iter().enumerate() {
348 let col_area = col_areas[col_idx];
349 let row_count = col_cells.len();
350 let row_constraints: Vec<Constraint> = (0..row_count)
351 .map(|_| Constraint::Ratio(1, row_count as u32))
352 .collect();
353 let row_areas = ratatui::layout::Layout::default()
354 .direction(Dir::Vertical)
355 .constraints(row_constraints)
356 .split(col_area);
357 for (row_idx, &cell_idx) in col_cells.iter().enumerate() {
358 let cell_area = row_areas[row_idx];
359 let (agent_id, _, is_focused_split) = &cells[cell_idx];
360 render_split_cell(buf, cell_area, app, agent_id, *is_focused_split);
361 }
362 }
363}
364
365fn render_split_cell(
366 buf: &mut Buffer,
367 area: Rect,
368 app: &App,
369 agent_id: &str,
370 is_focused_split: bool,
371) {
372 let ascii = matches!(app.capabilities.color, ColorMode::Monochrome);
373 let glyph = app
374 .team
375 .agents
376 .iter()
377 .find(|a| a.id == agent_id)
378 .map(|info| crate::data::state_glyph(info, ascii))
379 .unwrap_or("?");
380 let label = crate::data::agent_label(&app.team, agent_id);
381 let title = format!(" {glyph} {label} ");
382 let border = if is_focused_split {
383 Style::default()
384 .fg(app.capabilities.accent())
385 .add_modifier(Modifier::BOLD)
386 } else {
387 Style::default().fg(app.capabilities.muted())
388 };
389 let block = Block::default()
390 .title(title)
391 .borders(Borders::ALL)
392 .border_style(border);
393 let inner = block.inner(area);
394 block.render(area, buf);
395
396 let muted = Style::default().fg(app.capabilities.muted());
400 if !is_focused_split {
401 Paragraph::new("(focus this split to stream)")
402 .style(muted)
403 .alignment(Alignment::Center)
404 .render(inner, buf);
405 return;
406 }
407 if app.detail_buffer.is_empty() {
408 Paragraph::new("(no scrollback yet)")
409 .style(muted)
410 .alignment(Alignment::Center)
411 .render(inner, buf);
412 return;
413 }
414 let cap = inner.height as usize;
415 let start = app.detail_buffer.len().saturating_sub(cap);
416 let lines: Vec<Line<'_>> = app.detail_buffer[start..]
417 .iter()
418 .map(|s| Line::raw(s.clone()))
419 .collect();
420 Paragraph::new(lines).render(inner, buf);
421}
422
423fn render_mailbox(buf: &mut Buffer, area: Rect, app: &App) {
424 let focused = app.focused_pane == Pane::Mailbox;
425 let block = pane_block("MAILBOX", focused, app);
426 let inner = block.inner(area);
427 block.render(area, buf);
428
429 if inner.height == 0 {
430 return;
431 }
432
433 let layout = Layout::default()
436 .direction(Direction::Vertical)
437 .constraints([Constraint::Length(1), Constraint::Min(0)])
438 .split(inner);
439
440 render_mailbox_tabs(buf, layout[0], app);
441 render_mailbox_body(buf, layout[1], app);
442}
443
444fn render_mailbox_tabs(buf: &mut Buffer, area: Rect, app: &App) {
445 let active_style = Style::default()
450 .fg(app.capabilities.accent())
451 .add_modifier(Modifier::REVERSED);
452 let muted = Style::default().fg(app.capabilities.muted());
453 let mut spans: Vec<ratatui::text::Span<'_>> = Vec::with_capacity(7);
454 for (i, tab) in MailboxTab::ALL.iter().enumerate() {
455 if i > 0 {
456 spans.push(ratatui::text::Span::styled(" ", muted));
457 }
458 let label = format!(" {} ", tab.label());
459 let style = if app.mailbox_tab == *tab {
460 active_style
461 } else {
462 muted
463 };
464 spans.push(ratatui::text::Span::styled(label, style));
465 }
466 Paragraph::new(Line::from(spans)).render(area, buf);
467}
468
469fn render_mailbox_body(buf: &mut Buffer, area: Rect, app: &App) {
470 if app.selected_agent_id().is_none() {
471 let muted = Style::default().fg(app.capabilities.muted());
472 Paragraph::new("(select an agent)")
473 .style(muted)
474 .alignment(Alignment::Center)
475 .render(area, buf);
476 return;
477 }
478
479 let rows = app.mailbox.rows(app.mailbox_tab);
480 if rows.is_empty() {
481 let muted = Style::default().fg(app.capabilities.muted());
482 Paragraph::new(app.mailbox_tab.empty_hint())
483 .style(muted)
484 .alignment(Alignment::Center)
485 .render(area, buf);
486 return;
487 }
488
489 let cap = area.height as usize;
491 let start = rows.len().saturating_sub(cap);
492 let lines: Vec<Line<'_>> = rows[start..]
493 .iter()
494 .map(|r| Line::raw(render_row(r, &app.team)))
495 .collect();
496 Paragraph::new(lines).render(area, buf);
497}
498
499fn pane_block<'a>(title: &'a str, focused: bool, app: &App) -> Block<'a> {
500 let border = if focused {
501 Style::default()
502 .fg(app.capabilities.accent())
503 .add_modifier(Modifier::BOLD)
504 } else {
505 Style::default().fg(app.capabilities.muted())
506 };
507 Block::default()
508 .title(title)
509 .borders(Borders::ALL)
510 .border_style(border)
511}