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 stream = matches!(self.app.stage, crate::app::Stage::StreamKeys);
138 let right_stack = Layout::default()
139 .direction(Direction::Vertical)
140 .constraints(crate::pane_resize::right_stack_constraints(stream))
141 .split(outer[1]);
142
143 render_agents(buf, outer[0], self.app);
144 render_detail(buf, right_stack[0], self.app);
145 render_mailbox(buf, right_stack[1], self.app);
146 }
147}
148
149fn render_approvals_stripe(buf: &mut Buffer, area: Rect, app: &App) {
150 let n = app.pending_approvals.len();
151 let plural = if n == 1 { "" } else { "s" };
152 let text = format!("⚠ approvals: {n} pending{plural} — `a` to review");
153 let style = Style::default()
157 .fg(app.capabilities.accent())
158 .add_modifier(Modifier::REVERSED | Modifier::BOLD);
159 Paragraph::new(text)
160 .style(style)
161 .alignment(Alignment::Left)
162 .render(area, buf);
163}
164
165fn render_agents(buf: &mut Buffer, area: Rect, app: &App) {
166 let focused = app.focused_pane == Pane::Roster;
167 let block = pane_block("AGENTS", focused, app);
168 let inner = block.inner(area);
169 block.render(area, buf);
170
171 if app.team.agents.is_empty() {
172 let empty = Paragraph::new("(no agents)")
173 .style(Style::default().fg(app.capabilities.muted()))
174 .alignment(Alignment::Center);
175 empty.render(inner, buf);
176 return;
177 }
178
179 let ascii = matches!(app.capabilities.color, ColorMode::Monochrome);
180 let metas = tree_row_meta(&app.team.agents);
186 let lines: Vec<Line<'_>> = app
187 .team
188 .agents
189 .iter()
190 .zip(metas.iter())
191 .enumerate()
192 .map(|(i, (info, meta))| agent_line(info, *meta, Some(i) == app.selected_agent, ascii, app))
193 .collect();
194 let para = Paragraph::new(lines).alignment(Alignment::Left);
195 para.render(inner, buf);
196}
197
198fn tree_prefix(meta: TreeRowMeta, ascii: bool) -> String {
209 let branch = match (meta.is_last_sibling, ascii) {
210 (false, false) => "├─",
211 (true, false) => "└─",
212 (false, true) => "|-",
213 (true, true) => "`-",
214 };
215 match meta.depth {
216 0 => " ".to_string(),
217 1 => format!(" {branch} "),
218 _ => format!(" {branch} "),
220 }
221}
222
223fn agent_line<'a>(
224 info: &'a AgentInfo,
225 meta: TreeRowMeta,
226 selected: bool,
227 ascii: bool,
228 app: &App,
229) -> Line<'a> {
230 let glyph = state_glyph(info, ascii, app.now_secs);
231 let label = info.display_name.as_deref().unwrap_or(&info.agent);
236 let prefix = tree_prefix(meta, ascii);
237 let display = format!("{prefix}{glyph} {label}");
238 let style = if selected {
239 Style::default()
240 .fg(app.capabilities.accent())
241 .add_modifier(Modifier::REVERSED)
242 } else {
243 Style::default()
244 };
245 Line::styled(display, style)
246}
247
248fn render_detail(buf: &mut Buffer, area: Rect, app: &App) {
249 let focused_pane = app.focused_pane == Pane::Detail;
250 let stream = matches!(app.stage, crate::app::Stage::StreamKeys);
254 let title = match app
255 .selected_agent
256 .and_then(|i| app.team.agents.get(i))
257 .map(|a| crate::data::agent_label(&app.team, &a.id))
258 {
259 Some(label) if stream => format!("DETAIL · {label} [STREAM-KEYS]"),
260 Some(label) => format!("DETAIL · {label}"),
261 None if stream => "DETAIL [STREAM-KEYS]".to_string(),
262 None => "DETAIL".to_string(),
263 };
264 let outer_block = pane_block(&title, focused_pane || stream, app);
270 let inner = outer_block.inner(area);
271 outer_block.render(area, buf);
272
273 if app.selected_agent.is_none() || app.team.agents.is_empty() {
274 let muted = Style::default().fg(app.capabilities.muted());
275 Paragraph::new("(select an agent on the left to follow its session)")
276 .style(muted)
277 .alignment(Alignment::Center)
278 .render(inner, buf);
279 return;
280 }
281
282 if !app.detail_splits.is_empty() {
287 render_detail_splits(buf, inner, app);
288 return;
289 }
290
291 if app.detail_buffer.is_empty() {
292 let muted = Style::default().fg(app.capabilities.muted());
293 Paragraph::new("(no scrollback yet — agent may be starting up)")
294 .style(muted)
295 .alignment(Alignment::Center)
296 .render(inner, buf);
297 return;
298 }
299
300 let cap = inner.height as usize;
304 let start = app.detail_buffer.len().saturating_sub(cap);
305 use ansi_to_tui::IntoText;
312 let lines: Vec<Line<'_>> = app.detail_buffer[start..]
313 .iter()
314 .flat_map(|s| match s.as_bytes().into_text() {
315 Ok(text) => text.lines.into_iter().collect::<Vec<_>>(),
316 Err(_) => vec![Line::raw(s.clone())],
317 })
318 .collect();
319 Paragraph::new(lines).render(inner, buf);
320}
321
322fn render_detail_splits(buf: &mut Buffer, area: Rect, app: &App) {
342 use ratatui::layout::Direction as Dir;
343
344 let focused_id = app
349 .selected_agent_id()
350 .unwrap_or_else(|| "<no agent>".into());
351 let mut cells: Vec<(String, crate::app::SplitOrientation, bool)> = Vec::new();
352 cells.push((
353 focused_id,
354 app.detail_splits
358 .first()
359 .map(|(_, o)| *o)
360 .unwrap_or(crate::app::SplitOrientation::Vertical),
361 app.selected_split == 0 && app.focused_pane == Pane::Detail,
362 ));
363 for (i, (id, orientation)) in app.detail_splits.iter().enumerate() {
364 cells.push((
365 id.clone(),
366 *orientation,
367 app.selected_split == i + 1 && app.focused_pane == Pane::Detail,
368 ));
369 }
370
371 let mut columns: Vec<Vec<usize>> = vec![vec![0]];
374 for (idx, (_, orientation, _)) in cells.iter().enumerate().skip(1) {
375 match orientation {
376 crate::app::SplitOrientation::Vertical => columns.push(vec![idx]),
377 crate::app::SplitOrientation::Horizontal => {
378 columns.last_mut().expect("seed column").push(idx);
379 }
380 }
381 }
382
383 let col_count = columns.len();
384 let col_constraints: Vec<Constraint> = (0..col_count)
385 .map(|_| Constraint::Ratio(1, col_count as u32))
386 .collect();
387 let col_areas = ratatui::layout::Layout::default()
388 .direction(Dir::Horizontal)
389 .constraints(col_constraints)
390 .split(area);
391
392 for (col_idx, col_cells) in columns.iter().enumerate() {
393 let col_area = col_areas[col_idx];
394 let row_count = col_cells.len();
395 let row_constraints: Vec<Constraint> = (0..row_count)
396 .map(|_| Constraint::Ratio(1, row_count as u32))
397 .collect();
398 let row_areas = ratatui::layout::Layout::default()
399 .direction(Dir::Vertical)
400 .constraints(row_constraints)
401 .split(col_area);
402 for (row_idx, &cell_idx) in col_cells.iter().enumerate() {
403 let cell_area = row_areas[row_idx];
404 let (agent_id, _, is_focused_split) = &cells[cell_idx];
405 render_split_cell(buf, cell_area, app, agent_id, *is_focused_split);
406 }
407 }
408}
409
410fn render_split_cell(
411 buf: &mut Buffer,
412 area: Rect,
413 app: &App,
414 agent_id: &str,
415 is_focused_split: bool,
416) {
417 let ascii = matches!(app.capabilities.color, ColorMode::Monochrome);
418 let glyph = app
419 .team
420 .agents
421 .iter()
422 .find(|a| a.id == agent_id)
423 .map(|info| crate::data::state_glyph(info, ascii, app.now_secs))
424 .unwrap_or("?");
425 let label = crate::data::agent_label(&app.team, agent_id);
426 let title = format!(" {glyph} {label} ");
427 let border = if is_focused_split {
428 Style::default()
429 .fg(app.capabilities.accent())
430 .add_modifier(Modifier::BOLD)
431 } else {
432 Style::default().fg(app.capabilities.muted())
433 };
434 let block = Block::default()
435 .title(title)
436 .borders(Borders::ALL)
437 .border_style(border);
438 let inner = block.inner(area);
439 block.render(area, buf);
440
441 let muted = Style::default().fg(app.capabilities.muted());
445 if !is_focused_split {
446 Paragraph::new("(focus this split to stream)")
447 .style(muted)
448 .alignment(Alignment::Center)
449 .render(inner, buf);
450 return;
451 }
452 if app.detail_buffer.is_empty() {
453 Paragraph::new("(no scrollback yet)")
454 .style(muted)
455 .alignment(Alignment::Center)
456 .render(inner, buf);
457 return;
458 }
459 let cap = inner.height as usize;
460 let start = app.detail_buffer.len().saturating_sub(cap);
461 let lines: Vec<Line<'_>> = app.detail_buffer[start..]
462 .iter()
463 .map(|s| Line::raw(s.clone()))
464 .collect();
465 Paragraph::new(lines).render(inner, buf);
466}
467
468fn render_mailbox(buf: &mut Buffer, area: Rect, app: &App) {
469 let focused = app.focused_pane == Pane::Mailbox;
470 let block = pane_block("MAILBOX", focused, app);
471 let inner = block.inner(area);
472 block.render(area, buf);
473
474 if inner.height == 0 {
475 return;
476 }
477
478 let tab = app.mailbox_tab;
484 let input_open = app.mailbox_input_mode.is_some();
485 let filter = app.mailbox.filter_text(tab);
486 let search = app.mailbox.search_text(tab);
487 let indicator_visible = !input_open && (!filter.is_empty() || !search.is_empty());
488 let aux_height = if input_open || indicator_visible {
489 1
490 } else {
491 0
492 };
493
494 let layout = Layout::default()
495 .direction(Direction::Vertical)
496 .constraints([
497 Constraint::Length(1), Constraint::Length(aux_height), Constraint::Min(0), ])
501 .split(inner);
502
503 render_mailbox_tabs(buf, layout[0], app);
504 if aux_height == 1 {
505 render_mailbox_aux(buf, layout[1], app);
506 }
507 render_mailbox_body(buf, layout[2], app);
508}
509
510fn render_mailbox_aux(buf: &mut Buffer, area: Rect, app: &App) {
511 let tab = app.mailbox_tab;
522 let muted = Style::default().fg(app.capabilities.muted());
523 let text = match app.mailbox_input_mode {
524 Some(MailboxInputKind::Filter) => {
525 format!("filter: {}\u{2588}", app.mailbox.filter_text(tab))
526 }
527 Some(MailboxInputKind::Search) => {
528 format!("search: {}\u{2588}", app.mailbox.search_text(tab))
529 }
530 None => {
531 let filter = app.mailbox.filter_text(tab);
534 let search = app.mailbox.search_text(tab);
535 match (filter.is_empty(), search.is_empty()) {
536 (false, false) => format!("filter: {filter} search: {search}"),
537 (false, true) => format!("filter: {filter}"),
538 (true, false) => format!("search: {search}"),
539 (true, true) => String::new(), }
541 }
542 };
543 Paragraph::new(text).style(muted).render(area, buf);
544}
545
546fn render_mailbox_tabs(buf: &mut Buffer, area: Rect, app: &App) {
547 let active_style = Style::default()
552 .fg(app.capabilities.accent())
553 .add_modifier(Modifier::REVERSED);
554 let muted = Style::default().fg(app.capabilities.muted());
555 let mut spans: Vec<ratatui::text::Span<'_>> = Vec::with_capacity(7);
556 for (i, tab) in MailboxTab::ALL.iter().enumerate() {
557 if i > 0 {
558 spans.push(ratatui::text::Span::styled(" ", muted));
559 }
560 let label = format!(" {} ", tab.label());
561 let style = if app.mailbox_tab == *tab {
562 active_style
563 } else {
564 muted
565 };
566 spans.push(ratatui::text::Span::styled(label, style));
567 }
568 Paragraph::new(Line::from(spans)).render(area, buf);
569}
570
571fn render_mailbox_body(buf: &mut Buffer, area: Rect, app: &App) {
572 if app.selected_agent_id().is_none() {
573 let muted = Style::default().fg(app.capabilities.muted());
574 Paragraph::new("(select an agent)")
575 .style(muted)
576 .alignment(Alignment::Center)
577 .render(area, buf);
578 return;
579 }
580
581 let rows = app.mailbox.rows(app.mailbox_tab);
582 if rows.is_empty() {
583 let muted = Style::default().fg(app.capabilities.muted());
584 Paragraph::new(app.mailbox_tab.empty_hint())
585 .style(muted)
586 .alignment(Alignment::Center)
587 .render(area, buf);
588 return;
589 }
590
591 let visible = app.mailbox.visible_indices_in(&rows, app.mailbox_tab);
598 if visible.is_empty() {
599 return;
602 }
603 let cap = area.height as usize;
604 let selected = app
605 .mailbox
606 .cursor(app.mailbox_tab)
607 .selected_idx
608 .min(visible.len() - 1);
609 let start = if visible.len() <= cap {
615 0
616 } else if visible.len() - selected <= cap {
617 visible.len() - cap
618 } else {
619 selected.saturating_sub(cap.saturating_sub(1))
620 };
621 let end = (start + cap).min(visible.len());
622 let focused = app.focused_pane == Pane::Mailbox;
623 let highlight = Style::default().add_modifier(Modifier::REVERSED);
624 let muted = Style::default().fg(app.capabilities.muted());
625 let now_secs = app.now_secs;
636 const TIME_INDICATOR_WIDTH: usize = 12;
637 const TIME_INDICATOR_GUTTER: usize = 1;
638 let row_width = area.width as usize;
639 let lines: Vec<Line<'_>> = visible[start..end]
642 .iter()
643 .map(|&row_idx| {
644 let row = &rows[row_idx];
645 let left = render_row(row, &app.team, app.mailbox_tab);
646 let rtime = crate::mailbox::row_timestamp(now_secs, row.sent_at);
647 let reserved = TIME_INDICATOR_WIDTH + TIME_INDICATOR_GUTTER;
652 let left_chars = left.chars().count();
653 let max_left = row_width.saturating_sub(reserved);
654 let left_trimmed = if left_chars > max_left {
655 left.chars().take(max_left).collect::<String>()
656 } else {
657 left
658 };
659 let pad_n = max_left.saturating_sub(left_trimmed.chars().count());
660 let pad = " ".repeat(pad_n);
661 let indicator = format!("{rtime:>width$}", width = TIME_INDICATOR_WIDTH);
664 let line = Line::from(vec![
665 ratatui::text::Span::raw(left_trimmed),
666 ratatui::text::Span::raw(pad),
667 ratatui::text::Span::raw(" ".repeat(TIME_INDICATOR_GUTTER)),
668 ratatui::text::Span::styled(indicator, muted),
669 ]);
670 if focused && row_idx == visible[selected] {
671 line.style(highlight)
672 } else {
673 line
674 }
675 })
676 .collect();
677 Paragraph::new(lines).render(area, buf);
678}
679
680fn pane_block<'a>(title: &'a str, focused: bool, app: &App) -> Block<'a> {
681 let border = if focused {
682 Style::default()
683 .fg(app.capabilities.accent())
684 .add_modifier(Modifier::BOLD)
685 } else {
686 Style::default().fg(app.capabilities.muted())
687 };
688 Block::default()
689 .title(title)
690 .borders(Borders::ALL)
691 .border_style(border)
692}