use super::super::render_scrollbar_with_theme;
use super::{SESSIONS_MODAL, draw_modal_panel, modal_text_area};
use crate::tui::{state::MissionControlState, theme::MissionControlTheme};
use ratatui::{
Frame,
layout::{Alignment, Rect},
style::Modifier,
widgets::{Block, Clear, Paragraph, Wrap},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct SubagentViewerModalAreas {
pub(crate) help: Rect,
pub(crate) tabs: Rect,
pub(crate) timeline: Rect,
}
pub(crate) fn subagent_viewer_modal_areas(area: Rect) -> Option<SubagentViewerModalAreas> {
let text_area = modal_text_area(area, "Subagent Viewer", SESSIONS_MODAL)?;
if text_area.height < 3 || text_area.width == 0 {
return None;
}
let help = Rect::new(text_area.x, text_area.y, text_area.width, 1);
let tabs = Rect::new(
text_area.x,
text_area.y.saturating_add(1),
text_area.width,
1,
);
let timeline_y = tabs.y.saturating_add(1);
let timeline = Rect::new(
text_area.x,
timeline_y,
text_area.width,
text_area
.y
.saturating_add(text_area.height)
.saturating_sub(timeline_y),
);
(timeline.height > 0).then_some(SubagentViewerModalAreas {
help,
tabs,
timeline,
})
}
pub(crate) fn subagent_viewer_timeline_area(area: Rect) -> Option<Rect> {
subagent_viewer_modal_areas(area).map(|areas| areas.timeline)
}
pub(crate) fn subagent_viewer_tab_at(
area: Rect,
state: &MissionControlState,
column: u16,
row: u16,
) -> Option<usize> {
let areas = subagent_viewer_modal_areas(area)?;
if !contains(areas.tabs, column, row) {
return None;
}
let tabs = state.subagent_viewer_tabs();
let selected = selected_tab_index(state, &tabs);
visible_tab_areas(areas.tabs, tabs.len(), selected)
.into_iter()
.find(|tab| contains(tab.area, column, row))
.map(|tab| tab.actual_index)
}
pub(in crate::tui::render) fn draw_subagent_viewer_modal(
frame: &mut Frame<'_>,
area: Rect,
state: &MissionControlState,
) {
if !state.subagent_viewer_visible() {
return;
}
let theme = state.theme;
let Some(areas) = subagent_viewer_modal_areas(area) else {
return;
};
if draw_modal_panel(frame, area, "Subagent Viewer", SESSIONS_MODAL, theme).is_none() {
return;
}
frame.render_widget(
Paragraph::new(super::super::hotkey_line_with_theme(
theme,
"[Tab]/[Shift-Tab]/[Left]/[Right] switch • [Up]/[Down]/[PgUp]/[PgDn]/[Home]/[End]/wheel scroll • [Esc] close",
theme.muted(false),
))
.style(theme.muted(false)),
areas.help,
);
draw_tabs(frame, areas.tabs, state, theme);
draw_timeline(frame, areas.timeline, state, theme);
}
fn draw_tabs(
frame: &mut Frame<'_>,
area: Rect,
state: &MissionControlState,
theme: MissionControlTheme,
) {
let tabs = state.subagent_viewer_tabs();
if tabs.is_empty() {
frame.render_widget(
Paragraph::new("No sibling subagents available")
.style(theme.muted(false).bg(theme.surface_overlay())),
area,
);
return;
}
let selected = selected_tab_index(state, &tabs);
for visible_tab in visible_tab_areas(area, tabs.len(), selected) {
let Some(task_id) = tabs.get(visible_tab.actual_index) else {
continue;
};
if visible_tab.area.width == 0 {
continue;
}
let label_width = usize::from(visible_tab.area.width.saturating_sub(2)).max(1);
let label = state.subagent_viewer_tab_label(task_id, label_width);
let style = if visible_tab.actual_index == selected {
theme
.selection()
.add_modifier(Modifier::BOLD)
.bg(theme.surface_selection())
} else {
theme.muted(false).bg(theme.surface_overlay())
};
frame.render_widget(
Paragraph::new(label)
.alignment(Alignment::Center)
.style(style),
visible_tab.area,
);
}
}
fn draw_timeline(
frame: &mut Frame<'_>,
area: Rect,
state: &MissionControlState,
theme: MissionControlTheme,
) {
if area.width == 0 || area.height == 0 {
return;
}
let Some(task_id) = state.subagent_viewer_selected_task_id() else {
frame.render_widget(
Paragraph::new("No live activity available")
.style(theme.overlay_panel())
.wrap(Wrap { trim: false }),
area,
);
return;
};
let content_width = super::super::scroll_view_content_width(area).max(1);
let offset = state.subagent_viewer_scroll_offset(area.height, content_width);
let timeline = state.cached_subagent_viewer_timeline(&task_id, content_width);
let content = Rect {
width: content_width.min(area.width.saturating_sub(1)),
..area
};
let (visible_lines, skipped_rows) = timeline.visible_line_range(offset, area.height);
let lines = timeline.lines[visible_lines]
.iter()
.map(|line| line.line.clone())
.collect::<Vec<_>>();
frame.render_widget(
Paragraph::new(lines)
.style(theme.overlay_panel())
.wrap(Wrap { trim: false })
.scroll((skipped_rows, 0)),
content,
);
let gutter = Rect::new(
area.x + content.width,
area.y,
area.width - content.width,
area.height,
);
frame.render_widget(Clear, gutter);
frame.render_widget(Block::default().style(theme.pane()), gutter);
render_scrollbar_with_theme(
frame,
area,
timeline.rows.saturating_sub(usize::from(area.height)) + 1,
offset,
usize::from(area.height),
theme,
);
}
const MIN_TAB_WIDTH: u16 = 12;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct VisibleTabArea {
actual_index: usize,
area: Rect,
}
fn selected_tab_index(state: &MissionControlState, tabs: &[crate::output::ActivityId]) -> usize {
state
.subagent_viewer_selected_task_id()
.and_then(|selected| tabs.iter().position(|task_id| task_id == &selected))
.unwrap_or(0)
}
fn visible_tab_areas(area: Rect, count: usize, selected: usize) -> Vec<VisibleTabArea> {
if count == 0 || area.width == 0 {
return Vec::new();
}
let visible_count = count.min((usize::from(area.width) / usize::from(MIN_TAB_WIDTH)).max(1));
let selected = selected.min(count - 1);
let start = selected
.saturating_sub(visible_count / 2)
.min(count - visible_count);
let width = usize::from(area.width);
let base = width / visible_count;
let remainder = width % visible_count;
let mut x = area.x;
(start..start + visible_count)
.enumerate()
.map(|(visible_index, actual_index)| {
let tab_width = base + usize::from(visible_index < remainder);
let tab = Rect::new(x, area.y, tab_width as u16, area.height);
x = x.saturating_add(tab.width);
VisibleTabArea {
actual_index,
area: tab,
}
})
.collect()
}
fn contains(area: Rect, column: u16, row: u16) -> bool {
column >= area.x
&& column < area.x.saturating_add(area.width)
&& row >= area.y
&& row < area.y.saturating_add(area.height)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::output::{
ActivityEvent, ActivityId, ActivityKind, ActivityMetadata, ActivityStatus,
};
fn viewer_state() -> MissionControlState {
let mut state = MissionControlState::default();
let batch = ActivityId::new("batch");
state.apply_activity_event(ActivityEvent::Started {
id: batch.clone(),
parent_id: None,
kind: ActivityKind::SubagentBatch,
status: ActivityStatus::Running,
metadata: ActivityMetadata::new("subagents"),
});
for task in ["g1", "g2"] {
state.apply_activity_event(ActivityEvent::Started {
id: ActivityId::new(format!("batch/{task}")),
parent_id: Some(batch.clone()),
kind: ActivityKind::SubagentTask,
status: ActivityStatus::Running,
metadata: ActivityMetadata::new(task),
});
}
state.open_subagent_viewer(batch, ActivityId::new("batch/g1"));
state
}
#[test]
fn visible_timeline_matches_full_scroll_view_at_wrapped_offsets() {
use ratatui::{Terminal, backend::TestBackend};
let mut state = viewer_state();
let task = state.subagent_viewer_selected_task_id().unwrap();
state.apply_activity_event(crate::output::ActivityEvent::Delta {
id: task.clone(),
preview: (0..40)
.map(|index| format!("row {index}: 界 alpha beta gamma delta 👩💻\n"))
.collect(),
});
for (width, height) in [(1, 7), (2, 7), (8, 7), (23, 7), (80, 7), (80, 80)] {
let area = Rect::new(0, 0, width, height);
let content_width = width.saturating_sub(1).max(1);
for offset in [0, 1, 5, 13, 1000] {
state.home_subagent_viewer();
state.scroll_subagent_viewer_down(offset, area.height, content_width);
let scroll_offset = state.subagent_viewer_scroll_offset(area.height, content_width);
let mut actual = Terminal::new(TestBackend::new(width, area.height)).unwrap();
actual
.draw(|frame| draw_timeline(frame, area, &state, state.theme))
.unwrap();
let mut expected = Terminal::new(TestBackend::new(width, area.height)).unwrap();
expected
.draw(|frame| {
let timeline = state.cached_subagent_viewer_timeline(&task, content_width);
let (visible_lines, _) =
timeline.visible_line_range(scroll_offset, area.height);
assert!(visible_lines.len() <= usize::from(area.height));
super::super::super::render_scroll_view_persistent_with_theme(
frame,
area,
scroll_offset as u16,
timeline.rows as u16,
Paragraph::new(
timeline
.lines
.iter()
.map(|line| line.line.clone())
.collect::<Vec<_>>(),
)
.style(state.theme.overlay_panel())
.wrap(Wrap { trim: false }),
&Default::default(),
state.theme,
);
})
.unwrap();
assert_eq!(
actual.backend().buffer(),
expected.backend().buffer(),
"width={width}, offset={scroll_offset}"
);
}
}
}
#[test]
fn viewer_geometry_reserves_help_tabs_and_timeline() {
let areas = subagent_viewer_modal_areas(Rect::new(0, 0, 100, 30)).expect("areas");
assert_eq!(areas.help.height, 1);
assert_eq!(areas.tabs.height, 1);
assert_eq!(areas.timeline.y, areas.tabs.y + 1);
assert!(areas.timeline.height > 0);
}
#[test]
fn viewer_tab_hit_testing_uses_stable_tab_order() {
let state = viewer_state();
let areas = subagent_viewer_modal_areas(Rect::new(0, 0, 100, 30)).expect("areas");
let tabs = visible_tab_areas(areas.tabs, 2, 0);
assert_eq!(
subagent_viewer_tab_at(
Rect::new(0, 0, 100, 30),
&state,
tabs[0].area.x,
tabs[0].area.y,
),
Some(0)
);
assert_eq!(
subagent_viewer_tab_at(
Rect::new(0, 0, 100, 30),
&state,
tabs[1].area.x,
tabs[1].area.y,
),
Some(1)
);
assert_eq!(
subagent_viewer_tab_at(Rect::new(0, 0, 100, 30), &state, areas.help.x, areas.help.y),
None
);
}
#[test]
fn narrow_viewer_hit_testing_keeps_selected_nonzero_tab_visible() {
let mut state = viewer_state();
assert!(state.select_subagent_viewer_tab(1));
let terminal = Rect::new(0, 0, 12, 12);
let areas = subagent_viewer_modal_areas(terminal).expect("narrow viewer areas");
let visible = visible_tab_areas(areas.tabs, 2, 1);
assert_eq!(visible.len(), 1);
assert_eq!(visible[0].actual_index, 1);
assert_eq!(
subagent_viewer_tab_at(terminal, &state, visible[0].area.x, visible[0].area.y),
Some(1)
);
}
}