magi-code 0.77.1

Repository-aware CLI coding agent for terminal work
Documentation
use super::state::{MissionControlState, RightColumnTab, TuiLayoutMode};
use ratatui::layout::{Constraint, Direction, Flex, Layout, Rect};

pub(crate) const RIGHT_COLUMN_RAIL_WIDTH: u16 = 5;
pub(crate) const MIN_RIGHT_COLUMN_CONTENT_WIDTH: u16 = 20;
const ACTIVITY_RAIL_LABEL: &str = "Activity";
const SUMMARY_RAIL_LABEL: &str = "Summary";
pub(crate) const RIGHT_COLUMN_TAB_VERTICAL_PADDING: u16 = 1;
const RIGHT_COLUMN_RAIL_SEPARATOR_ROWS: u16 = 1;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TuiPane {
    Transcript,
    ActivityTree,
    Detail,
    Summary,
    RightColumnRail,
    Prompt,
    Help,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct TuiLayout {
    pub(super) transcript: Rect,
    pub(super) activity_tree: Option<Rect>,
    pub(super) detail: Option<Rect>,
    pub(super) summary: Option<Rect>,
    pub(super) right_column_rail: Option<Rect>,
    pub(super) prompt: Rect,
    pub(super) help: Option<Rect>,
}

impl TuiLayout {
    pub(crate) fn pane_at(&self, column: u16, row: u16) -> Option<TuiPane> {
        if self.help.is_some_and(|area| contains(area, column, row)) {
            return Some(TuiPane::Help);
        }
        [
            (TuiPane::Transcript, Some(self.transcript)),
            (TuiPane::ActivityTree, self.activity_tree),
            (TuiPane::Detail, self.detail),
            (TuiPane::Summary, self.summary),
            (TuiPane::RightColumnRail, self.right_column_rail),
            (TuiPane::Prompt, Some(self.prompt)),
        ]
        .into_iter()
        .find_map(|(pane, area)| {
            area.filter(|area| contains(*area, column, row))
                .map(|_| pane)
        })
    }

    pub(crate) fn pane_area(&self, pane: TuiPane) -> Option<Rect> {
        match pane {
            TuiPane::Transcript => Some(self.transcript),
            TuiPane::ActivityTree => self.activity_tree,
            TuiPane::Detail => self.detail,
            TuiPane::Summary => self.summary,
            TuiPane::RightColumnRail => self.right_column_rail,
            TuiPane::Prompt => Some(self.prompt),
            TuiPane::Help => self.help,
        }
    }

    pub(crate) fn right_column_tab_at(&self, column: u16, row: u16) -> Option<RightColumnTab> {
        [RightColumnTab::Activity, RightColumnTab::Summary]
            .into_iter()
            .find(|&tab| {
                self.right_column_tab_area(tab)
                    .is_some_and(|area| contains(area, column, row))
            })
    }

    pub(crate) fn right_column_tab_area(&self, tab: RightColumnTab) -> Option<Rect> {
        let rail = self.right_column_rail?;
        let y = match tab {
            RightColumnTab::Activity => rail.y,
            RightColumnTab::Summary => rail
                .y
                .saturating_add(right_column_tab_height(RightColumnTab::Activity))
                .saturating_add(RIGHT_COLUMN_RAIL_SEPARATOR_ROWS),
        };
        Some(Rect::new(
            rail.x,
            y,
            rail.width,
            right_column_tab_height(tab),
        ))
    }
}

pub(crate) fn right_column_tab_label(tab: RightColumnTab) -> &'static str {
    match tab {
        RightColumnTab::Activity => ACTIVITY_RAIL_LABEL,
        RightColumnTab::Summary => SUMMARY_RAIL_LABEL,
    }
}

fn right_column_tab_height(tab: RightColumnTab) -> u16 {
    (right_column_tab_label(tab).chars().count() as u16)
        .saturating_add(RIGHT_COLUMN_TAB_VERTICAL_PADDING.saturating_mul(2))
        .saturating_add(u16::from(tab == RightColumnTab::Summary))
}

fn right_column_rail_height() -> u16 {
    right_column_tab_height(RightColumnTab::Activity)
        .saturating_add(right_column_tab_height(RightColumnTab::Summary))
        .saturating_add(RIGHT_COLUMN_RAIL_SEPARATOR_ROWS)
}

fn right_column_rail_usable(area: Rect) -> bool {
    area.width >= RIGHT_COLUMN_RAIL_WIDTH.saturating_add(MIN_RIGHT_COLUMN_CONTENT_WIDTH)
        && area.height >= right_column_rail_height()
}

fn split_body(area: Rect, constraints: [Constraint; 2]) -> [Rect; 2] {
    Layout::horizontal(constraints).areas(area)
}

fn split_body_with_optional_rail(
    area: Rect,
    constraints: [Constraint; 2],
) -> ([Rect; 2], Option<Rect>) {
    let body = split_body(area, constraints);
    if !right_column_rail_usable(body[1]) {
        return (body, None);
    }

    let content_area = Rect::new(
        area.x,
        area.y,
        area.width.saturating_sub(RIGHT_COLUMN_RAIL_WIDTH),
        area.height,
    );
    let body = split_body(content_area, constraints);
    if body[1].width < MIN_RIGHT_COLUMN_CONTENT_WIDTH {
        return (split_body(area, constraints), None);
    }

    let rail = Rect::new(
        body[1].x.saturating_add(body[1].width),
        area.y,
        RIGHT_COLUMN_RAIL_WIDTH,
        area.height,
    );
    (body, Some(rail))
}

pub(crate) fn layout_for(area: Rect, state: &MissionControlState) -> TuiLayout {
    let prompt_wrap_width = area.width.saturating_sub(5).max(1);
    let prompt_content_rows = state.prompt_visible_rows(prompt_wrap_width);
    let prompt_height = prompt_content_rows.saturating_add(2);
    let root = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Min(8), Constraint::Length(prompt_height)])
        .split(area);
    let (transcript, activity_tree, detail, summary, right_column_rail) =
        if state.activity_column_visible() {
            let body_constraints = match state.layout_mode {
                TuiLayoutMode::TranscriptFocused => {
                    [Constraint::Percentage(60), Constraint::Percentage(40)]
                }
                TuiLayoutMode::ActivityFocused => {
                    [Constraint::Percentage(40), Constraint::Percentage(60)]
                }
            };
            let (body, rail) = split_body_with_optional_rail(root[0], body_constraints);
            let right_content = body[1];
            match state.right_column_tab {
                RightColumnTab::Activity => {
                    let activity = Layout::default()
                        .direction(Direction::Vertical)
                        .constraints([Constraint::Percentage(45), Constraint::Percentage(55)])
                        .split(right_content);
                    (
                        body[0],
                        non_empty(activity[0]),
                        non_empty(activity[1]),
                        None,
                        rail,
                    )
                }
                RightColumnTab::Summary => (body[0], None, None, non_empty(right_content), rail),
            }
        } else {
            (root[0], None, None, None, None)
        };
    TuiLayout {
        transcript,
        activity_tree,
        detail,
        summary,
        right_column_rail,
        prompt: root[1],
        help: state.show_help.then_some(centered_rect(70, 60, area)),
    }
}

fn non_empty(area: Rect) -> Option<Rect> {
    (area.width > 0 && area.height > 0).then_some(area)
}

fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
    let [vertical] = Layout::vertical([Constraint::Percentage(percent_y)])
        .flex(Flex::Center)
        .areas(area);
    let [centered] = Layout::horizontal([Constraint::Percentage(percent_x)])
        .flex(Flex::Center)
        .areas(vertical);
    centered
}

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::*;

    #[test]
    fn prompt_reaches_bottom_and_panels_use_all_remaining_rows() {
        let area = Rect::new(3, 2, 100, 30);
        let mut state = MissionControlState::default();
        for tab in [RightColumnTab::Activity, RightColumnTab::Summary] {
            state.right_column_tab = tab;
            let layout = layout_for(area, &state);
            assert_eq!(layout.prompt.bottom(), area.bottom());
            assert_eq!(layout.transcript.bottom(), layout.prompt.y);
            assert_eq!(layout.transcript.height, area.height - layout.prompt.height);
            assert_eq!(
                layout.pane_at(area.x, area.bottom() - 1),
                Some(TuiPane::Prompt)
            );
            let right_bottom = layout.summary.or(layout.detail).expect("right panel");
            assert_eq!(right_bottom.bottom(), layout.prompt.y);
            assert_eq!(
                layout.right_column_rail.expect("rail").bottom(),
                layout.prompt.y
            );
        }
        state.toggle_activity_column();
        let layout = layout_for(area, &state);
        assert_eq!(layout.prompt.bottom(), area.bottom());
        assert_eq!(layout.transcript.bottom(), layout.prompt.y);
    }

    #[test]
    fn centered_rect_centers_overlay_within_area() {
        let area = Rect::new(0, 0, 100, 30);
        let centered = centered_rect(70, 60, area);

        assert_eq!(centered.width, 70);
        assert_eq!(centered.height, 18);

        let left_margin = centered.x - area.x;
        let right_margin = area.width - left_margin - centered.width;
        let top_margin = centered.y - area.y;
        let bottom_margin = area.height - top_margin - centered.height;

        assert!(left_margin.abs_diff(right_margin) <= 1);
        assert!(top_margin.abs_diff(bottom_margin) <= 1);
    }

    #[test]
    fn layout_body_starts_at_top_without_header_row() {
        let state = MissionControlState::default();
        let layout = layout_for(Rect::new(0, 0, 100, 30), &state);

        assert_eq!(layout.transcript.y, 0);
        assert_eq!(layout.activity_tree.expect("activity tree").y, 0);
        assert_eq!(layout.pane_at(0, 0), Some(TuiPane::Transcript));
    }

    #[test]
    fn selected_activity_detail_gets_taller_activity_pane() {
        let state = MissionControlState::default();
        let layout = layout_for(Rect::new(0, 0, 100, 30), &state);
        let activity_tree = layout.activity_tree.expect("activity tree");
        let detail = layout.detail.expect("detail");

        assert!(detail.height > activity_tree.height);
        assert_eq!(
            activity_tree.y.saturating_add(activity_tree.height),
            detail.y
        );
    }

    #[test]
    fn right_column_rail_requires_minimum_content_and_label_height() {
        let width = RIGHT_COLUMN_RAIL_WIDTH + MIN_RIGHT_COLUMN_CONTENT_WIDTH;
        let height = right_column_rail_height();

        assert!(!right_column_rail_usable(Rect::new(
            0,
            0,
            width - 1,
            height
        )));
        assert!(right_column_rail_usable(Rect::new(0, 0, width, height)));
        assert!(!right_column_rail_usable(Rect::new(
            0,
            0,
            width,
            height - 1
        )));
    }

    #[test]
    fn right_column_rail_falls_back_for_short_zero_and_tiny_layouts() {
        let state = MissionControlState::default();
        for area in [
            Rect::new(0, 0, 0, 0),
            Rect::new(0, 0, 3, 30),
            Rect::new(0, 0, 40, 30),
            Rect::new(0, 0, 100, 14),
        ] {
            assert_eq!(
                layout_for(area, &state).right_column_rail,
                None,
                "rail should be omitted for {area:?}"
            );
        }

        assert!(
            layout_for(Rect::new(0, 0, 100, 30), &state)
                .right_column_rail
                .is_some()
        );
    }

    #[test]
    fn hidden_activity_column_uses_full_body_and_restores_widths() {
        let area = Rect::new(0, 0, 100, 30);
        let mut state = MissionControlState::default();
        state.focus_activity_layout();
        let visible = layout_for(area, &state);
        let visible_transcript_width = visible.transcript.width;
        let visible_activity_width = visible.activity_tree.expect("activity tree").width;

        state.toggle_activity_column();
        let hidden = layout_for(area, &state);
        assert_eq!(
            hidden.transcript,
            Rect::new(0, 0, 100, hidden.transcript.height)
        );
        assert_eq!(hidden.activity_tree, None);
        assert_eq!(hidden.detail, None);
        assert_eq!(hidden.pane_area(TuiPane::ActivityTree), None);
        assert_eq!(hidden.pane_area(TuiPane::Detail), None);
        assert_eq!(hidden.pane_at(99, 4), Some(TuiPane::Transcript));

        state.toggle_activity_column();
        let restored = layout_for(area, &state);
        assert_eq!(restored.transcript.width, visible_transcript_width);
        assert_eq!(
            restored.activity_tree.expect("activity tree").width,
            visible_activity_width
        );
    }

    #[test]
    fn hidden_activity_column_is_safe_for_tiny_terminals() {
        let mut state = MissionControlState::default();
        state.toggle_activity_column();
        for width in 0..=4 {
            for height in 0..=4 {
                let layout = layout_for(Rect::new(0, 0, width, height), &state);
                assert_eq!(layout.activity_tree, None);
                assert_eq!(layout.detail, None);
            }
        }
    }
}