magi-code 0.96.1

Repository-aware CLI coding agent for terminal work
Documentation
use crate::tui::state::{MissionControlState, WelcomeRenderCache};
use ratatui::{
    Frame,
    layout::Rect,
    style::{Modifier, Style},
    text::{Line, Span},
    widgets::{Block, BorderType, Borders, Clear, Paragraph, Wrap},
};

const LOGO: [&str; 6] = [
    "███╗   ███╗  █████╗   ██████╗  ██╗      ██████╗  ██████╗  ██████╗  ███████╗",
    "████╗ ████║ ██╔══██╗ ██╔════╝  ██║     ██╔════╝ ██╔═══██╗ ██╔══██╗ ██╔════╝",
    "██╔████╔██║ ███████║ ██║  ███╗ ██║ ███╗██║      ██║   ██║ ██║  ██║ █████╗",
    "██║╚██╔╝██║ ██╔══██║ ██║   ██║ ██║ ╚══╝██║      ██║   ██║ ██║  ██║ ██╔══╝",
    "██║ ╚═╝ ██║ ██║  ██║ ╚██████╔╝ ██║     ╚██████╗ ╚██████╔╝ ██████╔╝ ███████╗",
    "╚═╝     ╚═╝ ╚═╝  ╚═╝  ╚═════╝  ╚═╝      ╚═════╝  ╚═════╝  ╚═════╝  ╚══════╝",
];
const LOGO_WIDTH: u16 = 75;
const FULL_WELCOME_MIN_HEIGHT: u16 = 34;
const SIDE_WELCOME: &str = "This session has no context from the main session. It keeps running in the background when closed. Use it for side tasks that don't need the main conversation.";
const SHORTCUTS: [(&str, &str); 13] = [
    ("[Tab]", "Next pane"),
    ("[Alt-P]", "Cycle primary agent"),
    ("[Shift-Tab]", "Transcript / Changes tab"),
    ("[Alt-1]", "Show/hide Activity Tree"),
    ("[Alt-2]", "Show/hide Session Files"),
    ("[Alt-3]", "Show/hide Summary"),
    ("/skills", "Manage global / project skills"),
    ("/settings", "Edit settings / model availability"),
    ("/side", "Open side-chat agent"),
    (
        "/summarize-start | /summarize-stop",
        "Start / stop Summary agent",
    ),
    ("/fast", "Toggle Fast mode where supported"),
    ("/compact", "Compact current session"),
    ("/update", "Update Magi-Code"),
];

pub(super) fn draw_welcome(frame: &mut Frame<'_>, area: Rect, state: &MissionControlState) {
    state.release_notes_area.set(Rect::default());
    let Some(notes) = state
        .release_notes
        .filter(|_| !state.transcript_only_layout)
    else {
        draw_welcome_body(frame, area, state);
        return;
    };
    let container = Rect::new(
        area.x + 1,
        area.y + 4,
        area.width.saturating_sub(2),
        area.height.saturating_sub(5),
    );
    let block = Block::bordered()
        .border_type(BorderType::Rounded)
        .border_style(state.theme.muted(false))
        .title_bottom(if state.update_restart_welcome {
            " Esc: conversation · Submit: continue · Alt+↑↓: scroll "
        } else {
            " Mouse wheel / Alt+↑↓ "
        });
    let content = block.inner(container);
    let width = content.width;
    let notes_height = content.height;
    if width < 12 || notes_height == 0 {
        state.release_notes_viewport.set((0, 0));
        return;
    }
    let text = format!(
        "What's new in {}\n\n{}",
        env!("CARGO_PKG_VERSION"),
        notes.trim()
    );
    // Leave a small welcome mark above the full-width scroll container.
    let viewport = (width, notes_height);
    if state.release_notes_viewport.replace(viewport) != viewport {
        state.release_notes_offset.set(0);
        state.release_notes_viewed.set(0);
    }
    let offset = state.release_notes_offset.get();
    let (paragraph, rows) = release_notes_page(&text, width, notes_height, offset);
    state.release_notes_total.set(rows);
    state
        .release_notes_page_end
        .set((offset + usize::from(notes_height)).min(rows));
    frame.render_widget(
        Paragraph::new(vec![
            Line::from(r"(\_/)"),
            Line::from(r"( •_•)"),
            Line::from(r"/ >_ "),
        ])
        .centered()
        .style(state.theme.muted(false)),
        Rect::new(area.x, area.y, area.width, 3),
    );
    frame.render_widget(block, container);
    frame.render_widget(paragraph.style(state.theme.pane()), content);
    super::super::render_scrollbar_with_theme(
        frame,
        Rect::new(container.x, content.y, container.width, content.height),
        rows.saturating_sub(usize::from(notes_height)) + 1,
        offset,
        usize::from(notes_height),
        state.theme,
    );
    state.release_notes_area.set(container);
    state.release_notes_drawn.set(true);
}

fn release_notes_page(
    text: &str,
    width: u16,
    height: u16,
    offset: usize,
) -> (Paragraph<'_>, usize) {
    let mut rows = 0usize;
    let mut page = Vec::new();
    let mut skipped_rows = 0;
    let page_end = offset.saturating_add(usize::from(height));
    for line in text.lines() {
        let line_rows = Paragraph::new(line)
            .wrap(Wrap { trim: false })
            .line_count(width);
        let end = rows.saturating_add(line_rows);
        if end > offset && rows < page_end {
            if page.is_empty() {
                // Each bundled file is capped at 16 KiB, so scrolling within
                // one logical line fits u16 even when the bundle does not.
                skipped_rows = u16::try_from(offset.saturating_sub(rows))
                    .expect("release-note line fits the per-file byte limit");
            }
            page.push(Line::from(line));
        }
        rows = end;
    }
    (
        Paragraph::new(page)
            .wrap(Wrap { trim: false })
            .scroll((skipped_rows, 0)),
        rows,
    )
}

fn draw_welcome_body(frame: &mut Frame<'_>, area: Rect, state: &MissionControlState) {
    if area.is_empty() {
        return;
    }
    let theme = state.theme;
    let width = area.width.saturating_sub(2).clamp(1, LOGO_WIDTH);
    let key = (theme, width, area.height);
    let logo_phase = state
        .welcome_animation_active()
        .then_some(state.welcome_animation_phase);
    let mut cached = state.welcome_render_cache.borrow_mut();
    if cached.as_ref().is_none_or(|cache| {
        cache.key != key || cache.update_version != state.available_update_version
    }) {
        let (update_notice, update_height) = welcome_update_notice(width, state);
        let welcome_height = area.height.saturating_sub(update_height);
        let compact = if state.transcript_only_layout {
            let body_height = Paragraph::new(SIDE_WELCOME)
                .wrap(Wrap { trim: true })
                .line_count(width);
            width < LOGO_WIDTH || usize::from(welcome_height) < LOGO.len() + 1 + body_height
        } else {
            width < LOGO_WIDTH || welcome_height < FULL_WELCOME_MIN_HEIGHT
        };
        let mut lines = welcome_lines(width, compact, welcome_height, state);
        let height = if state.transcript_only_layout {
            Paragraph::new(lines.clone())
                .wrap(Wrap { trim: true })
                .line_count(width) as u16
        } else {
            lines.len() as u16
        }
        .min(welcome_height);
        let logo_height = if compact { 1 } else { LOGO.len() as u16 };
        // Short update layouts can remove the logo to retain the invitation.
        let logo_height = if update_notice.is_some() {
            logo_height.min(welcome_height.saturating_sub(2))
        } else {
            logo_height
        };
        let body = lines.split_off(usize::from(logo_height));
        *cached = Some(WelcomeRenderCache {
            key,
            update_version: state.available_update_version.clone(),
            update_notice,
            update_height,
            compact,
            logo_phase,
            logo: Paragraph::new(lines).style(theme.pane()),
            body: if state.transcript_only_layout {
                Paragraph::new(body)
                    .style(theme.pane())
                    .wrap(Wrap { trim: true })
            } else {
                Paragraph::new(body).style(theme.pane())
            },
            height,
            logo_height,
        });
    }
    let cache = cached.as_mut().expect("welcome layout cached");
    if cache.logo_phase != logo_phase {
        cache.logo = Paragraph::new(welcome_logo_lines(cache.compact, state)).style(theme.pane());
        cache.logo_phase = logo_phase;
    }
    let height = cache.height.min(area.height);
    let content = Rect::new(
        area.x + area.width.saturating_sub(width) / 2,
        area.y
            + area
                .height
                .saturating_sub(height.saturating_add(cache.update_height))
                / 2,
        width,
        height,
    );
    // Clear retains the exact opaque background and resets underlying styles.
    frame.render_widget(Clear, content);
    let logo_height = cache.logo_height.min(height);
    frame.render_widget(
        &cache.logo,
        Rect::new(content.x, content.y, width, logo_height),
    );
    frame.render_widget(
        &cache.body,
        Rect::new(
            content.x,
            content.y + logo_height,
            width,
            height - logo_height,
        ),
    );
    if let Some(notice) = &cache.update_notice {
        let notice_area = Rect::new(
            content.x,
            content.bottom().saturating_add(1).min(area.bottom()),
            width,
            cache.update_height.saturating_sub(1).min(
                area.bottom()
                    .saturating_sub(content.bottom().saturating_add(1)),
            ),
        );
        frame.render_widget(Clear, notice_area);
        frame.render_widget(notice, notice_area);
    }
}

fn welcome_logo_lines(compact: bool, state: &MissionControlState) -> Vec<Line<'static>> {
    if compact {
        vec![logo_line("MAGI CODE", state).centered()]
    } else {
        LOGO.iter()
            .map(|row| {
                // Center the artwork as one block, keeping its short rows aligned.
                let padded = format!("{row:<width$}", width = usize::from(LOGO_WIDTH));
                logo_line(&padded, state).centered()
            })
            .collect()
    }
}

fn welcome_update_notice(
    width: u16,
    state: &MissionControlState,
) -> (Option<Paragraph<'static>>, u16) {
    if state.transcript_only_layout {
        return (None, 0);
    }
    let theme = state.theme;
    let update_lines = state.available_update_version.as_ref().map(|version| {
        vec![
            Line::styled(
                format!("magi-code {version} is available."),
                theme.muted(false),
            ),
            Line::from(vec![
                Span::styled(
                    "/update",
                    Style::default()
                        .fg(theme.text_command())
                        .add_modifier(Modifier::BOLD),
                ),
                Span::styled(" to install and reopen this session.", theme.muted(false)),
            ]),
        ]
    });
    let update_height = update_lines.as_ref().map_or(0, |lines| {
        let inner_width = usize::from(width.saturating_sub(2).max(1));
        let rows: usize = lines
            .iter()
            .map(|line| line.width().div_ceil(inner_width))
            .sum();
        u16::try_from(rows).unwrap_or(u16::MAX).saturating_add(3)
    });
    let notice = update_lines.map(|lines| {
        Paragraph::new(lines)
            .style(theme.pane())
            .centered()
            .wrap(Wrap { trim: true })
            .block(
                Block::default()
                    .borders(Borders::ALL)
                    .border_type(BorderType::Rounded)
                    .border_style(Style::default().fg(theme.text_accent())),
            )
    });
    (notice, update_height)
}

fn welcome_lines(
    width: u16,
    compact: bool,
    welcome_height: u16,
    state: &MissionControlState,
) -> Vec<Line<'static>> {
    if state.transcript_only_layout {
        let mut lines = welcome_logo_lines(compact, state);
        lines.push(Line::default());
        lines.push(Line::styled(SIDE_WELCOME, state.theme.muted(false)).centered());
        return lines;
    }
    let theme = state.theme;
    let accent = Style::default().fg(theme.text_accent());
    let command = Style::default()
        .fg(theme.text_command())
        .add_modifier(Modifier::BOLD);
    let mut lines = welcome_logo_lines(compact, state);
    if !compact {
        lines.push(Line::default());
        lines.push(Line::styled("Follow the white rabbit, but take your time.", accent).centered());
        lines.push(Line::default());
        for row in [r"(\_/)", r"( •_•)", r"/ >_ "] {
            lines.push(Line::styled(row, theme.muted(false)).centered());
        }
    }
    lines.push(Line::default());
    for (index, (key, description)) in SHORTCUTS.into_iter().enumerate() {
        if index == 0 || key == "/skills" {
            if key == "/skills" {
                lines.push(Line::default());
            }
            let heading = if index == 0 {
                "Useful hotkeys"
            } else {
                "Useful Commands"
            };
            lines.push(Line::styled(heading, accent.add_modifier(Modifier::BOLD)));
        }
        let full = Line::from(vec![
            Span::styled(key, command),
            Span::styled(format!("  {description}"), theme.muted(false)),
        ]);
        // Keep commands intact on narrow panes; descriptions are optional help.
        if full.width() <= usize::from(width) {
            lines.push(full);
        } else if key.len() <= usize::from(width) {
            lines.push(Line::styled(key, command));
        } else {
            for part in key.split(" | ") {
                lines.push(Line::styled(part, command));
            }
        }
    }
    if state.available_update_version.is_some() {
        // On short panes, keep the invitation and update visible before extra help.
        lines.truncate(usize::from(welcome_height.saturating_sub(2)));
    }
    lines.push(Line::default());
    lines.push(Line::styled("Type a prompt below to begin.", theme.muted(false)).centered());
    lines
}

fn logo_line(text: &str, state: &MissionControlState) -> Line<'static> {
    let style = Style::default()
        .fg(state.theme.text_accent())
        .add_modifier(Modifier::BOLD);
    if state.welcome_animation_active() {
        Line::from(super::super::prompt::fast_mode_rainbow_spans(
            text,
            state.welcome_animation_phase,
            style,
            state.theme,
        ))
    } else {
        Line::styled(text.to_owned(), style)
    }
}