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; 10] = [
"███╗ ███╗",
"████╗ ████║",
"██╔██╗ ██╔██║ ███╗ ███╗ █████╗ ██████╗ ██╗ ██████╗ ██████╗ ██████╗ ███████╗",
"██║╚██╗ ██╔╝██║ ████╗ ████║ ██╔══██╗ ██╔════╝ ██║ ██╔════╝ ██╔═══██╗ ██╔══██╗ ██╔════╝",
"██║ ╚████╔╝ ██║ ██╔████╔██║ ███████║ ██║ ███╗ ██║ ███╗██║ ██║ ██║ ██║ ██║ █████╗",
"██║ ╚██╔╝ ██║ ██║╚██╔╝██║ ██╔══██║ ██║ ██║ ██║ ╚══╝██║ ██║ ██║ ██║ ██║ ██╔══╝",
"██║ ╚═╝ ██║ ██║ ╚═╝ ██║ ██║ ██║ ╚██████╔╝ ██║ ╚██████╗ ╚██████╔╝ ██████╔╝ ███████╗",
"██║ ██║ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝",
"██║ ██║",
"╚═╝ ╚═╝",
];
const LOGO_WIDTH: u16 = 96;
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); 11] = [
("[Alt-A]", "Show/hide right panel"),
("[Tab/Shift-Tab]", "Next / previous pane"),
("[Alt-P]", "Cycle primary agent"),
("[Alt-1/2]", "Activity / Summary tab"),
("/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(" 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()
);
let paragraph = Paragraph::new(text)
.style(state.theme.pane())
.wrap(Wrap { trim: false });
let rows = paragraph.line_count(width);
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();
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.scroll((offset as u16, 0)), 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 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 < 15 || 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 };
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,
);
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 state.transcript_only_layout {
return if compact {
vec![logo_line("M", state).centered()]
} else {
LOGO.iter()
.map(|row| logo_line(&row.chars().take(15).collect::<String>(), state).centered())
.collect()
};
}
if compact {
vec![logo_line("MAGI CODE", state).centered()]
} else {
LOGO.iter()
.map(|row| {
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 || 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));
}
}
}
if state.available_update_version.is_some() {
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)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::{
render::{test_support::find_text, transcript::draw_transcript},
state::MissionControlState,
theme::MissionControlTheme,
};
use ratatui::{Terminal, backend::TestBackend};
#[test]
fn release_notes_scroll_to_end_and_resize_restarts_unseen_progress() {
use crate::tui::release_notes::{confirm_draw, scroll_key};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let state = MissionControlState {
release_notes: Some(
"First fixture line\nSecond fixture line\nThird fixture line\nFourth fixture line\nLast fixture line",
),
..Default::default()
};
let mut terminal = Terminal::new(TestBackend::new(30, 11)).unwrap();
let next = KeyEvent::new(KeyCode::Down, KeyModifiers::ALT);
terminal
.draw(|frame| draw_welcome(frame, frame.area(), &state))
.unwrap();
let buffer = terminal.backend().buffer();
let (_, rabbit_y) = find_text(buffer, r"(\_/)").unwrap();
let (_, notes_y) = find_text(buffer, "What's new").unwrap();
assert!(rabbit_y < notes_y);
scroll_key(next, &state);
assert_eq!(
state.release_notes_offset.get(),
0,
"cannot skip an unconfirmed page"
);
confirm_draw(&state);
assert!(!state.release_notes_displayed.get());
scroll_key(next, &state);
let mut resized = Terminal::new(TestBackend::new(25, 11)).unwrap();
resized
.draw(|frame| draw_welcome(frame, frame.area(), &state))
.unwrap();
assert_eq!(state.release_notes_offset.get(), 0);
assert_eq!(state.release_notes_viewed.get(), 0);
assert_eq!(state.prompt_editor.text(), "");
confirm_draw(&state);
for _ in 0..10 {
if state.release_notes_displayed.get() {
break;
}
scroll_key(next, &state);
resized
.draw(|frame| draw_welcome(frame, frame.area(), &state))
.unwrap();
confirm_draw(&state);
}
assert!(state.release_notes_displayed.get());
assert!(find_text(resized.backend().buffer(), "Last fixture line").is_some());
let end_offset = state.release_notes_offset.get();
scroll_key(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT), &state);
assert_eq!(state.release_notes_offset.get(), end_offset - 1);
}
#[test]
fn release_notes_diff_page_does_not_page_or_acknowledge_hidden_notes() {
use crate::tui::{
release_notes::{confirm_draw, scroll_key},
state::RightColumnTab,
};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut state = MissionControlState {
release_notes: Some("Approved improvement"),
..Default::default()
};
let mut terminal = Terminal::new(TestBackend::new(120, 60)).unwrap();
terminal
.draw(|frame| crate::tui::render::draw(frame, &state))
.unwrap();
assert!(state.release_notes_drawn.get());
state.right_column_tab = RightColumnTab::Diff;
for code in [KeyCode::Up, KeyCode::Down] {
assert!(!scroll_key(KeyEvent::new(code, KeyModifiers::ALT), &state));
}
terminal
.draw(|frame| crate::tui::render::draw(frame, &state))
.unwrap();
confirm_draw(&state);
assert!(find_text(terminal.backend().buffer(), "Approved improvement").is_none());
assert!(!state.release_notes_drawn.get());
assert!(!state.release_notes_displayed.get());
assert_eq!(state.release_notes_viewed.get(), 0);
assert_eq!(state.release_notes_offset.get(), 0);
state.right_column_tab = RightColumnTab::Activity;
terminal
.draw(|frame| crate::tui::render::draw(frame, &state))
.unwrap();
confirm_draw(&state);
assert!(find_text(terminal.backend().buffer(), "Approved improvement").is_some());
assert!(state.release_notes_displayed.get());
}
#[test]
fn release_notes_require_a_complete_uncovered_welcome() {
let state = MissionControlState {
release_notes: Some("- Approved improvement\n- Approved fix"),
..Default::default()
};
let mut terminal = Terminal::new(TestBackend::new(100, 40)).unwrap();
terminal
.draw(|frame| draw_welcome(frame, frame.area(), &state))
.unwrap();
assert!(state.release_notes_drawn.get());
assert!(find_text(terminal.backend().buffer(), "Approved fix").is_some());
assert!(!state.release_notes_displayed.get());
for hidden in ["/settings", "[Alt-A]", "Type a prompt below", "PgUp/PgDn"] {
assert!(find_text(terminal.backend().buffer(), hidden).is_none());
}
assert_eq!(state.release_notes_area.get(), Rect::new(1, 4, 98, 35));
assert_eq!(state.release_notes_viewport.get(), (96, 33));
state.release_notes_drawn.set(false);
let mut small = Terminal::new(TestBackend::new(20, 5)).unwrap();
small
.draw(|frame| draw_welcome(frame, frame.area(), &state))
.unwrap();
assert!(!state.release_notes_drawn.get());
assert!(find_text(small.backend().buffer(), "Approved fix").is_none());
}
#[test]
fn release_notes_are_not_acknowledged_under_an_overlay_or_in_side_welcome() {
let mut state = MissionControlState {
release_notes: Some("Approved improvement"),
transcript_only_layout: true,
..Default::default()
};
let mut terminal = Terminal::new(TestBackend::new(120, 60)).unwrap();
terminal
.draw(|frame| draw_welcome(frame, frame.area(), &state))
.unwrap();
assert!(!state.release_notes_drawn.get());
assert!(find_text(terminal.backend().buffer(), "Approved improvement").is_none());
state.transcript_only_layout = false;
state.toast = Some(crate::tui::toast::ToastState::new(
"Notice",
crate::tui::toast::ToastSeverity::Success,
std::time::Instant::now(),
));
terminal
.draw(|frame| crate::tui::render::draw(frame, &state))
.unwrap();
assert!(!state.release_notes_drawn.get());
state.toast = None;
state.conversation_modal = Some(Box::new(
crate::tui::render::transcript_modal::TranscriptModal::new(
MissionControlState::default(),
),
));
terminal
.draw(|frame| crate::tui::render::draw(frame, &state))
.unwrap();
crate::tui::release_notes::confirm_draw(&state);
assert!(!state.release_notes_drawn.get());
assert!(!state.release_notes_displayed.get());
}
#[test]
fn side_welcome_keeps_mark_and_explanation_without_help_wordmark_or_animation() {
for (width, height, mark) in [(100, 40, LOGO[0]), (46, 9, "M")] {
let mut state = MissionControlState {
transcript_only_layout: true,
color_enabled: true,
reduced_motion: false,
available_update_version: Some("99.0.0".into()),
..Default::default()
};
assert!(!state.welcome_animation_active());
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| draw_welcome(frame, frame.area(), &state))
.unwrap();
let first = terminal.backend().buffer().clone();
assert!(find_text(&first, mark).is_some());
let text = (0..height)
.map(|y| {
(0..width)
.map(|x| first[(x, y)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>()
.join(" ");
let normalized = text.split_whitespace().collect::<Vec<_>>().join(" ");
assert!(normalized.contains(SIDE_WELCOME), "{normalized}");
for absent in [
"MAGI CODE",
"Useful",
"/settings",
"[Alt-A]",
"is available.",
"██╔══██╗",
] {
assert!(find_text(&first, absent).is_none(), "unexpected {absent}");
}
state.welcome_animation_phase = 19;
terminal
.draw(|frame| draw_welcome(frame, frame.area(), &state))
.unwrap();
assert_eq!(&first, terminal.backend().buffer());
state.color_enabled = false;
terminal
.draw(|frame| draw_welcome(frame, frame.area(), &state))
.unwrap();
assert_eq!(&first, terminal.backend().buffer());
}
}
#[test]
fn primary_welcome_still_has_wordmark_help_and_animation() {
let state = MissionControlState {
color_enabled: true,
reduced_motion: false,
..Default::default()
};
assert!(state.welcome_animation_active());
for (width, height, mark) in [(100, 40, LOGO[2]), (80, 30, "MAGI CODE")] {
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| draw_welcome(frame, frame.area(), &state))
.unwrap();
let buffer = terminal.backend().buffer();
for text in [
mark,
"Useful hotkeys",
"Useful Commands",
"/settings",
"/side Open side-chat agent",
] {
assert!(find_text(buffer, text).is_some(), "missing {text}");
}
assert!(find_text(buffer, "This session has no context").is_none());
}
}
fn draw_fresh_welcome(frame: &mut Frame<'_>, area: Rect, state: &MissionControlState) {
if area.is_empty() {
return;
}
let width = area.width.saturating_sub(2).clamp(1, LOGO_WIDTH);
let lines = welcome_lines(
width,
width < LOGO_WIDTH || area.height < FULL_WELCOME_MIN_HEIGHT,
area.height,
state,
);
let height = (lines.len() as u16).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,
);
frame.render_widget(Clear, content);
frame.render_widget(Paragraph::new(lines).style(state.theme.pane()), content);
}
#[test]
fn welcome_cache_updates_notice_when_added_changed_removed_or_resized() {
let mut state = MissionControlState::default();
let mut terminal = Terminal::new(TestBackend::new(100, 40)).unwrap();
let mut fresh = Terminal::new(TestBackend::new(100, 40)).unwrap();
for (version, width, height) in [
(None, 100, 40),
(Some("99.0.0"), 100, 40),
(Some("99.1.0"), 100, 40),
(Some("99.1.0"), 80, 30),
(Some("99.1.0"), 40, 28),
(Some("99.1.0"), 40, 23),
(Some("99.1.0"), 1, 1),
(Some("99.1.0"), 100, 6),
(Some("99.1.0"), 100, 7),
(None, 100, 40),
(Some("99.2.0"), 100, 40),
] {
state.available_update_version = version.map(str::to_owned);
let area = Rect::new(0, 0, width, height);
for phase in [0, 1, 2] {
state.welcome_animation_phase = phase;
terminal
.draw(|frame| draw_welcome(frame, area, &state))
.unwrap();
let fresh_state = MissionControlState {
available_update_version: version.map(str::to_owned),
welcome_animation_phase: phase,
..Default::default()
};
fresh
.draw(|frame| draw_welcome(frame, area, &fresh_state))
.unwrap();
let buffer = terminal.backend().buffer();
assert_eq!(
buffer,
fresh.backend().buffer(),
"version={version:?}, area={area:?}"
);
if width >= 40 && height >= 23 {
if let Some(version) = version {
let notice =
find_text(buffer, &format!("magi-code {version} is available."))
.unwrap();
let invitation =
find_text(buffer, "Type a prompt below to begin.").unwrap();
assert!(notice.1 > invitation.1);
} else {
assert!(find_text(buffer, "is available.").is_none());
assert!(find_text(buffer, LOGO[0]).is_some());
}
}
}
}
}
#[test]
fn welcome_cache_matches_fresh_render_across_visual_changes() {
use crate::appearance::{ColorDepth, RuntimeAppearance};
let mut state = MissionControlState::default();
let mut cached = Terminal::new(TestBackend::new(110, 44)).unwrap();
let mut fresh = Terminal::new(TestBackend::new(110, 44)).unwrap();
for (depth, custom_theme) in [
(ColorDepth::TrueColor, false),
(ColorDepth::TrueColor, true),
(ColorDepth::Ansi256, true),
(ColorDepth::None, false),
] {
let mut appearance = RuntimeAppearance::default();
appearance.policy.depth = depth;
if custom_theme {
let mut theme = (*appearance.theme).clone();
theme.roles.surface_background = opaline::OpalineColor::new(12, 34, 56);
theme.roles.standard_styles.dimmed = opaline::OpalineStyle::new()
.with_fg(opaline::OpalineColor::new(210, 180, 150))
.with_bg(opaline::OpalineColor::new(60, 40, 20));
theme.roles.standard_styles.dimmed.italic = true;
appearance = appearance.with_resolved_theme(theme);
}
state.theme = MissionControlTheme::from_runtime_appearance(&appearance, 1);
for (width, height) in [
(100, 34),
(97, 33),
(98, 32),
(98, 33),
(78, 26),
(45, 26),
(46, 25),
(40, 23),
(1, 1),
(8, 3),
(100, 34),
] {
for (reduced_motion, color_enabled) in
[(false, true), (true, true), (false, false), (false, true)]
{
state.reduced_motion = reduced_motion;
state.color_enabled = color_enabled;
for phase in [0, 0, 12, 12, usize::MAX, 0] {
state.welcome_animation_phase = phase;
for (x, y) in [(0, 0), (3, 2)] {
let area = Rect::new(x, y, width, height);
cached
.draw(|frame| draw_welcome(frame, area, &state))
.unwrap();
fresh
.draw(|frame| draw_fresh_welcome(frame, area, &state))
.unwrap();
assert_eq!(
cached.backend().buffer(),
fresh.backend().buffer(),
"depth={depth:?}, area={area:?}, phase={phase}, reduced={reduced_motion}, color={color_enabled}"
);
}
}
}
}
}
}
#[test]
fn welcome_animation_changes_only_logo_colors_not_background_text() {
for (width, height) in [(100, 40), (44, 24)] {
let mut state = MissionControlState {
color_enabled: true,
reduced_motion: true,
..Default::default()
};
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| draw_welcome(frame, frame.area(), &state))
.unwrap();
let static_welcome = terminal.backend().buffer().clone();
state.reduced_motion = false;
for phase in 0..40 {
state.welcome_animation_phase = phase;
terminal
.draw(|frame| draw_welcome(frame, frame.area(), &state))
.unwrap();
for y in 0..height {
for x in 0..width {
assert_eq!(
terminal.backend().buffer()[(x, y)].symbol(),
static_welcome[(x, y)].symbol(),
"welcome text changed at ({x}, {y}), phase={phase}"
);
}
}
}
}
}
#[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 logo = find_text(buffer, LOGO[0]).unwrap();
for (row_index, row) in LOGO.iter().enumerate() {
for (column, character) in row.chars().enumerate() {
assert_eq!(
buffer[(logo.0 + column as u16, logo.1 + row_index as u16)].symbol(),
character.to_string(),
"artwork row {row_index}, column {column}"
);
}
}
assert_eq!(tagline.1, logo.1 + LOGO.len() as u16 + 1);
assert!(find_text(buffer, "Type a prompt below to begin.").is_some());
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 update_notice_is_below_welcome_and_not_in_conversation() {
let mut state = MissionControlState {
available_update_version: Some("99.0.0".into()),
..Default::default()
};
for (width, height) in [(100, 40), (80, 30), (40, 28)] {
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| draw_welcome(frame, frame.area(), &state))
.unwrap();
let buffer = terminal.backend().buffer();
let prompt = find_text(buffer, "Type a prompt below to begin.").unwrap();
let notice = find_text(buffer, "magi-code 99.0.0 is available.").unwrap();
assert!(notice.1 > prompt.1);
assert_eq!(
buffer[(notice.0, notice.1 - 1)].fg,
state.theme.text_accent()
);
}
assert!(state.transcript.is_empty());
state.transcript = vec!["Conversation content".to_string()].into();
let mut terminal = Terminal::new(TestBackend::new(100, 40)).unwrap();
terminal
.draw(|frame| draw_transcript(frame, frame.area(), &state))
.unwrap();
assert!(find_text(terminal.backend().buffer(), "99.0.0").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_without_changing_background_or_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);
let bottom = (logo.0, logo.1 + LOGO.len() as u16 - 1);
assert_ne!(first[bottom].fg, second[bottom].fg);
assert!((0..34).all(|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();
}
}
}