magi-code 0.96.1

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

pub(crate) const RIGHT_COLUMN_RAIL_WIDTH: u16 = 5;
const TRANSCRIPT_RAIL_LABEL: &str = "Transcript";
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) header: Rect,
    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);
        }
        if self.summary.is_some_and(|area| contains(area, column, row)) {
            return Some(TuiPane::Summary);
        }
        [
            (TuiPane::ActivityTree, self.activity_tree),
            (TuiPane::Detail, self.detail),
            (TuiPane::Transcript, Some(self.transcript)),
            (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 header_modal_area(&self, modal: ModalKind) -> Option<Rect> {
        match modal {
            ModalKind::Summary => self.summary,
            ModalKind::ActivityTree => self.activity_tree,
            ModalKind::SessionFiles => self.detail,
            _ => None,
        }
    }

    pub(crate) fn header_modal_at(&self, column: u16, row: u16) -> Option<ModalKind> {
        [
            ModalKind::Summary,
            ModalKind::ActivityTree,
            ModalKind::SessionFiles,
        ]
        .into_iter()
        .find(|&modal| {
            self.header_modal_area(modal)
                .is_some_and(|area| contains(area, column, row))
        })
    }

    pub(crate) fn right_column_tab_at(&self, column: u16, row: u16) -> Option<RightColumnTab> {
        [RightColumnTab::Activity, RightColumnTab::Diff]
            .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?;
        if rail.height < right_column_rail_height() {
            let index = match tab {
                RightColumnTab::Activity => 0,
                RightColumnTab::Diff => 1,
            };
            return (index < rail.height).then(|| Rect::new(rail.x, rail.y + index, rail.width, 1));
        }
        let y = match tab {
            RightColumnTab::Activity => rail.y,
            RightColumnTab::Diff => 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 => TRANSCRIPT_RAIL_LABEL,
        RightColumnTab::Diff => "Changes",
    }
}

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))
}

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

fn right_column_rail_usable(area: Rect) -> bool {
    area.width >= RIGHT_COLUMN_RAIL_WIDTH + 20 && area.height >= 3
}

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(3), Constraint::Length(prompt_height)])
        .split(area);
    let [header, body_area] =
        Layout::vertical([Constraint::Length(3), Constraint::Min(0)]).areas(root[0]);
    let right_column_rail = (!state.transcript_only_layout && right_column_rail_usable(body_area))
        .then(|| {
            Rect::new(
                body_area.right().saturating_sub(RIGHT_COLUMN_RAIL_WIDTH),
                body_area.y,
                RIGHT_COLUMN_RAIL_WIDTH,
                body_area.height,
            )
        });
    let mut transcript = Rect {
        width: body_area
            .width
            .saturating_sub(right_column_rail.map_or(0, |rail| rail.width)),
        ..body_area
    };
    let pinned = state.modals.header_modals_pinned && state.header_modals_allowed_on_tab();
    let mut modal_width = (transcript.width / 3).max(27).min(transcript.width);
    if pinned {
        modal_width = modal_width.min(transcript.width / 2);
    }
    let modal = Rect::new(
        transcript.right().saturating_sub(modal_width),
        transcript.y,
        modal_width,
        transcript.height,
    );
    if pinned {
        transcript.width = transcript.width.saturating_sub(modal_width);
    }
    let mut layout = TuiLayout {
        header,
        transcript,
        activity_tree: None,
        detail: None,
        summary: None,
        right_column_rail,
        prompt: root[1],
        help: state
            .show_help
            .then_some(super::modal_container::modal_area(area, 60, 60)),
    };
    if state.header_modals_allowed_on_tab() && !state.modals.header_modals.is_empty() {
        let count = state.modals.header_modals.len() as u16;
        let mut y = modal.y;
        for (index, kind) in state.modals.header_modals.iter().enumerate() {
            let height = modal.height / count + u16::from((index as u16) < modal.height % count);
            let area = non_empty(Rect::new(modal.x, y, modal.width, height));
            match kind {
                ModalKind::Summary => layout.summary = area,
                ModalKind::ActivityTree => layout.activity_tree = area,
                ModalKind::SessionFiles => layout.detail = area,
                _ => {}
            }
            y = y.saturating_add(height);
        }
    }
    layout
}

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

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)
}