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()
);
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() {
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 };
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 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 || 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)),
]);
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)
}
}