magi-code 0.77.1

Repository-aware CLI coding agent for terminal work
Documentation
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)
        );
    }
}