use crate::tui::{state::MissionControlState, theme::MissionControlTheme};
use ratatui::{
Frame,
layout::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::{Clear, Paragraph},
};
const LOGO: [&str; 5] = [
"█▀▄▀█ ▄▀█ █▀▀ █ █▀▀ █▀█ █▀▄ █▀▀",
"█ ▀ █ █▀█ █ █ █ █ █ █ █ █ █▀▀",
"▀ ▀ ▀ ▀ ▀▀▀ ▀ ▀▀▀ ▀▀▀ ▀▀ ▀▀▀",
"",
"Follow the white rabbit, but take your time.",
];
const SHORTCUTS: [(&str, &str); 10] = [
("[Alt-A]", "Show/hide right panel"),
("[Tab]", "Cycle pane focus"),
("[Alt-P]", "Focus prompt"),
("[Alt-1/2]", "Activity / Summary tab"),
("/skills", "Manage global / project skills"),
("/models", "Manage available models"),
(
"/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) {
if area.is_empty() {
return;
}
let theme = state.theme;
let width = area.width.saturating_sub(2).clamp(1, 76);
let compact = width < 44 || area.height < 26;
let accent = Style::default().fg(theme.text_accent());
let command = Style::default()
.fg(theme.text_command())
.add_modifier(Modifier::BOLD);
let mut lines = Vec::new();
if compact {
lines.push(logo_line("MAGI CODE", state).centered());
} else {
for (index, row) in LOGO.into_iter().enumerate() {
lines.push(if index < 3 {
logo_line(row, state).centered()
} else {
Line::styled(row, 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 || index == 4 {
if index == 4 {
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)),
]);
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));
}
}
}
lines.push(Line::default());
lines.push(Line::styled("Type a prompt below to begin.", theme.muted(false)).centered());
let height = u16::try_from(lines.len())
.unwrap_or(u16::MAX)
.min(area.height);
let content = Rect::new(
area.x + area.width.saturating_sub(width) / 2,
area.y + area.height.saturating_sub(height) / 2,
width,
height,
);
if state.welcome_animation_active() {
draw_rain(frame, area, theme, state.welcome_animation_phase);
}
frame.render_widget(Clear, content);
frame.render_widget(Paragraph::new(lines).style(theme.pane()), content);
}
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)
}
}
fn draw_rain(frame: &mut Frame<'_>, area: Rect, theme: MissionControlTheme, phase: usize) {
const GLYPHS: &[u8] = b"01.:|+*";
for column in (0..area.width).step_by(3) {
let seed = usize::from(column).wrapping_mul(2654435761);
let tail_length = 4 + seed % 7;
let cycle = usize::from(area.height) + tail_length + 9;
let head = (phase / (2 + seed % 3)).wrapping_add(seed) % cycle;
for tail in 0..tail_length {
let Some(row) = head
.checked_sub(tail)
.filter(|&row| row < usize::from(area.height))
else {
continue;
};
let color = if tail == 0 {
theme.text_accent()
} else {
theme.text_muted()
};
let glyph = GLYPHS[seed.wrapping_add(row).wrapping_add(phase / 6) % GLYPHS.len()];
frame.buffer_mut()[(area.x + column, area.y + row as u16)]
.set_char(char::from(glyph))
.set_style(Style::default().fg(color).add_modifier(Modifier::DIM));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::{
render::{test_support::find_text, transcript::draw_transcript},
state::MissionControlState,
};
use ratatui::{Terminal, backend::TestBackend};
#[test]
fn empty_transcript_shows_welcome_and_theme_colored_commands() {
let state = MissionControlState::default();
let mut terminal = Terminal::new(TestBackend::new(100, 34)).unwrap();
terminal
.draw(|frame| draw_welcome(frame, frame.area(), &state))
.unwrap();
let buffer = terminal.backend().buffer();
let tagline = find_text(buffer, "Follow the white rabbit, but take your time.").unwrap();
let rabbit = find_text(buffer, r"(\_/)").unwrap();
assert_eq!(rabbit.1, tagline.1 + 2);
for x in tagline.0..tagline.0 + 42 {
assert_eq!(buffer[(x, tagline.1 + 1)].symbol(), " ");
}
let hotkeys = find_text(buffer, "Useful hotkeys").unwrap();
let commands = find_text(buffer, "Useful Commands").unwrap();
let last_hotkey = find_text(buffer, "[Alt-1/2]").unwrap();
assert!(hotkeys.1 < last_hotkey.1);
assert_eq!(commands.1, last_hotkey.1 + 2);
for x in commands.0..commands.0 + 50 {
assert_eq!(buffer[(x, commands.1 - 1)].symbol(), " ");
}
for (command, _) in SHORTCUTS {
let position = find_text(buffer, command).expect(command);
assert_eq!(buffer[position].fg, state.theme.text_command());
}
assert!(find_text(buffer, "Transcript will appear here").is_none());
}
#[test]
fn compact_welcome_keeps_commands_and_yields_to_session_content() {
let mut state = MissionControlState::default();
let mut terminal = Terminal::new(TestBackend::new(40, 23)).unwrap();
terminal
.draw(|frame| draw_transcript(frame, frame.area(), &state))
.unwrap();
let buffer = terminal.backend().buffer();
for text in [
"MAGI CODE",
"/summarize-start",
"/summarize-stop",
"/update",
] {
assert!(find_text(buffer, text).is_some(), "missing {text}");
}
state.transcript = vec!["A new conversation".to_string()].into();
terminal
.draw(|frame| draw_transcript(frame, frame.area(), &state))
.unwrap();
assert!(find_text(terminal.backend().buffer(), "MAGI CODE").is_none());
assert!(find_text(terminal.backend().buffer(), "A new conversation").is_some());
}
#[test]
fn welcome_animates_logo_and_background_without_changing_help() {
let mut state = MissionControlState {
color_enabled: true,
reduced_motion: false,
..Default::default()
};
let mut terminal = Terminal::new(TestBackend::new(100, 34)).unwrap();
terminal
.draw(|frame| draw_welcome(frame, frame.area(), &state))
.unwrap();
let first = terminal.backend().buffer().clone();
state.welcome_animation_phase = 12;
terminal
.draw(|frame| draw_welcome(frame, frame.area(), &state))
.unwrap();
let second = terminal.backend().buffer();
let logo = find_text(&first, LOGO[0]).unwrap();
assert_ne!(first[logo].fg, second[logo].fg);
assert!((0..34).any(|y| first[(0, y)] != second[(0, y)]));
let help = find_text(&first, "/skills").unwrap();
assert_eq!(first[help], second[help]);
for (reduced_motion, color_enabled) in [(true, true), (false, false)] {
state.reduced_motion = reduced_motion;
state.color_enabled = color_enabled;
terminal
.draw(|frame| draw_welcome(frame, frame.area(), &state))
.unwrap();
let still = terminal.backend().buffer().clone();
state.welcome_animation_phase += 1;
terminal
.draw(|frame| draw_welcome(frame, frame.area(), &state))
.unwrap();
assert_eq!(&still, terminal.backend().buffer());
assert!((0..34).all(|y| still[(0, y)].symbol() == " "));
}
}
#[test]
fn welcome_handles_tiny_and_empty_areas() {
for (width, height) in [(0, 0), (1, 1), (8, 3)] {
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| draw_welcome(frame, frame.area(), &MissionControlState::default()))
.unwrap();
}
}
}