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, tree_row_meta, AgentInfo, TreeRowMeta};
25use crate::mailbox::{render_row, MailboxInputKind, 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 metas = tree_row_meta(&app.team.agents);
180 let lines: Vec<Line<'_>> = app
181 .team
182 .agents
183 .iter()
184 .zip(metas.iter())
185 .enumerate()
186 .map(|(i, (info, meta))| agent_line(info, *meta, Some(i) == app.selected_agent, ascii, app))
187 .collect();
188 let para = Paragraph::new(lines).alignment(Alignment::Left);
189 para.render(inner, buf);
190}
191
192fn tree_prefix(meta: TreeRowMeta, ascii: bool) -> String {
203 let branch = match (meta.is_last_sibling, ascii) {
204 (false, false) => "├─",
205 (true, false) => "└─",
206 (false, true) => "|-",
207 (true, true) => "`-",
208 };
209 match meta.depth {
210 0 => " ".to_string(),
211 1 => format!(" {branch} "),
212 _ => format!(" {branch} "),
214 }
215}
216
217fn agent_line<'a>(
218 info: &'a AgentInfo,
219 meta: TreeRowMeta,
220 selected: bool,
221 ascii: bool,
222 app: &App,
223) -> Line<'a> {
224 let glyph = state_glyph(info, ascii);
225 let label = info.display_name.as_deref().unwrap_or(&info.agent);
230 let prefix = tree_prefix(meta, ascii);
231 let display = format!("{prefix}{glyph} {label}");
232 let style = if selected {
233 Style::default()
234 .fg(app.capabilities.accent())
235 .add_modifier(Modifier::REVERSED)
236 } else {
237 Style::default()
238 };
239 Line::styled(display, style)
240}
241
242fn render_detail(buf: &mut Buffer, area: Rect, app: &App) {
243 let focused_pane = app.focused_pane == Pane::Detail;
244 let stream = matches!(app.stage, crate::app::Stage::StreamKeys);
248 let title = match app
249 .selected_agent
250 .and_then(|i| app.team.agents.get(i))
251 .map(|a| crate::data::agent_label(&app.team, &a.id))
252 {
253 Some(label) if stream => format!("DETAIL · {label} [STREAM-KEYS]"),
254 Some(label) => format!("DETAIL · {label}"),
255 None if stream => "DETAIL [STREAM-KEYS]".to_string(),
256 None => "DETAIL".to_string(),
257 };
258 let outer_block = pane_block(&title, focused_pane || stream, app);
264 let inner = outer_block.inner(area);
265 outer_block.render(area, buf);
266
267 if app.selected_agent.is_none() || app.team.agents.is_empty() {
268 let muted = Style::default().fg(app.capabilities.muted());
269 Paragraph::new("(select an agent on the left to follow its session)")
270 .style(muted)
271 .alignment(Alignment::Center)
272 .render(inner, buf);
273 return;
274 }
275
276 if !app.detail_splits.is_empty() {
281 render_detail_splits(buf, inner, app);
282 return;
283 }
284
285 if app.detail_buffer.is_empty() {
286 let muted = Style::default().fg(app.capabilities.muted());
287 Paragraph::new("(no scrollback yet — agent may be starting up)")
288 .style(muted)
289 .alignment(Alignment::Center)
290 .render(inner, buf);
291 return;
292 }
293
294 let cap = inner.height as usize;
298 let start = app.detail_buffer.len().saturating_sub(cap);
299 use ansi_to_tui::IntoText;
306 let lines: Vec<Line<'_>> = app.detail_buffer[start..]
307 .iter()
308 .flat_map(|s| match s.as_bytes().into_text() {
309 Ok(text) => text.lines.into_iter().collect::<Vec<_>>(),
310 Err(_) => vec![Line::raw(s.clone())],
311 })
312 .collect();
313 Paragraph::new(lines).render(inner, buf);
314}
315
316fn render_detail_splits(buf: &mut Buffer, area: Rect, app: &App) {
336 use ratatui::layout::Direction as Dir;
337
338 let focused_id = app
343 .selected_agent_id()
344 .unwrap_or_else(|| "<no agent>".into());
345 let mut cells: Vec<(String, crate::app::SplitOrientation, bool)> = Vec::new();
346 cells.push((
347 focused_id,
348 app.detail_splits
352 .first()
353 .map(|(_, o)| *o)
354 .unwrap_or(crate::app::SplitOrientation::Vertical),
355 app.selected_split == 0 && app.focused_pane == Pane::Detail,
356 ));
357 for (i, (id, orientation)) in app.detail_splits.iter().enumerate() {
358 cells.push((
359 id.clone(),
360 *orientation,
361 app.selected_split == i + 1 && app.focused_pane == Pane::Detail,
362 ));
363 }
364
365 let mut columns: Vec<Vec<usize>> = vec![vec![0]];
368 for (idx, (_, orientation, _)) in cells.iter().enumerate().skip(1) {
369 match orientation {
370 crate::app::SplitOrientation::Vertical => columns.push(vec![idx]),
371 crate::app::SplitOrientation::Horizontal => {
372 columns.last_mut().expect("seed column").push(idx);
373 }
374 }
375 }
376
377 let col_count = columns.len();
378 let col_constraints: Vec<Constraint> = (0..col_count)
379 .map(|_| Constraint::Ratio(1, col_count as u32))
380 .collect();
381 let col_areas = ratatui::layout::Layout::default()
382 .direction(Dir::Horizontal)
383 .constraints(col_constraints)
384 .split(area);
385
386 for (col_idx, col_cells) in columns.iter().enumerate() {
387 let col_area = col_areas[col_idx];
388 let row_count = col_cells.len();
389 let row_constraints: Vec<Constraint> = (0..row_count)
390 .map(|_| Constraint::Ratio(1, row_count as u32))
391 .collect();
392 let row_areas = ratatui::layout::Layout::default()
393 .direction(Dir::Vertical)
394 .constraints(row_constraints)
395 .split(col_area);
396 for (row_idx, &cell_idx) in col_cells.iter().enumerate() {
397 let cell_area = row_areas[row_idx];
398 let (agent_id, _, is_focused_split) = &cells[cell_idx];
399 render_split_cell(buf, cell_area, app, agent_id, *is_focused_split);
400 }
401 }
402}
403
404fn render_split_cell(
405 buf: &mut Buffer,
406 area: Rect,
407 app: &App,
408 agent_id: &str,
409 is_focused_split: bool,
410) {
411 let ascii = matches!(app.capabilities.color, ColorMode::Monochrome);
412 let glyph = app
413 .team
414 .agents
415 .iter()
416 .find(|a| a.id == agent_id)
417 .map(|info| crate::data::state_glyph(info, ascii))
418 .unwrap_or("?");
419 let label = crate::data::agent_label(&app.team, agent_id);
420 let title = format!(" {glyph} {label} ");
421 let border = if is_focused_split {
422 Style::default()
423 .fg(app.capabilities.accent())
424 .add_modifier(Modifier::BOLD)
425 } else {
426 Style::default().fg(app.capabilities.muted())
427 };
428 let block = Block::default()
429 .title(title)
430 .borders(Borders::ALL)
431 .border_style(border);
432 let inner = block.inner(area);
433 block.render(area, buf);
434
435 let muted = Style::default().fg(app.capabilities.muted());
439 if !is_focused_split {
440 Paragraph::new("(focus this split to stream)")
441 .style(muted)
442 .alignment(Alignment::Center)
443 .render(inner, buf);
444 return;
445 }
446 if app.detail_buffer.is_empty() {
447 Paragraph::new("(no scrollback yet)")
448 .style(muted)
449 .alignment(Alignment::Center)
450 .render(inner, buf);
451 return;
452 }
453 let cap = inner.height as usize;
454 let start = app.detail_buffer.len().saturating_sub(cap);
455 let lines: Vec<Line<'_>> = app.detail_buffer[start..]
456 .iter()
457 .map(|s| Line::raw(s.clone()))
458 .collect();
459 Paragraph::new(lines).render(inner, buf);
460}
461
462fn render_mailbox(buf: &mut Buffer, area: Rect, app: &App) {
463 let focused = app.focused_pane == Pane::Mailbox;
464 let block = pane_block("MAILBOX", focused, app);
465 let inner = block.inner(area);
466 block.render(area, buf);
467
468 if inner.height == 0 {
469 return;
470 }
471
472 let tab = app.mailbox_tab;
478 let input_open = app.mailbox_input_mode.is_some();
479 let filter = app.mailbox.filter_text(tab);
480 let search = app.mailbox.search_text(tab);
481 let indicator_visible = !input_open && (!filter.is_empty() || !search.is_empty());
482 let aux_height = if input_open || indicator_visible {
483 1
484 } else {
485 0
486 };
487
488 let layout = Layout::default()
489 .direction(Direction::Vertical)
490 .constraints([
491 Constraint::Length(1), Constraint::Length(aux_height), Constraint::Min(0), ])
495 .split(inner);
496
497 render_mailbox_tabs(buf, layout[0], app);
498 if aux_height == 1 {
499 render_mailbox_aux(buf, layout[1], app);
500 }
501 render_mailbox_body(buf, layout[2], app);
502}
503
504fn render_mailbox_aux(buf: &mut Buffer, area: Rect, app: &App) {
505 let tab = app.mailbox_tab;
516 let muted = Style::default().fg(app.capabilities.muted());
517 let text = match app.mailbox_input_mode {
518 Some(MailboxInputKind::Filter) => {
519 format!("filter: {}\u{2588}", app.mailbox.filter_text(tab))
520 }
521 Some(MailboxInputKind::Search) => {
522 format!("search: {}\u{2588}", app.mailbox.search_text(tab))
523 }
524 None => {
525 let filter = app.mailbox.filter_text(tab);
528 let search = app.mailbox.search_text(tab);
529 match (filter.is_empty(), search.is_empty()) {
530 (false, false) => format!("filter: {filter} search: {search}"),
531 (false, true) => format!("filter: {filter}"),
532 (true, false) => format!("search: {search}"),
533 (true, true) => String::new(), }
535 }
536 };
537 Paragraph::new(text).style(muted).render(area, buf);
538}
539
540fn render_mailbox_tabs(buf: &mut Buffer, area: Rect, app: &App) {
541 let active_style = Style::default()
546 .fg(app.capabilities.accent())
547 .add_modifier(Modifier::REVERSED);
548 let muted = Style::default().fg(app.capabilities.muted());
549 let mut spans: Vec<ratatui::text::Span<'_>> = Vec::with_capacity(7);
550 for (i, tab) in MailboxTab::ALL.iter().enumerate() {
551 if i > 0 {
552 spans.push(ratatui::text::Span::styled(" ", muted));
553 }
554 let label = format!(" {} ", tab.label());
555 let style = if app.mailbox_tab == *tab {
556 active_style
557 } else {
558 muted
559 };
560 spans.push(ratatui::text::Span::styled(label, style));
561 }
562 Paragraph::new(Line::from(spans)).render(area, buf);
563}
564
565fn render_mailbox_body(buf: &mut Buffer, area: Rect, app: &App) {
566 if app.selected_agent_id().is_none() {
567 let muted = Style::default().fg(app.capabilities.muted());
568 Paragraph::new("(select an agent)")
569 .style(muted)
570 .alignment(Alignment::Center)
571 .render(area, buf);
572 return;
573 }
574
575 let rows = app.mailbox.rows(app.mailbox_tab);
576 if rows.is_empty() {
577 let muted = Style::default().fg(app.capabilities.muted());
578 Paragraph::new(app.mailbox_tab.empty_hint())
579 .style(muted)
580 .alignment(Alignment::Center)
581 .render(area, buf);
582 return;
583 }
584
585 let visible = app.mailbox.visible_indices(app.mailbox_tab);
590 if visible.is_empty() {
591 return;
594 }
595 let cap = area.height as usize;
596 let selected = app
597 .mailbox
598 .cursor(app.mailbox_tab)
599 .selected_idx
600 .min(visible.len() - 1);
601 let start = if visible.len() <= cap {
607 0
608 } else if visible.len() - selected <= cap {
609 visible.len() - cap
610 } else {
611 selected.saturating_sub(cap.saturating_sub(1))
612 };
613 let end = (start + cap).min(visible.len());
614 let focused = app.focused_pane == Pane::Mailbox;
615 let highlight = Style::default().add_modifier(Modifier::REVERSED);
616 let muted = Style::default().fg(app.capabilities.muted());
617 let now_secs = app.now_secs;
628 const TIME_INDICATOR_WIDTH: usize = 12;
629 const TIME_INDICATOR_GUTTER: usize = 1;
630 let row_width = area.width as usize;
631 let lines: Vec<Line<'_>> = visible[start..end]
634 .iter()
635 .map(|&row_idx| {
636 let row = &rows[row_idx];
637 let left = render_row(row, &app.team, app.mailbox_tab);
638 let rtime = crate::mailbox::row_timestamp(now_secs, row.sent_at);
639 let reserved = TIME_INDICATOR_WIDTH + TIME_INDICATOR_GUTTER;
644 let left_chars = left.chars().count();
645 let max_left = row_width.saturating_sub(reserved);
646 let left_trimmed = if left_chars > max_left {
647 left.chars().take(max_left).collect::<String>()
648 } else {
649 left
650 };
651 let pad_n = max_left.saturating_sub(left_trimmed.chars().count());
652 let pad = " ".repeat(pad_n);
653 let indicator = format!("{rtime:>width$}", width = TIME_INDICATOR_WIDTH);
656 let line = Line::from(vec![
657 ratatui::text::Span::raw(left_trimmed),
658 ratatui::text::Span::raw(pad),
659 ratatui::text::Span::raw(" ".repeat(TIME_INDICATOR_GUTTER)),
660 ratatui::text::Span::styled(indicator, muted),
661 ]);
662 if focused && row_idx == visible[selected] {
663 line.style(highlight)
664 } else {
665 line
666 }
667 })
668 .collect();
669 Paragraph::new(lines).render(area, buf);
670}
671
672fn pane_block<'a>(title: &'a str, focused: bool, app: &App) -> Block<'a> {
673 let border = if focused {
674 Style::default()
675 .fg(app.capabilities.accent())
676 .add_modifier(Modifier::BOLD)
677 } else {
678 Style::default().fg(app.capabilities.muted())
679 };
680 Block::default()
681 .title(title)
682 .borders(Borders::ALL)
683 .border_style(border)
684}