use super::super::render_scrollbar_with_theme;
use super::{ModalHeight, ModalSpec, modal_rect, modal_text_area};
use crate::tui::{state::MissionControlState, theme::MissionControlTheme};
use ratatui::{
Frame,
layout::{Alignment, Rect},
style::Modifier,
widgets::{Block, Clear, Paragraph, Wrap},
};
const SUBAGENT_VIEWER_MODAL: ModalSpec = ModalSpec {
min_width: 12,
min_height: 5,
width_ratio: (3, 5),
height: ModalHeight::Ratio(3, 5),
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct SubagentViewerModalAreas {
pub(crate) help: Rect,
pub(crate) tabs: Rect,
pub(crate) timeline: Rect,
pub(crate) prompt: Rect,
}
pub(crate) fn subagent_viewer_modal_areas(area: Rect) -> Option<SubagentViewerModalAreas> {
let modal = modal_rect(area, SUBAGENT_VIEWER_MODAL)?;
let mut text_area = modal_text_area(area, "Subagent Viewer", SUBAGENT_VIEWER_MODAL)?;
text_area.height = modal.bottom().saturating_sub(1).saturating_sub(text_area.y);
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 remaining_height = text_area.bottom().saturating_sub(timeline_y);
let prompt_height = remaining_height.saturating_sub(1).min(3);
let prompt_height = if prompt_height >= 3 { prompt_height } else { 0 };
let timeline = Rect::new(
text_area.x,
timeline_y,
text_area.width,
remaining_height.saturating_sub(prompt_height),
);
let prompt = Rect::new(
text_area.x,
timeline.bottom(),
text_area.width,
prompt_height,
);
(timeline.height > 0).then_some(SubagentViewerModalAreas {
help,
tabs,
timeline,
prompt,
})
}
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_prompt_area(area: Rect) -> Option<Rect> {
subagent_viewer_modal_areas(area)
.map(|areas| areas.prompt)
.filter(|area| area.height >= 3 && area.width > 2)
}
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;
};
let Some(modal) = modal_rect(area, SUBAGENT_VIEWER_MODAL) else {
return;
};
frame.render_widget(Clear, modal);
frame.render_widget(
super::super::bordered_block()
.title(super::super::padded_title_text(
"Subagent Viewer",
theme.title(false),
))
.border_style(theme.border_alt().bg(theme.surface_background()))
.style(theme.pane()),
modal,
);
frame.render_widget(
Paragraph::new(super::super::hotkey_line_with_theme(
theme,
"[Tab]/[Shift-Tab] switch • [PgUp]/[PgDn]/wheel scroll • [Esc] close",
theme.muted(false),
))
.style(theme.muted(false)),
areas.help,
);
draw_timeline(frame, areas.timeline, state, theme);
draw_tabs(frame, areas.tabs, state, theme);
if areas.prompt.height >= 3 && areas.prompt.width > 2 {
super::super::prompt::draw_subagent_prompt(frame, areas.prompt, state);
if state.subagent_prompt_editable()
&& let Some(autocomplete) = state
.selected_subagent_prompt()
.and_then(|draft| draft.autocomplete.as_ref())
{
let completion_bounds = Rect::new(
areas.timeline.x,
areas.timeline.y,
areas.timeline.width,
areas.timeline.height + areas.prompt.height,
);
super::super::prompt::draw_prompt_autocomplete(
frame,
completion_bounds,
areas.prompt,
autocomplete,
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_background())),
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_background())
};
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.pane())
.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.pane())
.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.pane())
.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_preserves_card_styles_dark_padding_and_one_typing_row() {
use crate::appearance::RuntimeAppearance;
use ratatui::{Terminal, backend::TestBackend};
let appearance = RuntimeAppearance::default();
let mut custom = (*appearance.theme).clone();
custom.roles.surface_panel_alt = opaline::OpalineColor::new(210, 220, 230);
for theme in [
MissionControlTheme::default(),
MissionControlTheme::from_runtime_appearance(
&appearance.with_resolved_theme(custom),
1,
),
] {
let mut state = viewer_state();
state.theme = theme;
state.apply_activity_event(ActivityEvent::Delta {
id: ActivityId::new("batch/g1"),
preview: "child output".into(),
});
state.with_selected_subagent_prompt(|state| {
state.set_prompt_text("first typing row\nsecond typing row", 0);
});
let screen = Rect::new(0, 0, 200, 50);
let modal = Rect::new(40, 10, 120, 30);
assert_eq!(modal_rect(screen, SUBAGENT_VIEWER_MODAL), Some(modal));
let areas = subagent_viewer_modal_areas(screen).unwrap();
let selected_tab = visible_tab_areas(areas.tabs, 2, 0)[0].area;
let mut terminal =
Terminal::new(TestBackend::new(screen.width, screen.height)).unwrap();
terminal
.draw(|frame| {
frame.render_widget(
Block::default().style(theme.transcript_user_body()),
screen,
);
draw_subagent_viewer_modal(frame, screen, &state);
})
.unwrap();
let buffer = terminal.backend().buffer();
for y in 0..screen.height {
for x in 0..screen.width {
if contains(areas.prompt, x, y)
|| contains(selected_tab, x, y)
|| contains(areas.timeline, x, y)
{
continue;
}
let expected = if contains(modal, x, y) {
theme.surface_background()
} else {
theme.surface_panel()
};
assert_eq!(buffer[(x, y)].bg, expected, "cell ({x}, {y})");
}
}
let timeline = areas.timeline;
let content_right = timeline.right() - 1;
for (row, background) in [
theme.surface_panel_alt(),
theme.surface_panel_alt(),
theme.surface_panel_alt(),
theme.surface_panel(),
theme.surface_panel(),
theme.surface_panel(),
]
.into_iter()
.enumerate()
{
let y = timeline.y + row as u16;
for x in timeline.x..content_right {
assert_eq!(buffer[(x, y)].bg, background, "card cell ({x}, {y})");
if matches!(row, 0 | 2 | 3 | 5) {
assert_eq!(buffer[(x, y)].symbol(), " ", "card padding ({x}, {y})");
}
}
}
for y in timeline.y..timeline.bottom() {
assert_eq!(buffer[(content_right, y)].bg, theme.surface_background());
}
for y in timeline.y + 6..timeline.bottom() {
for x in timeline.x..content_right {
assert_eq!(buffer[(x, y)].bg, theme.surface_background());
}
}
let output_row = (timeline.x..content_right)
.map(|x| buffer[(x, timeline.y + 4)].symbol())
.collect::<String>();
assert!(output_row.contains("child output"));
assert_ne!(buffer[(modal.x, modal.y)].symbol(), " ");
assert_ne!(
buffer[(modal.right() - 1, modal.bottom() - 1)].symbol(),
" "
);
assert_eq!(
buffer[(selected_tab.x, selected_tab.y)].bg,
theme.surface_selection()
);
assert_eq!(areas.prompt.height, 3);
assert_eq!(areas.prompt.bottom(), modal.bottom() - 1);
assert_ne!(
buffer[(areas.prompt.x, areas.prompt.bottom() - 1)].symbol(),
" "
);
assert_ne!(buffer[(areas.prompt.x, modal.bottom() - 1)].symbol(), " ");
assert_eq!(
buffer[(areas.prompt.x, areas.prompt.y + 1)].bg,
theme.surface_background()
);
let text = buffer
.content
.iter()
.map(|cell| cell.symbol())
.collect::<String>();
assert!(text.contains("child output"));
assert!(text.contains("first typing row"));
assert!(!text.contains("second typing row"));
}
}
#[test]
fn viewer_geometry_reserves_help_tabs_timeline_and_prompt() {
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);
assert_eq!(areas.prompt.height, 3);
assert_eq!(areas.timeline.bottom(), areas.prompt.y);
let viewport =
crate::tui::viewports::key_viewports(Rect::new(0, 0, 100, 30), &viewer_state())
.subagent_prompt
.unwrap();
let editor_area = super::super::super::content_text_area_for_pane(
super::super::super::TuiPane::Prompt,
areas.prompt,
);
assert_eq!(viewport.visible_rows, editor_area.height);
assert_eq!(viewport.visible_rows, 1);
assert_eq!(
viewport.wrap_width,
super::super::super::scroll_view_content_width(editor_area)
);
}
#[test]
fn viewer_prompt_renders_selected_draft_and_local_rejection_notice() {
use ratatui::{Terminal, backend::TestBackend};
let mut state = viewer_state();
state.set_prompt_text("primary draft must not leak", 0);
state.with_selected_subagent_prompt(|state| {
state.set_prompt_text("selected child draft", 0);
state.status = "Slash commands are not available for subagents".into();
});
let mut terminal = Terminal::new(TestBackend::new(140, 30)).unwrap();
terminal
.draw(|frame| draw_subagent_viewer_modal(frame, frame.area(), &state))
.unwrap();
let text = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect::<String>();
assert!(text.contains("selected child draft"));
assert!(text.contains("Slash commands are not available for subagents"));
assert!(text.contains("Read-only"));
assert!(!text.contains("primary draft must not leak"));
state.select_subagent_viewer_tab(1);
terminal
.draw(|frame| draw_subagent_viewer_modal(frame, frame.area(), &state))
.unwrap();
let text = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect::<String>();
assert!(!text.contains("selected child draft"));
assert!(!text.contains("Slash commands are not available for subagents"));
}
#[test]
fn viewer_prompt_small_terminal_layout_does_not_overlap_output() {
use ratatui::{Terminal, backend::TestBackend};
for (width, height) in [(1, 1), (12, 8), (12, 12), (40, 15)] {
let area = Rect::new(0, 0, width, height);
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| draw_subagent_viewer_modal(frame, area, &viewer_state()))
.unwrap();
if let Some(areas) = subagent_viewer_modal_areas(area) {
assert!(areas.timeline.bottom() <= areas.prompt.y);
assert!(areas.prompt.bottom() <= area.bottom());
}
}
}
#[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)
);
}
}