//! Welcome screen — the first thing users see. //! //! Layout (top to bottom): //! - Top margin row (always preserved) //! - Top bar: repo_root:branch (left), version (right) //! - Vertically centered content: logo → gap → menu → gap → prompt //! - Bottom margin use ratatui::buffer::Buffer; use ratatui::layout::{Alignment, Constraint, Flex, Layout, Position, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Padding, Paragraph, Widget, Wrap}; use crate::app::app_view::{AuthMode, AuthState, SessionPickerEntry, TrustState}; use crate::startup::StartupWarning; use crate::theme::Theme; use crate::views::prompt_widget::{PromptFlag, PromptInfo, PromptWidget}; mod hero_box; pub(crate) mod logo; mod menu; mod prompt; mod top_bar; pub(crate) use logo::shimmer_frame; use logo::{logo_line_count, render_logo}; use menu::render_menu; pub(crate) use top_bar::location_line_at; use top_bar::render_top_bar; /// True for VS Code and xterm.js embeds (VS Code-family IDEs and Zed) where /// quit is `Ctrl+D` (canonical: [`TerminalName::is_vscode_family`]). fn welcome_in_vscode_family() -> bool { crate::terminal::terminal_context().brand.is_vscode_family() } /// Build the quit hint spans used in Authenticating sub-screens. fn quit_hint_spans(theme: &Theme) -> Vec> { let key = if welcome_in_vscode_family() { "ctrl+d" } else { "ctrl+q" }; vec![ Span::styled( key, Style::default() .fg(theme.accent_user) .add_modifier(Modifier::BOLD), ), Span::styled(" quit", Style::default().fg(theme.gray)), ] } /// Style for a clickable welcome block: bright primary while `hovered`, else /// `base`. Shared by the announcement and changelog renderers. pub(super) fn hover_style(theme: &Theme, hovered: bool, base: Style) -> Style { if hovered { Style::default().fg(theme.text_primary) } else { base } } /// Horizontal margin (left and right) in normal mode. const H_MARGIN: u16 = 2; /// Horizontal margin in compact mode. const H_MARGIN_COMPACT: u16 = 1; /// Minimum width for menu + changelog sections so they don't resize when the import row toggles. /// Derivation: "[ " (2) + import-claude label (22) + gap (4) + "ctrl+i [x]" (11) + " ]" (2) = 41. /// Bumped to 51 for comfortable breathing room. const MENU_MIN_WIDTH: u16 = 51; /// Whether the welcome prompt is currently focused (accepting text input). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum WelcomePromptFocus { #[default] Unfocused, Focused, } /// Result of rendering the welcome screen. #[derive(Default)] pub struct WelcomeRenderResult { /// Cursor position (if the prompt wants a visible cursor). pub cursor_pos: Option<(u16, u16)>, /// Terminal image/cursor escapes paired with their ownership transition. pub post_flush_escapes: Option, /// Hit-test rects for each menu item (for click/hover). pub menu_rects: Vec, /// Hit-test rect for the prompt input area (for click to start session). pub prompt_rect: Option, /// Hit-test rect for the import-claude banner (for click to open import modal). pub import_banner_rect: Option, /// Hit areas from the session picker (for mouse hit-testing). pub session_picker_hit_areas: Option, /// Hit-test rect for the auth copy line (click-to-copy during Authenticating). pub auth_url_rect: Option, /// Hit-test rect for the "show full URL" fallback link. pub auth_fallback_rect: Option, /// Hit-test rect for the "[Refresh]" button on the paywall tier line. pub refresh_rect: Option, /// Hit-test rect for the gate URL link (click to open in browser). pub gate_url_rect: Option, /// Whether a "Changelog" menu action was rendered (above Quit), so the /// input handler can map the extra menu row to the release-notes action /// once markdown is available. pub changelog_action_present: bool, /// Hit-test rect for the clickable changelog info block (opens release notes). pub changelog_cta_rect: Option, /// Whether the announcement overflowed (the "expandable" signal). pub announcement_truncated: bool, /// Hit-test rect for the full announcement block (click anywhere to toggle). pub announcement_rect: Option, /// Hit-test rect for the promo upgrade CTA `[label]` button (click → open). pub upgrade_cta_rect: Option, } use hero_box::HERO_BOX_MIN_WIDTH; /// Prompt input height (shared across hero and stacked layout paths). const PROMPT_HEIGHT: u16 = 3; /// Gap between prompt and version line. const VERSION_GAP: u16 = 1; /// Computed areas for the welcome screen vertical layout. pub(super) struct WelcomeLayout { pub(super) logo: Rect, pub(super) error: Rect, pub(super) menu: Rect, /// Stacked info slot below the menu (narrow layout only) — shows either the /// announcement or the changelog (one at a time; the announcement takes /// priority). Zero in the hero box layout, which uses `hero_info` instead. pub(super) changelog: Rect, pub(super) tip: Rect, pub(super) prompt: Rect, pub(super) version: Rect, // Hero box sub-rects (all zero when hero box is inactive). pub(super) hero_box: Rect, pub(super) hero_logo: Rect, pub(super) hero_version: Rect, pub(super) hero_subtitle: Rect, /// In-box info slot — shows either the announcement or the changelog /// (only one at a time; the announcement takes priority). pub(super) hero_info: Rect, pub(super) hero_menu: Rect, } /// Inputs to [`WelcomeLayout::compute`] / [`WelcomeLayout::compute_stacked`]. /// /// Bundled (and `Default`-able) so call sites name each field — in particular /// the two distinct compaction flags can't be silently transposed. #[derive(Default)] struct WelcomeLayoutInput<'a> { content_area: Rect, /// Error/warning row height; 0 when there's nothing to show. error_height: u16, menu_height: u16, tip_height: u16, /// Desired changelog height (collapsed to 0 if the terminal is too short). changelog_height: u16, /// Vertical compaction (session picker visible): skip the logo + info slot. compact: bool, /// Horizontal-inset compaction (appearance setting) for the stacked slot. prompt_compact: bool, announcement: Option<&'a xai_grok_announcements::RemoteAnnouncement>, /// Whether a long announcement is expanded inline (vs. collapsed to 2 lines). expanded: bool, /// Whether the info slot reserves a promo upgrade CTA (spacer + button). has_upgrade_cta: bool, } impl WelcomeLayout { /// Whether the hero box (side-by-side logo + menu inside a border) is active. pub(super) fn has_hero_box(&self) -> bool { self.hero_box.width > 0 && self.hero_box.height > 0 } pub(super) fn fixed_below(tip_height: u16) -> u16 { let tip_gap = if tip_height > 0 { 1u16 } else { 0 }; tip_height + tip_gap + PROMPT_HEIGHT + VERSION_GAP + 1 } pub(super) fn effective_changelog( content_height: u16, fixed_above: u16, content_slot: u16, fixed_below: u16, requested: u16, ) -> (u16, u16) { let gap = if requested > 0 { 1u16 } else { 0 }; let min_without = fixed_above + content_slot + 1 + fixed_below; if requested > 0 && content_height >= min_without + gap + requested { (requested, 1) } else { (0, 0) } } /// Compute the welcome screen layout, allowing the wide hero-box variant. fn compute(input: WelcomeLayoutInput<'_>) -> Self { Self::compute_inner(input, true) } /// Compute the welcome screen layout, forced to the stacked variant. /// /// The blocked screens (login, ZDR gate) render through /// `render_welcome_blocked`, which only paints the stacked `logo`/`menu` /// rects (and never an announcement). The hero-box layout zeroes those, so /// the blocked path must stay stacked regardless of terminal size. fn compute_stacked(input: WelcomeLayoutInput<'_>) -> Self { Self::compute_inner(input, false) } /// Compute the welcome screen layout. /// /// Picks hero vs stacked, then measures the info slot (announcement, else /// changelog) at that layout's slot width before placing rects — width is /// content-size-only, so it's a clean two-phase computation. `allow_hero_box` /// gates the wide variant; stacked-only callers pass `false`. fn compute_inner(input: WelcomeLayoutInput<'_>, allow_hero_box: bool) -> Self { let WelcomeLayoutInput { content_area, error_height, menu_height, tip_height, changelog_height, compact, prompt_compact, announcement, expanded, has_upgrade_cta, } = input; let zero = Rect::default(); // Pick hero vs stacked first, independent of the announcement's height: // the changelog isn't clamped so it must fit as-is, but an announcement // clamps to fit, so with one present the box only needs to fit empty. let gate_info = if announcement.is_some() { 0 } else { changelog_height }; let use_hero_box = allow_hero_box && !compact && content_area.width >= HERO_BOX_MIN_WIDTH && menu_height > 0 && content_area.height >= hero_box::min_content_height(error_height, menu_height, tip_height, gate_info); if use_hero_box { // The hero box measures + clamps the announcement itself. return hero_box::compute_hero_box( content_area, error_height, menu_height, tip_height, changelog_height, announcement, expanded, has_upgrade_cta, ); } // Stacked info slot: the announcement clamped to the column budget, else // the changelog. Measure at the centered menu width inside the inset. let info_height = match announcement { Some(ann) => { let avail = content_area .width .saturating_sub(prompt::prompt_inset(prompt_compact) * 2); let width = stacked_info_width(avail, content_area.height, MENU_MIN_WIDTH); hero_box::announcement_desired_rows(ann, width, expanded, has_upgrade_cta).min( stacked_info_budget( content_area, error_height, menu_height, tip_height, compact, ), ) } None => changelog_height, }; // Stacked layout: skip the logo in compact mode (the session picker // needs the space); otherwise pick small/full/none by height. let logo_rows = if compact { 0 } else { logo_line_count(content_area.height) }; let gap_after_logo = if error_height > 0 { 1 } else { 0 }; let tip_gap = if tip_height > 0 { 1u16 } else { 0 }; let fixed_below = Self::fixed_below(tip_height); let fixed_above = logo_rows + 1 + gap_after_logo + error_height; // +1 for gap after logo // The stacked info slot below the menu holds whichever block is shown // (announcement or changelog), matching the hero box's single-slot rule. let (eff_changelog_height, _) = if !compact { Self::effective_changelog( content_area.height, fixed_above, menu_height, fixed_below, info_height, ) } else { (0, 0) }; let eff_changelog_gap = if eff_changelog_height > 0 { 1u16 } else { 0 }; // Compute top_pad using the *default* menu height (4 items = 7 rows) so // the logo position stays constant regardless of picker/focus state. let top_pad = if compact { 0 } else { let default_menu_height = 4u16; let remaining = content_area.height.saturating_sub(fixed_above); remaining .saturating_sub(default_menu_height) .saturating_sub(eff_changelog_gap + eff_changelog_height) .saturating_sub(fixed_below) / 3 }; let logo_gap = 1u16; let flex_gap = 1u16; let [ _, logo, _, _, error, menu, _, changelog, _, tip, _, prompt, _, version, ] = Layout::vertical([ Constraint::Length(top_pad), Constraint::Length(logo_rows), Constraint::Length(logo_gap), // gap after logo Constraint::Length(gap_after_logo), Constraint::Length(error_height), Constraint::Length(menu_height), Constraint::Length(eff_changelog_gap), Constraint::Length(eff_changelog_height), Constraint::Min(flex_gap), Constraint::Length(tip_height), Constraint::Length(tip_gap), Constraint::Length(PROMPT_HEIGHT), Constraint::Length(VERSION_GAP), Constraint::Length(1), // version ]) .areas(content_area); Self { logo, error, menu, changelog, tip, prompt, version, hero_box: zero, hero_logo: zero, hero_version: zero, hero_subtitle: zero, hero_info: zero, hero_menu: zero, } } } /// Controls what the version badge renders. pub(super) enum VersionBadgeMode<'a> { /// Full badge: team | tier | api_key | **Grok Build** VERSION+channel **Beta** (right-aligned). Full { subscription_tier: Option<&'a str> }, /// Hero footer: team | api_key | Grok Build Beta [channel] (right-aligned, gray). HeroFooter, /// Hero inline: **Grok Build Beta** VERSION (left-aligned). HeroInline, } pub(super) fn render_version_badge( version_rect: Rect, buf: &mut Buffer, theme: &Theme, team_name: Option<&str>, h_margin: u16, is_api_key_auth: bool, mode: VersionBadgeMode<'_>, ) { let version_area = Rect { width: version_rect.width.saturating_sub(h_margin), ..version_rect }; let sep = Span::styled( " \u{2502} ", Style::default().fg(theme.gray).add_modifier(Modifier::DIM), ); let mut spans = Vec::new(); let (show_team, show_tier, show_api_key, align) = match &mode { VersionBadgeMode::Full { .. } => (true, true, true, Alignment::Right), VersionBadgeMode::HeroFooter => (true, false, true, Alignment::Right), VersionBadgeMode::HeroInline => (false, false, false, Alignment::Left), }; if show_team && let Some(team) = team_name { spans.push(Span::styled(team, Style::default().fg(theme.gray))); spans.push(sep.clone()); } if show_tier && let VersionBadgeMode::Full { subscription_tier: Some(tier), } = &mode { spans.push(Span::styled( format!("Tier: {tier}"), Style::default().fg(theme.gray), )); spans.push(sep.clone()); } if show_api_key && is_api_key_auth { spans.push(Span::styled( "Logged in with API key", Style::default().fg(theme.gray), )); spans.push(sep); } let channel = xai_grok_update::channel_label(); match &mode { VersionBadgeMode::Full { .. } => { spans.push(Span::styled( "Grok Build ", Style::default() .fg(theme.text_primary) .add_modifier(Modifier::BOLD), )); spans.push(Span::styled( format!("{}{}", xai_grok_version::VERSION, channel), Style::default().fg(theme.gray), )); spans.push(Span::styled( " Beta", Style::default() .fg(theme.text_primary) .add_modifier(Modifier::BOLD), )); } VersionBadgeMode::HeroFooter => { let channel_display = if channel.is_empty() { "Beta" } else { channel.trim() }; spans.push(Span::styled( channel_display, Style::default().fg(theme.gray), )); } VersionBadgeMode::HeroInline => { spans.push(Span::styled( "Grok Build Beta ", Style::default() .fg(theme.text_primary) .add_modifier(Modifier::BOLD), )); spans.push(Span::styled( xai_grok_version::VERSION, Style::default().fg(theme.gray), )); } } let version_line = Line::from(spans).alignment(align); Paragraph::new(version_line).render(version_area, buf); } /// Render the prompt box and version line (shared across welcome states). /// /// When `skip_version` is true the version badge is not rendered (it was /// already drawn inside the hero box). #[allow(clippy::too_many_arguments)] fn render_prompt_and_version( layout: &WelcomeLayout, content_width: u16, buf: &mut Buffer, theme: &Theme, focus: WelcomePromptFocus, prompt: &mut PromptWidget, info: &PromptInfo<'_>, tip: Option<&str>, team_name: Option<&str>, h_margin: u16, compact: bool, pending_hint: Option, is_api_key_auth: bool, skip_version: bool, ) -> ( Option<(u16, u16)>, Option, ) { let [_, prompt_centered, _] = Layout::horizontal([ Constraint::Min(0), Constraint::Length(content_width), Constraint::Min(0), ]) .flex(Flex::Center) .areas(layout.prompt); if let Some(tip_text) = tip && layout.tip.height > 0 { let [_, tip_centered, _] = Layout::horizontal([ Constraint::Min(0), Constraint::Length(content_width), Constraint::Min(0), ]) .flex(Flex::Center) .areas(layout.tip); let inset = prompt::prompt_inset(compact); let tip_inset = Rect { x: tip_centered.x + inset, y: tip_centered.y, width: tip_centered.width.saturating_sub(inset * 2), height: tip_centered.height, }; crate::tips::render::render_tip(tip_inset, buf, tip_text); } let prompt_result = prompt::render_prompt(prompt_centered, buf, focus, prompt, info, 2, 2, compact); if let Some(pending) = &pending_hint { let key_style = Style::default() .fg(theme.text_primary) .add_modifier(Modifier::BOLD); let action_style = Style::default().fg(theme.gray); let key_text = pending.shortcut.display(); let label = format!("press again to {}", pending.label); let line = Line::from(vec![ Span::styled(format!(" {key_text}"), key_style), Span::styled(":", action_style), Span::styled(label, action_style), ]); buf.set_line( layout.version.x, layout.version.y, &line, layout.version.width, ); } else if !skip_version { render_version_badge( layout.version, buf, theme, team_name, h_margin, is_api_key_auth, VersionBadgeMode::Full { subscription_tier: None, }, ); } else { render_version_badge( layout.version, buf, theme, team_name, h_margin, is_api_key_auth, VersionBadgeMode::HeroFooter, ); } prompt_result } /// All display state for rendering the welcome screen. pub struct WelcomeRenderParams<'a> { pub prompt_focus: WelcomePromptFocus, pub auth_state: &'a AuthState, /// Folder-trust state. When `Pending` (auth done, access granted), the /// welcome screen renders the trust question instead of the normal prompt. pub trust_state: &'a TrustState, pub login_label: Option<&'a str>, pub auth_code_input: &'a str, pub clipboard_copied: bool, pub show_raw_url: bool, pub announcement: Option<&'a xai_grok_announcements::RemoteAnnouncement>, pub tip: Option<&'a str>, pub model_name: &'a str, pub flags: &'a [PromptFlag<'a>], pub selected: Option, pub team_name: Option<&'a str>, pub has_access: bool, pub has_claude_import: bool, pub mouse_pos: Option<(u16, u16)>, pub is_zdr_blocked: bool, pub session_picker: Option<&'a [SessionPickerEntry]>, pub session_picker_loading: bool, pub compact: bool, pub pending_hint: Option, pub startup_warnings: &'a [StartupWarning], pub pending_update_version: Option<&'a str>, /// Recent foreign session offered on ctrl+u, suppressed by a pending update. pub foreign_resume_hint: Option<&'a xai_grok_workspace::foreign_sessions::RecentForeignSession>, pub is_api_key_auth: bool, pub session_picker_content_results: Option<&'a [xai_grok_shell::extensions::session_search::SearchSessionHit]>, pub session_picker_content_loading: bool, /// The query the picker entries were server-fetched with (see /// [`crate::views::session_picker::effective_filter_query`]). pub session_picker_entries_query: Option<&'a str>, pub welcome_tick: u64, pub gate: Option<&'a xai_grok_shell::auth::GateInfo>, pub subscription_tier: Option<&'a str>, pub session_picker_grouped: bool, /// Source filter (local/remote/all) for the session picker. pub session_picker_source_filter: crate::views::session_picker::SourceFilter, /// Process-wide `--chat`: the picker lists backend conversations only, so /// the Local/Remote source filter and local deep search are hidden. pub chat_mode: bool, /// Live working directory (tracks `Effect::SetWorkingDir`), used to pin /// the current repo's session group to the top of the picker. pub cwd: &'a std::path::Path, /// App-level credit balance for showing the usage warning on the welcome screen. pub credit_balance: Option<&'a crate::views::credit_bar::CreditBalance>, /// Auto top-up rule paired with `credit_balance` for the welcome warning. pub auto_topup: Option<&'a crate::views::credit_bar::AutoTopupInfo>, /// Whether /usage is visible (false for team users — suppresses the warning). pub usage_visible: bool, /// Cached changelog bullets for the welcome screen (up to 3). pub changelog_bullets: &'a [String], /// Whether full release notes markdown is available (controls the CTA hint). pub changelog_has_full_notes: bool, /// Whether a long managed-config announcement is expanded inline (vs the /// default 2-line collapsed view with a trailing `…`). pub welcome_announcement_expanded: bool, /// Promo upgrade CTA `[label]` to paint below the hero announcement: `Some` /// drives both the reserved row height and the `[label]` button. `None` = no /// CTA on the welcome screen. pub upgrade_cta: Option<&'a str>, } /// Render the welcome screen. pub fn render_welcome( area: Rect, buf: &mut Buffer, params: &WelcomeRenderParams<'_>, prompt: &mut PromptWidget, session_picker_state: &mut crate::views::picker::PickerState, ) -> WelcomeRenderResult { let theme = Theme::current(); let h_margin = if params.compact { H_MARGIN_COMPACT } else { H_MARGIN }; let v_margin = 1u16; buf.set_style(area, Style::default().bg(theme.bg_base)); // Announcements only render inside the hero box. Top bar is always 1 row. let [_, top_bar_area, content_area, _] = Layout::vertical([ Constraint::Length(v_margin), Constraint::Length(1), Constraint::Min(10), Constraint::Length(v_margin), ]) .areas(area); let top_bar_inner = Rect { x: top_bar_area.x + h_margin, y: top_bar_area.y, width: top_bar_area.width.saturating_sub(h_margin * 2), height: 1, }; render_top_bar(top_bar_inner, buf, &theme, None); let mut result = match params.auth_state { AuthState::Pending { error } => { let label = params.login_label.unwrap_or("grok.com"); let login_text = format!("Login with {}", label); let menu = [("l", login_text.as_str()), ("q", "Quit")]; let msg = error.as_deref().map(|e| (e, theme.accent_error)); let info = PromptInfo { model_name: params.model_name, flags: params.flags, multiline: false, usage_warning: None, usage_warning_critical: false, }; let (menu_rects, post_flush_escapes) = render_welcome_blocked( content_area, buf, msg, &menu, params.selected, Some((prompt, &info)), h_margin, params.compact, ); WelcomeRenderResult { cursor_pos: None, post_flush_escapes, menu_rects, prompt_rect: None, session_picker_hit_areas: None, import_banner_rect: None, auth_url_rect: None, auth_fallback_rect: None, refresh_rect: None, gate_url_rect: None, changelog_action_present: false, changelog_cta_rect: None, announcement_truncated: false, announcement_rect: None, upgrade_cta_rect: None, } } AuthState::Authenticating { auth_url, mode, .. } => { let llc = logo_line_count(content_area.height); let (url_rect, fallback_rect) = render_welcome_authenticating( content_area, buf, &theme, llc, auth_url.as_deref(), *mode, params.auth_code_input, params.clipboard_copied, params.show_raw_url, ); WelcomeRenderResult { cursor_pos: None, post_flush_escapes: None, menu_rects: vec![], prompt_rect: None, session_picker_hit_areas: None, import_banner_rect: None, auth_url_rect: url_rect, auth_fallback_rect: fallback_rect, refresh_rect: None, gate_url_rect: None, changelog_action_present: false, changelog_cta_rect: None, announcement_truncated: false, announcement_rect: None, upgrade_cta_rect: None, } } AuthState::Done if params.is_zdr_blocked => { let menu = [("l", "Switch account"), ("q", "Quit")]; let (menu_rects, post_flush_escapes) = render_welcome_blocked( content_area, buf, Some(( "Grok Build is not yet available for this account.", theme.gray_bright, )), &menu, params.selected, None, h_margin, params.compact, ); WelcomeRenderResult { cursor_pos: None, post_flush_escapes, menu_rects, prompt_rect: None, session_picker_hit_areas: None, import_banner_rect: None, auth_url_rect: None, auth_fallback_rect: None, refresh_rect: None, gate_url_rect: None, changelog_action_present: false, changelog_cta_rect: None, announcement_truncated: false, announcement_rect: None, upgrade_cta_rect: None, } } // Folder-trust question: shown after auth, before any session is // created, when the cwd has untrusted repo-local config. Mirrors the // Pending login screen. Skipped under ZDR/access gates (the ZDR arm // above and the !has_access arm below) since those already block // sessions. The `if let` destructure makes the `Pending`-only render // structurally exhaustive (no `unreachable!`). AuthState::Done if params.has_access => { if let TrustState::Pending { workspace } = params.trust_state { render_welcome_trust( content_area, buf, &theme, workspace, params.selected, h_margin, params.compact, ) } else { render_welcome_done( content_area, buf, &theme, params, prompt, session_picker_state, h_margin, ) } } AuthState::Done => render_welcome_done( content_area, buf, &theme, params, prompt, session_picker_state, h_margin, ), }; if result.post_flush_escapes.is_none() { result.post_flush_escapes = crate::terminal::overlay::clear().map(Into::into); } result } /// Render a blocked welcome screen: logo + optional message + menu + version. /// /// Used for both the login screen (Pending) and the ZDR gate. The layout is: /// Logo /// {message} /// Menu items /// {prompt} (optional) /// Version badge #[allow(clippy::too_many_arguments)] fn render_welcome_blocked( content_area: Rect, buf: &mut Buffer, message: Option<(&str, ratatui::style::Color)>, menu_items: &[(&str, &str)], selected: Option, prompt: Option<(&mut PromptWidget, &PromptInfo<'_>)>, h_margin: u16, compact: bool, ) -> (Vec, Option) { let theme = Theme::current(); let msg_height = if message.is_some() { 2u16 } else { 0u16 }; let menu_height = menu_items.len() as u16; // Force the stacked layout: this renderer only paints the stacked // logo/menu rects, which the hero-box layout would leave empty. let layout = WelcomeLayout::compute_stacked(WelcomeLayoutInput { content_area, error_height: msg_height, menu_height, compact, prompt_compact: compact, ..Default::default() }); render_logo(layout.logo, buf, &theme, content_area.height); if let Some((text, color)) = message { let line = Line::from(Span::styled(text, Style::default().fg(color))).alignment(Alignment::Center); Paragraph::new(line).render(layout.error, buf); } // Inset the menu the same as the input bar / post-auth menu so the actions // keep side spacing instead of touching the window edge on narrow terminals. let menu_area = inset_horizontal(layout.menu, prompt::prompt_inset(compact)); let menu_rects = render_menu(menu_area, buf, &theme, menu_items, selected, None, 0); let post_flush_escapes = if let Some((prompt_widget, info)) = prompt { let [_, prompt_centered, _] = Layout::horizontal([ Constraint::Min(0), Constraint::Length(content_area.width), Constraint::Min(0), ]) .flex(Flex::Center) .areas(layout.prompt); prompt::render_prompt( prompt_centered, buf, WelcomePromptFocus::Unfocused, prompt_widget, info, 2, 2, compact, ) .1 } else { None }; render_version_badge( layout.version, buf, &theme, None, h_margin, false, VersionBadgeMode::Full { subscription_tier: None, }, ); (menu_rects, post_flush_escapes) } /// Render the folder-trust question. Mirrors [`render_welcome_blocked`]'s /// stacked layout (logo + message + menu + version badge), but the message is a /// multi-line block showing the workspace path and the warning that Grok Build /// may run or modify contents in this directory (a security risk). The y/N /// answer is handled by the welcome input interceptor, so this only paints; /// `menu_rects` are returned for parity with the other welcome arms. fn render_welcome_trust( content_area: Rect, buf: &mut Buffer, theme: &Theme, workspace: &std::path::Path, selected: Option, h_margin: u16, compact: bool, ) -> WelcomeRenderResult { let menu_items = [("y", "Yes, proceed"), ("n", "No, quit")]; let lines = vec![ Line::from(Span::styled( "Do you trust the contents of this directory?", Style::default().fg(theme.gray_bright), )) .alignment(Alignment::Center), Line::from(Span::styled( workspace.display().to_string(), Style::default().fg(theme.accent_user), )) .alignment(Alignment::Center), Line::default(), // Two lines so the warning never clips at narrow / compact widths // (a single ~78-char line would truncate "...posing security risks"). Line::from(Span::styled( "Grok Build may run or modify contents in this directory,", Style::default().fg(theme.gray), )) .alignment(Alignment::Center), Line::from(Span::styled( "posing security risks.", Style::default().fg(theme.gray), )) .alignment(Alignment::Center), // Spacer between the warning and the y/n menu. Line::default(), ]; let msg_height = lines.len() as u16; let menu_height = menu_items.len() as u16; let layout = WelcomeLayout::compute_stacked(WelcomeLayoutInput { content_area, error_height: msg_height, menu_height, compact, prompt_compact: compact, ..Default::default() }); render_logo(layout.logo, buf, theme, content_area.height); Paragraph::new(lines).render(layout.error, buf); let menu_area = inset_horizontal(layout.menu, prompt::prompt_inset(compact)); let menu_rects = render_menu(menu_area, buf, theme, &menu_items, selected, None, 0); render_version_badge( layout.version, buf, theme, None, h_margin, false, VersionBadgeMode::Full { subscription_tier: None, }, ); // Only `menu_rects` are meaningful here; the rest are absent (no prompt, // picker, auth/gate links) -- `Default` keeps this honest without a 13-field // all-`None` literal. WelcomeRenderResult { menu_rects, ..Default::default() } } /// Header text shared by Loopback and Command auth modes. const AUTH_HEADER: &str = "A browser window will open for authentication."; /// Header text for the device-flow auth mode. const DEVICE_AUTH_HEADER: &str = "Approve in your browser to finish signing in."; /// Caption beneath the device code. const DEVICE_CODE_CAPTION: &str = "Make sure your browser shows this code."; /// Extract `user_code` from a device verification URL (`None` if absent or /// malformed). Shown on-screen so the user can confirm it matches the browser /// before approving (anti-phishing). fn extract_user_code(url: &str) -> Option<&str> { let code = url .split('?') .nth(1)? .split('&') .find_map(|kv| kv.strip_prefix("user_code="))?; let valid = !code.is_empty() && code.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'); valid.then_some(code) } /// Clickable copy prompt shared by Loopback and Command auth modes. const AUTH_COPY_PREFIX: &str = "If it doesn't open, click "; const AUTH_COPY_HERE: &str = "here"; const AUTH_COPY_SUFFIX: &str = " to copy."; /// Build the "click here to copy" line with "here" underlined in accent color. fn auth_copy_line(theme: &Theme) -> Line<'static> { Line::from(vec![ Span::styled(AUTH_COPY_PREFIX, Style::default().fg(theme.gray_bright)), Span::styled( AUTH_COPY_HERE, Style::default() .fg(theme.accent_user) .add_modifier(Modifier::UNDERLINED), ), Span::styled(AUTH_COPY_SUFFIX, Style::default().fg(theme.gray_bright)), ]) .alignment(Alignment::Center) } /// Number of physical rows the header + blank occupy before the copy line. fn auth_copy_preceding_rows(header: &str, inner_width: u16) -> u16 { let header_rows = (header.len() as u16).div_ceil(inner_width); header_rows + 1 // header + blank } /// Number of physical rows the copy line occupies when wrapped. fn auth_copy_line_rows(inner_width: u16) -> u16 { let copy_len = AUTH_COPY_PREFIX.len() + AUTH_COPY_HERE.len() + AUTH_COPY_SUFFIX.len(); (copy_len as u16).div_ceil(inner_width) } const AUTH_FALLBACK_TEXT: &str = "Copying not working? Click here to show full URL."; /// Build the fallback "show full URL" link line. fn auth_fallback_line(theme: &Theme) -> Line<'static> { Line::from(Span::styled( AUTH_FALLBACK_TEXT, Style::default() .fg(theme.gray) .add_modifier(Modifier::UNDERLINED), )) .alignment(Alignment::Center) } /// Push the shared copy-prompt block: the "click here to copy" line, a "copied!" /// slot (kept blank when not copied so the height is stable), and the /// show-full-URL fallback link. fn push_auth_copy_block(lines: &mut Vec>, theme: &Theme, clipboard_copied: bool) { lines.push(Line::default()); lines.push(auth_copy_line(theme)); lines.push(Line::default()); lines.push(if clipboard_copied { Line::from(Span::styled("copied!", Style::default().fg(theme.gray))) .alignment(Alignment::Center) } else { Line::default() }); lines.push(Line::default()); lines.push(auth_fallback_line(theme)); } /// Rows occupied by [`push_auth_copy_block`]. fn auth_copy_block_rows(inner_width: u16) -> u16 { auth_copy_line_rows(inner_width) + 5 } /// Click hit-rects for the copy line and fallback link. `header`'s wrapped row /// count sets the copy line's vertical offset. fn auth_hit_rects( msg_area: Rect, h_pad: u16, inner_width: u16, header: &str, preceding_extra: u16, ) -> (Option, Option) { let preceding = auth_copy_preceding_rows(header, inner_width) + preceding_extra; let copy_rows = auth_copy_line_rows(inner_width); let copy_rect = Rect { x: msg_area.x + h_pad, y: msg_area.y + preceding, width: inner_width, height: copy_rows, }; // fallback line is after: copy_rows + blank + copied_slot + blank let fallback_y = msg_area.y + preceding + copy_rows + 3; let fb_rect = Rect { x: msg_area.x + h_pad, y: fallback_y, width: inner_width, height: 1, }; (Some(copy_rect), Some(fb_rect)) } /// Render the "raw URL" mode: shows the full URL with mouse capture disabled /// so the user can select and copy it natively. fn render_raw_url_mode( content_area: Rect, buf: &mut Buffer, theme: &Theme, top_pad: u16, logo_line_count: u16, auth_url: Option<&str>, ) -> (Option, Option) { // Use full terminal width for the URL so the terminal wraps it // naturally without inserting spaces (important for copy-paste). let full_width = content_area.width.max(1); let url_lines = auth_url .map(|u| (u.len() as u16).div_ceil(full_width)) .unwrap_or(0); let msg_height = 1 + 1 + url_lines; // hint + blank + URL let [_, logo_area, _, msg_area, _, hint_area, _] = Layout::vertical([ Constraint::Length(top_pad), Constraint::Length(logo_line_count), Constraint::Length(2), Constraint::Length(msg_height), Constraint::Min(1), Constraint::Length(1), Constraint::Min(0), ]) .areas(content_area); render_logo(logo_area, buf, theme, content_area.height); // Render hint above the URL. let hint = Line::from(Span::styled( "Select the URL below with your mouse and copy manually.", Style::default().fg(theme.gray), )) .alignment(Alignment::Center); Paragraph::new(hint).render( Rect { height: 1, ..msg_area }, buf, ); // Write the URL directly to the buffer character-by-character so the // terminal wraps naturally at the screen edge. Ratatui's Paragraph // wrap inserts spaces at break points which corrupts the URL on copy. // // When the URL fits on a single line, center it to match the rest of the // screen. When it's longer, keep it flush-left at the full terminal width // so the natural wrap preserves copy-paste (centering a wrapped URL would // inject leading spaces into the selection). if let Some(url) = auth_url { let url_style = Style::default().fg(theme.accent_user); let url_y = msg_area.y + 2; // after hint + blank // Control characters are skipped below to prevent terminal escape // injection, so measure the URL without them. let url_len = url.chars().filter(|c| !c.is_control()).count() as u16; let x_offset = if url_len <= full_width { (full_width - url_len) / 2 } else { 0 }; let buf_area = buf.area(); let buf_max_col = buf_area.x + buf_area.width; let buf_max_row = buf_area.y + buf_area.height; for (i, ch) in url.chars().filter(|c| !c.is_control()).enumerate() { let col = msg_area.x + x_offset + (i as u16) % full_width; let row = url_y + (i as u16) / full_width; if row >= msg_area.y + msg_area.height { break; } // Guard against OOB access during resize races. if col >= buf_max_col || row >= buf_max_row { continue; } buf[(col, row)].set_char(ch).set_style(url_style); } } let hint_spans = vec![ Span::styled( "ctrl+q", Style::default() .fg(theme.accent_user) .add_modifier(Modifier::BOLD), ), Span::styled(" go back", Style::default().fg(theme.gray)), ]; let hints = Line::from(hint_spans).alignment(Alignment::Center); Paragraph::new(hints).render(hint_area, buf); (None, None) // no click rects — mouse capture is disabled } /// Which "browser opened, now waiting" arm to render; owns the header, /// waiting caption, and (for `Device`) the device-code derivation. #[derive(Clone, Copy)] enum BrowserStatusKind { /// External auth provider opened its own browser. Command, /// RFC 8628 device flow — also shows the device code. Device, } /// Render a "browser opened, now waiting" auth arm (Command + Device). /// /// Shared status layout: logo, then a centered block of header, optional device /// code + caption, optional copy/fallback links (when there's a URL), and the /// waiting caption; finally quit hints. #[allow(clippy::too_many_arguments)] fn render_browser_status_arm( content_area: Rect, buf: &mut Buffer, theme: &Theme, top_pad: u16, logo_line_count: u16, auth_url: Option<&str>, show_raw_url: bool, clipboard_copied: bool, kind: BrowserStatusKind, ) -> (Option, Option) { let h_pad: u16 = content_area.width / 6; let inner_width = content_area.width.saturating_sub(h_pad * 2).max(1); if show_raw_url { return render_raw_url_mode(content_area, buf, theme, top_pad, logo_line_count, auth_url); } // Device also parses the user code from the verification URL. let (header, waiting_text, user_code) = match kind { BrowserStatusKind::Command => (AUTH_HEADER, "Waiting for login to complete...", None), BrowserStatusKind::Device => ( DEVICE_AUTH_HEADER, "Waiting for approval...", auth_url.and_then(extract_user_code), ), }; let header_rows = (header.len() as u16).div_ceil(inner_width); let code_extra = if user_code.is_some() { let caption_rows = (DEVICE_CODE_CAPTION.len() as u16).div_ceil(inner_width); 1 + 1 + 1 + caption_rows // blank + code + blank + caption } else { 0 }; let copy_extra = if auth_url.is_some() { auth_copy_block_rows(inner_width) } else { 0 }; let msg_height = header_rows + code_extra + copy_extra + 1 + 1; // blank + waiting let [_, logo_area, _, msg_area, _, hint_area, _] = Layout::vertical([ Constraint::Length(top_pad), Constraint::Length(logo_line_count), Constraint::Length(2), // gap Constraint::Length(msg_height), // status message Constraint::Min(1), // gap Constraint::Length(1), // hints Constraint::Min(0), ]) .areas(content_area); render_logo(logo_area, buf, theme, content_area.height); let mut lines: Vec = vec![ Line::from(Span::styled(header, Style::default().fg(theme.gray_bright))) .alignment(Alignment::Center), ]; if let Some(code) = user_code { lines.push(Line::default()); lines.push( Line::from(Span::styled( code.to_owned(), Style::default() .fg(theme.text_primary) .add_modifier(Modifier::BOLD), )) .alignment(Alignment::Center), ); lines.push(Line::default()); lines.push( Line::from(Span::styled( DEVICE_CODE_CAPTION, Style::default().fg(theme.gray), )) .alignment(Alignment::Center), ); } if auth_url.is_some() { push_auth_copy_block(&mut lines, theme, clipboard_copied); } lines.push(Line::default()); lines.push( Line::from(Span::styled(waiting_text, Style::default().fg(theme.gray))) .alignment(Alignment::Center), ); Paragraph::new(lines) .wrap(Wrap { trim: false }) .block(Block::default().padding(Padding::horizontal(h_pad))) .render(msg_area, buf); let (click_rect, fallback_rect) = if auth_url.is_some() { auth_hit_rects(msg_area, h_pad, inner_width, header, code_extra) } else { (None, None) }; let hints = Line::from(quit_hint_spans(theme)).alignment(Alignment::Center); Paragraph::new(hints).render(hint_area, buf); (click_rect, fallback_rect) } /// Render the welcome screen during authentication (Authenticating state). #[allow(clippy::too_many_arguments)] fn render_welcome_authenticating( content_area: Rect, buf: &mut Buffer, theme: &Theme, logo_line_count: u16, auth_url: Option<&str>, mode: AuthMode, auth_code_input: &str, clipboard_copied: bool, show_raw_url: bool, ) -> (Option, Option) { let top_pad = content_area.height.saturating_sub(logo_line_count) / 10; match mode { AuthMode::Loopback => { // Manual token paste: show copy prompt + input box let h_pad: u16 = content_area.width / 6; let inner_width = content_area.width.saturating_sub(h_pad * 2).max(1); if show_raw_url { return render_raw_url_mode( content_area, buf, theme, top_pad, logo_line_count, auth_url, ); } let msg_height = if auth_url.is_some() { let header_rows = (AUTH_HEADER.len() as u16).div_ceil(inner_width); header_rows + auth_copy_block_rows(inner_width) } else { 1u16 }; let [_, logo_area, _, msg_area, _, prompt_area, _, hint_area, _] = Layout::vertical([ Constraint::Length(top_pad), Constraint::Length(logo_line_count), Constraint::Length(1), // gap Constraint::Length(msg_height), // instruction + copy prompt Constraint::Min(1), // gap Constraint::Length(5), // prompt box Constraint::Length(1), // gap Constraint::Length(1), // hints Constraint::Min(0), ]) .areas(content_area); render_logo(logo_area, buf, theme, content_area.height); // Instruction text let mut lines: Vec = Vec::new(); if auth_url.is_some() { lines.push( Line::from(Span::styled( AUTH_HEADER, Style::default().fg(theme.gray_bright), )) .alignment(Alignment::Center), ); push_auth_copy_block(&mut lines, theme, clipboard_copied); } else { lines.push( Line::from(Span::styled( "Waiting for auth URL...", Style::default().fg(theme.gray), )) .alignment(Alignment::Center), ); } Paragraph::new(lines) .wrap(Wrap { trim: false }) .block(Block::default().padding(Padding::horizontal(h_pad))) .render(msg_area, buf); let (click_rect, fallback_rect) = if auth_url.is_some() { auth_hit_rects(msg_area, h_pad, inner_width, AUTH_HEADER, 0) } else { (None, None) }; // Prompt box with token input let prompt_width = content_area.width; let [_, prompt_centered, _] = Layout::horizontal([ Constraint::Min(0), Constraint::Length(prompt_width), Constraint::Min(0), ]) .flex(Flex::Center) .areas(prompt_area); render_auth_input_box(prompt_centered, buf, theme, auth_code_input); // Hints let mut hint_spans = vec![ Span::styled( "enter", Style::default() .fg(theme.accent_user) .add_modifier(Modifier::BOLD), ), Span::styled(" submit ", Style::default().fg(theme.gray)), ]; hint_spans.extend(quit_hint_spans(theme)); let hints = Line::from(hint_spans).alignment(Alignment::Center); Paragraph::new(hints).render(hint_area, buf); (click_rect, fallback_rect) } AuthMode::Command => render_browser_status_arm( content_area, buf, theme, top_pad, logo_line_count, auth_url, show_raw_url, clipboard_copied, BrowserStatusKind::Command, ), AuthMode::Device => render_browser_status_arm( content_area, buf, theme, top_pad, logo_line_count, auth_url, show_raw_url, clipboard_copied, BrowserStatusKind::Device, ), AuthMode::Pending => { // Connecting: status text let [_, logo_area, _, msg_area, _, hint_area, _] = Layout::vertical([ Constraint::Length(top_pad), Constraint::Length(logo_line_count), Constraint::Length(2), Constraint::Length(2), Constraint::Min(1), Constraint::Length(1), Constraint::Min(0), ]) .areas(content_area); render_logo(logo_area, buf, theme, content_area.height); let msg = Line::from(Span::styled( "Connecting...", Style::default().fg(theme.gray_bright), )) .alignment(Alignment::Center); Paragraph::new(msg).render(msg_area, buf); let hints = Line::from(quit_hint_spans(theme)).alignment(Alignment::Center); Paragraph::new(hints).render(hint_area, buf); (None, None) } } } /// Shrink a rect by `inset` columns on the left and right (clamped at 0). fn inset_horizontal(rect: Rect, inset: u16) -> Rect { Rect { x: rect.x + inset, width: rect.width.saturating_sub(inset * 2), ..rect } } /// Render the changelog section (header + bullets), centered to the menu width. /// When `clickable` (full notes exist) the whole block opens the notes on click /// and brightens while hovered; returns that clickable rect. #[allow(clippy::too_many_arguments)] fn render_changelog_section( area: Rect, buf: &mut Buffer, theme: &Theme, bullets: &[String], min_width_hint: u16, content_height: u16, clickable: bool, mouse_pos: Option<(u16, u16)>, ) -> Option { let menu_width = logo::logo_visual_width(content_height) .max(30) .max(min_width_hint); let [_, centered, _] = Layout::horizontal([ Constraint::Min(0), Constraint::Length(menu_width), Constraint::Min(0), ]) .flex(Flex::Center) .areas(area); if centered.width < 20 || centered.height == 0 { return None; } let hovered = clickable && mouse_pos.is_some_and(|(mx, my)| centered.contains(Position::new(mx, my))); let header_style = hover_style( theme, hovered, Style::default() .fg(theme.gray_bright) .add_modifier(Modifier::DIM), ); let title = "Changelog"; buf.set_span( centered.x, centered.y, &Span::styled(title, header_style), centered.width, ); let bullet_style = hover_style(theme, hovered, Style::default().fg(theme.gray_bright)); let max_text_width = centered.width.saturating_sub(2) as usize; // "• " prefix = 2 cols for (i, bullet) in bullets.iter().enumerate() { let row = centered.y + 2 + i as u16; if row >= centered.y + centered.height { break; } let truncated = crate::render::line_utils::truncate_str(bullet, max_text_width); let text = format!("\u{2022} {truncated}"); buf.set_span( centered.x, row, &Span::styled(text, bullet_style), centered.width, ); } clickable.then_some(centered) } /// Wrap width of the stacked info slot, centered at the menu width inside the /// inset. Both `compute`'s height measurement and `render_announcement_section` /// go through here — same width, no drift. `logo_height` selects the min menu /// width. fn stacked_info_width(avail_width: u16, logo_height: u16, min_width_hint: u16) -> u16 { logo::logo_visual_width(logo_height) .max(30) .max(min_width_hint) .min(avail_width) } /// Largest info-slot height the stacked column can allocate, mirroring /// [`WelcomeLayout::effective_changelog`]. Compact never shows the slot. fn stacked_info_budget( content_area: Rect, error_height: u16, menu_height: u16, tip_height: u16, compact: bool, ) -> u16 { if compact { return 0; } let logo_rows = logo_line_count(content_area.height); let gap_after_logo = if error_height > 0 { 1u16 } else { 0 }; let fixed_above = logo_rows + 1 + gap_after_logo + error_height; let fixed_below = WelcomeLayout::fixed_below(tip_height); // +1 info-slot gap, +1 min flex gap above the tip. content_area .height .saturating_sub(fixed_above + menu_height + 1 + fixed_below + 1) } /// Render the announcement in the stacked info slot, centered to the menu width. /// Returns `(block_rect, truncated)`: the clickable block and the overflow flag. #[allow(clippy::too_many_arguments)] fn render_announcement_section( area: Rect, buf: &mut Buffer, theme: &Theme, announcement: &xai_grok_announcements::RemoteAnnouncement, min_width_hint: u16, content_height: u16, expanded: bool, mouse_pos: Option<(u16, u16)>, upgrade_cta: Option<&str>, ) -> (Option, bool, Option) { // Same width the height pre-pass reserved for (see `stacked_info_width`). let menu_width = stacked_info_width(area.width, content_height, min_width_hint); let [_, centered, _] = Layout::horizontal([ Constraint::Min(0), Constraint::Length(menu_width), Constraint::Min(0), ]) .flex(Flex::Center) .areas(area); if centered.width < 20 || centered.height == 0 { return (None, false, None); } // Mirror the hero: reserve the CTA rows at the bottom, draw the text into // what's left, then place the `[label]` button right after the drawn text. let (text_area, truncated, cta_rect) = hero_box::render_announcement_with_upgrade_cta( buf, theme, centered, announcement, expanded, mouse_pos, upgrade_cta, ); (Some(text_area), truncated, cta_rect) } /// Render the normal welcome screen (Done state -- already authenticated). fn render_welcome_done( content_area: Rect, buf: &mut Buffer, theme: &Theme, p: &WelcomeRenderParams<'_>, prompt: &mut PromptWidget, session_picker_state: &mut crate::views::picker::PickerState, h_margin: u16, ) -> WelcomeRenderResult { let show_picker = p.session_picker.is_some() || p.session_picker_loading; // Only use compact layout when the session picker is visible — it needs // the logo/centering space for its list. Plain compact mode keeps the // normal welcome layout. let welcome_compact = show_picker; let cta = p .gate .and_then(|g| g.label.as_deref()) .unwrap_or("Upgrade Subscription"); let in_vscode_family = welcome_in_vscode_family(); let (key_g, key_l, key_q) = ( "ctrl+g", "ctrl+l", if in_vscode_family { "ctrl+d" } else { "ctrl+q" }, ); // Heights that don't depend on the menu — computed first so the menu // builder can probe the layout to decide whether to add a Changelog row. // Startup-warning hint height (multi-line aware). Must pick the same // entry `render_startup_warnings` draws — see `startup::banner_warning`. let hint_height = crate::startup::banner_warning(p.startup_warnings).map_or(0u16, |w| { let msg_lines = w.message.lines().count() as u16; let action_line = if w.action.is_some() { 1 } else { 0 }; msg_lines + action_line + 1 // +1 for buffer spacing }); let has_update_tip = p.pending_update_version.is_some(); let has_resume_tip = !has_update_tip && p.foreign_resume_hint.is_some(); let tip_height = if !show_picker { if has_update_tip || has_resume_tip { 1u16 // update/resume tips are short, always 1 row } else if let Some(tip_text) = p.tip { let inset = prompt::prompt_inset(welcome_compact); let tip_width = content_area.width.saturating_sub(inset * 2); crate::tips::render::tip_height(tip_width, tip_text) } else { 0 } } else { 0 }; let changelog_height = if p.has_access && !show_picker && !p.changelog_bullets.is_empty() { 2 + p.changelog_bullets.len() as u16 } else { 0 }; // Changelog is reachable via this menu row (ctrl+l). Show from the first // frame so the menu doesn't shift while the CDN fetch completes. let show_changelog_action = p.has_access && !show_picker; let gate_menu; let owned_menu; let menu_items: &[(&str, &str)] = if !p.has_access { gate_menu = [(key_g, cta), (key_l, "Logout"), (key_q, "Quit")]; &gate_menu } else { let (key_w, key_s, key_q, key_i_with_x) = ( "ctrl+w", "ctrl+s", if in_vscode_family { "ctrl+d" } else { "ctrl+q" }, "ctrl+i [x]", ); // Insert the import row at the top when there are pending `.claude/` // settings to import — it's the most actionable item right now. let mut items: Vec<(&str, &str)> = Vec::with_capacity(5); if p.has_claude_import { // The trailing "[x]" is a clickable dismiss affordance — the // welcome screen mouse handler treats clicks on the rightmost // 3 cells of this row as dismiss instead of open. Keyboard: // ctrl-shift-i. The key string is right-aligned by render_menu, // so [x] sits at the very end of the row. items.push((key_i_with_x, "Import Claude settings")); } items.push((key_w, "New worktree")); items.push((key_s, "Resume session")); // "Changelog" above Quit; no shortcut — opened by click (row or block). if show_changelog_action { items.push(("", "Changelog")); } items.push((key_q, "Quit")); owned_menu = items; owned_menu.as_slice() }; let menu_height = if show_picker { 0 } else { menu_items.len() as u16 }; // Session picker height: 1 row per entry (no dividers), scrollable. let picker_count = p.session_picker.map_or(0, |s| s.len()); let picker_height = if show_picker { if p.session_picker_loading { 1 } else { (picker_count as u16).min(15) + 3 // +3 for title + search + gap } } else { 0 }; let content_height = menu_height + picker_height; // The layout measures the announcement slot itself (collapsed: title + up to // 2 wrapped lines; expanded: the full message, clamped so the box fits). let layout = WelcomeLayout::compute(WelcomeLayoutInput { content_area, error_height: hint_height, menu_height: content_height, tip_height, changelog_height, compact: welcome_compact, prompt_compact: p.compact, announcement: p.announcement, expanded: p.welcome_announcement_expanded, has_upgrade_cta: p.upgrade_cta.is_some(), }); // Render startup warning in the error area (same slot as auth errors). let import_banner_rect = render_startup_warnings(layout.error, buf, theme, p.startup_warnings); // Hit-rects / truncation flag, set by whichever layout draws each block. let mut changelog_cta_rect: Option = None; let mut announcement_truncated = false; let mut announcement_rect: Option = None; let mut upgrade_cta_rect: Option = None; let (menu_rects, picker_close_button) = if show_picker { // Use the full area since logo/menu are hidden and shortcuts // are now rendered inside the picker content area. let picker_area = Rect { x: content_area.x, y: content_area.y, width: content_area.width, height: content_area.height, }; let hit_areas = render_session_picker( picker_area, buf, theme, &mut SessionPickerRenderCtx { state: session_picker_state, sessions: p.session_picker, loading: p.session_picker_loading, pending_hint: p.pending_hint, shortcuts_area: None, content_results: p.session_picker_content_results, content_loading: p.session_picker_content_loading, entries_query: p.session_picker_entries_query, tick: p.welcome_tick, grouped: p.session_picker_grouped, source_filter: p.session_picker_source_filter, chat_mode: p.chat_mode, cwd: p.cwd, }, ); (vec![], Some(hit_areas)) } else if layout.has_hero_box() { // Wide layout: render bordered hero box with logo left, version + menu right. let rects = hero_box::render_hero_box( &layout, buf, theme, menu_items, p.selected, p.mouse_pos, p.announcement, p.welcome_announcement_expanded, p.changelog_bullets, p.changelog_has_full_notes, p.upgrade_cta, ); changelog_cta_rect = rects.changelog_cta_rect; announcement_truncated = rects.announcement_truncated; announcement_rect = rects.announcement_rect; upgrade_cta_rect = rects.upgrade_cta_rect; (rects.menu_rects, None) } else { // Narrow layout: stacked logo above, menu below. Inset the menu the // same as the input bar (`prompt_inset`) so it keeps side spacing // instead of touching the window edge on narrow terminals. render_logo(layout.logo, buf, theme, content_area.height); let menu_area = inset_horizontal(layout.menu, prompt::prompt_inset(p.compact)); ( render_menu( menu_area, buf, theme, menu_items, p.selected, p.mouse_pos, MENU_MIN_WIDTH, ), None, ) }; // Stacked info slot below the menu (narrow layout): show the announcement // or the changelog (announcement takes priority), mirroring the hero box. // Inset to match the input bar so it lines up with the menu above. if layout.changelog.height > 0 { let info_area = inset_horizontal(layout.changelog, prompt::prompt_inset(p.compact)); if let Some(ann) = p.announcement { let (block, truncated, cta_rect) = render_announcement_section( info_area, buf, theme, ann, MENU_MIN_WIDTH, content_area.height, p.welcome_announcement_expanded, p.mouse_pos, p.upgrade_cta, ); announcement_rect = block; announcement_truncated = truncated; upgrade_cta_rect = cta_rect; } else { changelog_cta_rect = render_changelog_section( info_area, buf, theme, p.changelog_bullets, MENU_MIN_WIDTH, content_area.height, p.changelog_has_full_notes, p.mouse_pos, ); } } // Skip the prompt input when picker is visible to save space; // shortcuts are rendered inside the picker content area. let mut refresh_hit_rect: Option = None; let mut gate_url_hit_rect: Option = None; let (cursor_pos, post_flush_escapes) = if show_picker { (None, None) } else if !p.has_access { // Show CTA message and version instead of the prompt. let [_, centered, _] = Layout::horizontal([ Constraint::Min(0), Constraint::Length(content_area.width), Constraint::Min(0), ]) .flex(Flex::Center) .areas(layout.prompt); // Show the user's current tier + clickable refresh button above the gate message. let tier_label = p.subscription_tier.unwrap_or("Free"); let tier_prefix = format!("Tier: {tier_label} "); let refresh_text = "[Refresh]"; let total_width = tier_prefix.len() + refresh_text.len(); let tier_line = Line::from(vec![ Span::styled("Tier: ", Style::default().fg(theme.gray)), Span::styled( tier_label, Style::default() .fg(theme.gray_bright) .add_modifier(Modifier::BOLD), ), Span::styled(" ", Style::default()), Span::styled( refresh_text, Style::default() .fg(theme.accent_user) .add_modifier(Modifier::UNDERLINED), ), ]) .alignment(Alignment::Center); let tier_area = Rect { height: 1, ..centered }; Paragraph::new(tier_line).render(tier_area, buf); // Compute the click rect for "[Refresh]" within the centered line. let line_start_x = tier_area.x + tier_area.width.saturating_sub(total_width as u16) / 2; refresh_hit_rect = Some(Rect { x: line_start_x + tier_prefix.len() as u16, y: tier_area.y, width: refresh_text.len() as u16, height: 1, }); let gate_text = p .gate .map(|g| g.message.as_str()) .unwrap_or("SuperGrok subscription required"); let msg = Line::from(Span::styled( gate_text, Style::default().fg(theme.gray_bright), )) .alignment(Alignment::Center); Paragraph::new(msg).render( Rect { y: centered.y + 1, height: 1, ..centered }, buf, ); if centered.height > 2 { let url_area = Rect { y: centered.y + 2, height: 1, ..centered }; let gate_link = p .gate .and_then(|g| g.url.as_deref()) .unwrap_or("https://grok.com/supergrok?referrer=grok-build"); let url = Line::from(Span::styled( gate_link, Style::default() .fg(theme.accent_user) .add_modifier(Modifier::UNDERLINED), )) .alignment(Alignment::Center); Paragraph::new(url).render(url_area, buf); // Compute click rect for the gate URL text (centered within url_area). let link_width = gate_link.len() as u16; let link_x = url_area.x + url_area.width.saturating_sub(link_width) / 2; gate_url_hit_rect = Some(Rect { x: link_x, y: url_area.y, width: link_width.min(url_area.width), height: 1, }); } render_version_badge( layout.version, buf, theme, p.team_name, h_margin, p.is_api_key_auth, VersionBadgeMode::Full { subscription_tier: p.subscription_tier, }, ); (None, None) } else { // When a background update is available, show the update // notification in the tip area instead of the random tip. // Render the update notification with accent styling when present. if let Some(ver) = p.pending_update_version && layout.tip.height > 0 { let [_, tip_centered, _] = Layout::horizontal([ Constraint::Min(0), Constraint::Length(content_area.width), Constraint::Min(0), ]) .flex(Flex::Center) .areas(layout.tip); let inset = prompt::prompt_inset(p.compact); let tip_inset = Rect { x: tip_centered.x + inset, y: tip_centered.y, width: tip_centered.width.saturating_sub(inset * 2), height: tip_centered.height, }; let key_name = "ctrl+u"; let line = Line::from(vec![ Span::styled( "Update: ", Style::default() .fg(theme.accent_user) .add_modifier(Modifier::BOLD), ), Span::styled( format!("v{ver} available \u{2014} press {key_name} to restart"), Style::default().fg(theme.accent_user), ), ]); Paragraph::new(line) .style(Style::default().bg(theme.bg_base)) .render(tip_inset, buf); } // Recent foreign session: offer a one-click resume in the tip area // (only when no update is pending — the update shares ctrl+u and wins). if p.pending_update_version.is_none() && let Some(hint) = p.foreign_resume_hint && layout.tip.height > 0 { let [_, tip_centered, _] = Layout::horizontal([ Constraint::Min(0), Constraint::Length(content_area.width), Constraint::Min(0), ]) .flex(Flex::Center) .areas(layout.tip); let inset = prompt::prompt_inset(p.compact); let tip_inset = Rect { x: tip_centered.x + inset, y: tip_centered.y, width: tip_centered.width.saturating_sub(inset * 2), height: tip_centered.height, }; let mins = hint.age.as_secs() / 60; let when = if mins == 0 { "moments ago".to_string() } else { format!("{mins}m ago") }; let accent = Style::default().fg(theme.accent_user); let accent_bold = accent.add_modifier(Modifier::BOLD); let tool = crate::app::foreign_tool_display_label(hint.tool); let line = Line::from(vec![ Span::styled("Coming from ", accent), Span::styled(tool, accent_bold), Span::styled(format!("? Resume your session from {when} using "), accent), Span::styled("ctrl+u", accent_bold), ]); Paragraph::new(line) .style(Style::default().bg(theme.bg_base)) .render(tip_inset, buf); } let warning = p.credit_balance.and_then(|bal| { crate::views::credit_bar::usage_warning(bal, p.auto_topup, p.usage_visible) }); let (usage_warning_text, usage_warning_critical) = match warning { Some((text, critical)) => (Some(text), critical), None => (None, false), }; let usage_info = PromptInfo { model_name: p.model_name, flags: p.flags, multiline: false, usage_warning: usage_warning_text.as_deref(), usage_warning_critical, }; render_prompt_and_version( &layout, content_area.width, buf, theme, p.prompt_focus, prompt, &usage_info, if p.pending_update_version.is_some() || p.foreign_resume_hint.is_some() { // Update/resume tip already rendered above with custom styling. None } else { p.tip }, p.team_name, h_margin, p.compact, p.pending_hint, p.is_api_key_auth, layout.has_hero_box(), ) }; WelcomeRenderResult { cursor_pos, post_flush_escapes, menu_rects, prompt_rect: if show_picker || !p.has_access { None } else { Some(layout.prompt) }, session_picker_hit_areas: picker_close_button, import_banner_rect, auth_url_rect: None, auth_fallback_rect: None, refresh_rect: refresh_hit_rect, gate_url_rect: gate_url_hit_rect, changelog_action_present: show_changelog_action, changelog_cta_rect, announcement_truncated, announcement_rect, upgrade_cta_rect, } } /// Context for session picker rendering. pub(crate) struct SessionPickerRenderCtx<'a> { pub(crate) state: &'a mut crate::views::picker::PickerState, pub(crate) sessions: Option<&'a [SessionPickerEntry]>, /// Live working directory (tracks `Effect::SetWorkingDir`), used to pin /// the current repo's group to the top. pub(crate) cwd: &'a std::path::Path, pub(crate) loading: bool, pub(crate) pending_hint: Option, pub(crate) shortcuts_area: Option, pub(crate) content_results: Option<&'a [xai_grok_shell::extensions::session_search::SearchSessionHit]>, pub(crate) content_loading: bool, /// The query `sessions` were server-fetched with (see /// [`crate::views::session_picker::effective_filter_query`]). pub(crate) entries_query: Option<&'a str>, pub(crate) tick: u64, /// When true, entries are grouped by `repo_name` with non-selectable headers. pub(crate) grouped: bool, /// Source filter (local/remote/all) for filtering session entries. pub(crate) source_filter: crate::views::session_picker::SourceFilter, /// Process-wide `--chat`: hides the source-filter chip and the /// deep-search/filter footer hints (see `WelcomeRenderParams::chat_mode`). pub(crate) chat_mode: bool, } /// Render the session picker list on the welcome screen. /// /// Builds `PickerEntry` items from `SessionPickerEntry` data and delegates to /// `render_picker`. Returns `PickerHitAreas` for mouse hit-testing. pub(crate) fn render_session_picker( area: Rect, buf: &mut Buffer, theme: &Theme, ctx: &mut SessionPickerRenderCtx<'_>, ) -> crate::views::picker::PickerHitAreas { use crate::views::picker::{self, PickerConfig, PickerEntry, PickerField, PickerRow}; use crate::views::session_picker::{ SessionEntryData, build_grouped_picker_entries, build_session_entry_data, }; let entries_data = match ctx.sessions { Some(s) => s, None => &[], }; // Filter entries by query and source (shared helper). The same effective // query must drive filtering AND the content header/rows gates below, or // this render disagrees with `handle_welcome_input`'s `build_entry_map` // (which receives the effective query) on row indices. let filter_query = crate::views::session_picker::effective_filter_query(&ctx.state.query, ctx.entries_query); let filtered_indices = crate::app::app_view::filter_session_entries(ctx.sessions, filter_query, ctx.source_filter); let content_width = area.width; // approximate for truncation let built = build_session_entry_data(entries_data, &filtered_indices, ctx.state, content_width); // Build PickerEntry refs that borrow from `built`. let fields_vecs: Vec> = built .iter() .map(|b| { b.field_data .iter() .map(|(l, v)| PickerField { label: l, value: v }) .collect() }) .collect(); // Build picker entries, optionally grouped by repo_name. let (mut picker_entries, non_selectable_indices) = if ctx.grouped { let current_repo = crate::views::session_picker::repo_name_from_cwd(&ctx.cwd.to_string_lossy()); build_grouped_picker_entries( entries_data, &filtered_indices, &built, &fields_vecs, ctx.state, Some(current_repo.as_str()), ) } else { let entries: Vec = built .iter() .zip(fields_vecs.iter()) .map(|(b, fields)| { PickerEntry::Row(PickerRow { label: &b.summary, right_label: &b.right_text, selected: b.is_selected, expanded: b.is_expanded, fields, description_lines: &[], summary_lines: &[], dimmed: false, indent: 0, badge: b.badge, badge_color: None, collapsible: b.collapsible, underline_last_desc: false, }) }) .collect(); (entries, Vec::new()) }; // Append content search result rows (shared helper handles dedup). use crate::views::session_picker::{build_content_entry_data, build_content_header_label}; // Content rows will start after fuzzy rows + 1 header row. let content_start = picker_entries.len() + 1; let content_entry_data: Vec = if let Some(hits) = ctx.content_results && ctx.source_filter != crate::views::session_picker::SourceFilter::External && !filter_query.is_empty() { build_content_entry_data( hits, entries_data, &filtered_indices, ctx.state, content_start, ) } else { Vec::new() }; // Show header only if there are actual deduped content rows to display. let has_content_rows = !content_entry_data.is_empty(); let content_loading = ctx.content_loading && ctx.source_filter != crate::views::session_picker::SourceFilter::External; let spinner_label = build_content_header_label(content_loading, has_content_rows, ctx.tick); // Only show the header when content results exist or when content // search is in progress with a non-empty query. This must match the // header condition inside `build_entry_map` as called from // `handle_welcome_input` (app_view.rs) so the input handler's // `entry_count` agrees with the rendered entry list — a mismatch causes // arrow-key selection to target the wrong row. Both sides therefore gate // on the same EFFECTIVE query (`filter_query`), not the live one. let show_content_header = has_content_rows || (content_loading && !filter_query.trim().is_empty()); if show_content_header { picker_entries.push(PickerEntry::Header { label: &spinner_label, }); } let content_fields: Vec> = content_entry_data .iter() .map(|b| { b.field_data .iter() .map(|(l, v)| PickerField { label: l, value: v }) .collect() }) .collect(); let content_snippets: Vec<[&str; 1]> = content_entry_data .iter() .map(|b| [b.snippet_preview.as_deref().unwrap_or("")]) .collect(); for (i, (b, fields)) in content_entry_data .iter() .zip(content_fields.iter()) .enumerate() { let has_snippet = b.snippet_preview.is_some(); picker_entries.push(PickerEntry::Row(PickerRow { label: &b.summary, right_label: &b.right_text, selected: b.is_selected, expanded: b.is_expanded, fields, description_lines: if has_snippet { &content_snippets[i] } else { &[] }, summary_lines: &[], dimmed: false, indent: 1, badge: if has_snippet { "match" } else { "" }, badge_color: Some(theme.accent_user), collapsible: true, underline_last_desc: false, })); } // Build shortcuts for fullscreen mode. Chat mode drops the worktree / // deep-search / filter hints (local-Build-row actions). let worktree_shortcut: &'static str = "ctrl+w"; use crate::views::shortcuts_bar::HintItem; let mut default_shortcuts: Vec = vec![ HintItem::new(crate::key!(Esc), "back"), HintItem::new(crate::key!(Enter), "select"), ]; if !ctx.chat_mode { default_shortcuts.push(HintItem { keys: vec![], label: "worktree".into(), custom_display: Some(worktree_shortcut), description: None, pinned: false, }); } default_shortcuts.push(HintItem { keys: vec![], label: "navigate".into(), custom_display: Some("\u{2191}\u{2193}"), description: None, pinned: false, }); if !ctx.chat_mode { default_shortcuts.push(HintItem { keys: vec![], label: "filter".into(), custom_display: Some("f"), description: None, pinned: false, }); } let config = PickerConfig { title: Some("Resume session"), show_search_hint: true, expandable: true, esc_clears_query: true, shortcuts: Some(&default_shortcuts), pending_hint: ctx.pending_hint, non_selectable: &non_selectable_indices, non_selectable_clickable: &[], shortcuts_area: ctx.shortcuts_area, tabs: None, active_tab: 0, filter_label: (!ctx.chat_mode).then(|| ctx.source_filter.label()), filter_key_hint: (!ctx.chat_mode).then_some("f"), filter_active: !ctx.chat_mode && ctx.source_filter.is_active(), action_keys: &[], disable_search: false, compact_bottom_bar: false, search_only_on_slash: false, vim_normal_first: crate::appearance::cache::load_vim_mode(), }; picker::render_picker( buf, area, theme, ctx.state, &picker_entries, &config, ctx.loading, ) } /// Render the auth token input box (loopback mode). fn render_auth_input_box(area: Rect, buf: &mut Buffer, theme: &Theme, input: &str) { let prompt_block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(theme.accent_user)) .padding(Padding { left: 2, right: 1, top: 0, bottom: 0, }); let inner = prompt_block.inner(area); prompt_block.render(area, buf); if inner.height > 0 && inner.width > 2 { let display = mask_auth_token_for_display(input); let style = if input.is_empty() { Style::default().fg(theme.gray_dim) } else { Style::default().fg(theme.accent_user) }; let line = Line::from(vec![ Span::styled( crate::glyphs::prompt_arrow(), Style::default().fg(theme.accent_user), ), Span::styled(display, style), ]); buf.set_line(inner.x, inner.y, &line, inner.width); } } /// Render one startup warning centered in the given area. /// /// `startup_warnings` can hold more than one entry (the WezTerm /// kitty-keyboard banner is prepended ahead of `summarize_warnings()` /// output — see `diagnostics::assemble_startup_warnings`), but only one is /// rendered — the severity-aware pick from `startup::banner_warning`, so a /// runtime-pushed Warning displaces an earlier Info entry; all of them point /// at `/terminal-setup`, which lists every issue. One message line, one /// optional action line, plus a buffer row for spacing. Severity controls /// color (yellow for `Warning`, dim for `Info`). fn render_startup_warnings( area: Rect, buf: &mut Buffer, theme: &Theme, warnings: &[StartupWarning], ) -> Option { let w = crate::startup::banner_warning(warnings)?; // Skip the import-claude startup warning entirely — the import row in the // menu now carries the call-to-action with the same visual weight as // every other welcome menu item. Showing the warning text in addition to // the menu row would be redundant noise. if w.message.starts_with("Import Claude settings") || w.message.starts_with("Claude settings detected") { return None; } let color = match w.severity { crate::startup::WarningSeverity::Warning => theme.warning, crate::startup::WarningSeverity::Info => theme.gray_dim, }; let style = Style::default().fg(color); let mut lines: Vec> = w .message .lines() .map(|l| Line::from(Span::styled(l, style)).alignment(Alignment::Center)) .collect(); if let Some(ref action) = w.action { lines.push(Line::from(Span::styled(action.as_str(), style)).alignment(Alignment::Center)); } Paragraph::new(lines).render(area, buf); None } fn mask_auth_token_for_display(input: &str) -> String { use crate::render::line_utils::floor_char_boundary; if input.is_empty() { return "Paste your token here...".to_string(); } let len = input.len(); if len <= 8 { return input.to_string(); } let boundary = floor_char_boundary(input, len - 4); let visible = &input[boundary..]; let masked_count = input[..boundary].chars().count(); format!("{}{}", "\u{2022}".repeat(masked_count), visible) } #[cfg(test)] mod tests { use super::*; use crate::app::app_view::SessionPickerEntry; use crate::views::picker::PickerState; use crate::views::session_picker::{build_grouped_picker_entries, build_session_entry_data}; #[test] fn mask_auth_token_cases() { assert_eq!(mask_auth_token_for_display(""), "Paste your token here..."); assert_eq!(mask_auth_token_for_display("12345678"), "12345678"); let masked = mask_auth_token_for_display("abcdefghij"); assert!(masked.ends_with("ghij")); assert!(masked.starts_with("\u{2022}")); // Regression: multi-byte input panicked on byte-index slicing let masked = mask_auth_token_for_display("测试令牌一二三四五六"); assert!(masked.starts_with("\u{2022}")); } fn make_entry(id: &str, summary: &str, repo_name: &str) -> SessionPickerEntry { SessionPickerEntry { id: id.into(), summary: summary.into(), updated_at: chrono::Utc::now(), created_at: chrono::Utc::now(), cwd: format!("/home/user/{repo_name}"), hostname: None, source: "local".into(), model_id: None, num_messages: 1, last_active_at: None, branch: None, repo_name: repo_name.into(), worktree_label: None, card_detail: None, } } fn render_params<'a>( auth_state: &'a AuthState, trust_state: &'a TrustState, session_picker: Option<&'a [SessionPickerEntry]>, ) -> WelcomeRenderParams<'a> { WelcomeRenderParams { prompt_focus: WelcomePromptFocus::Unfocused, auth_state, trust_state, login_label: None, auth_code_input: "", clipboard_copied: false, show_raw_url: false, announcement: None, tip: None, model_name: "test", flags: &[], selected: None, team_name: None, has_access: true, has_claude_import: false, mouse_pos: None, is_zdr_blocked: false, session_picker, session_picker_loading: false, compact: false, pending_hint: None, startup_warnings: &[], pending_update_version: None, foreign_resume_hint: None, is_api_key_auth: false, session_picker_content_results: None, session_picker_content_loading: false, session_picker_entries_query: None, welcome_tick: 0, gate: None, subscription_tier: None, session_picker_grouped: false, session_picker_source_filter: crate::views::session_picker::SourceFilter::All, chat_mode: false, cwd: std::path::Path::new("/repo"), credit_balance: None, auto_topup: None, usage_visible: true, changelog_bullets: &[], changelog_has_full_notes: false, welcome_announcement_expanded: false, upgrade_cta: None, } } fn render_done_text(params: &WelcomeRenderParams<'_>) -> String { let area = Rect::new(0, 0, 100, 40); let mut buf = Buffer::empty(area); let mut prompt = PromptWidget::new(); let mut picker = PickerState::default(); render_welcome(area, &mut buf, params, &mut prompt, &mut picker); buffer_text(&buf) } #[test] fn foreign_resume_tip_names_each_tool_and_age() { use xai_grok_workspace::foreign_sessions::ForeignSessionTool; let auth = AuthState::Done; let trust = TrustState::Done; for (tool, label) in [ (ForeignSessionTool::Claude, "Claude Code"), (ForeignSessionTool::Codex, "Codex"), (ForeignSessionTool::Cursor, "Cursor"), ] { let hint = xai_grok_workspace::foreign_sessions::RecentForeignSession { tool, native_id: "native-id".into(), age: std::time::Duration::from_secs(125), }; let mut params = render_params(&auth, &trust, None); params.foreign_resume_hint = Some(&hint); let text = render_done_text(¶ms); assert!(text.contains(&format!("Coming from {label}?")), "{text}"); assert!(text.contains("2m ago"), "{text}"); assert!(text.contains("ctrl+u"), "{text}"); } } #[test] fn pending_update_suppresses_foreign_resume_tip() { let auth = AuthState::Done; let trust = TrustState::Done; let hint = xai_grok_workspace::foreign_sessions::RecentForeignSession { tool: xai_grok_workspace::foreign_sessions::ForeignSessionTool::Cursor, native_id: "native-id".into(), age: std::time::Duration::from_secs(30), }; let mut params = render_params(&auth, &trust, None); params.foreign_resume_hint = Some(&hint); params.pending_update_version = Some("9.9.9"); let text = render_done_text(¶ms); assert!(text.contains("v9.9.9 available"), "{text}"); assert!(!text.contains("Coming from Cursor?"), "{text}"); } fn png() -> [u8; 8] { [0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n'] } fn seed_static_owner(owner_id: u64) { let _ = crate::terminal::overlay::static_image(&png(), 20, 10, 0, 0, owner_id) .unwrap() .commit(); } fn assert_promptless_clear(result: WelcomeRenderResult, owner_id: u64) { let post_flush = result .post_flush_escapes .expect("promptless welcome must clear ID 1"); assert!(post_flush.as_str().contains("a=d")); let before_write = crate::terminal::overlay::static_image(&png(), 20, 10, 0, 0, owner_id).unwrap(); assert!( !before_write.as_str().contains("a=t"), "constructing the clear must not commit ownership" ); post_flush.write_to(&mut Vec::new()).unwrap(); let after_write = crate::terminal::overlay::static_image(&png(), 20, 10, 0, 0, owner_id).unwrap(); assert!( after_write.as_str().contains("a=t"), "writing the clear must commit ownership" ); } #[test] fn authenticating_welcome_returns_paired_overlay_clear() { let _guard = crate::terminal::image::set_protocol_for_test( crate::terminal::image::GraphicsProtocol::Kitty, ); crate::terminal::overlay::reset_owner(); seed_static_owner(81); let auth_state = AuthState::Authenticating { request_seq: 1, handle: None, auth_url: None, mode: AuthMode::Command, }; let trust_state = TrustState::Done; let params = render_params(&auth_state, &trust_state, None); let area = Rect::new(0, 0, 100, 40); let mut buf = Buffer::empty(area); let mut prompt = PromptWidget::new(); let mut picker = PickerState::default(); let result = render_welcome(area, &mut buf, ¶ms, &mut prompt, &mut picker); assert_promptless_clear(result, 81); } #[test] fn picker_welcome_returns_paired_overlay_clear() { let _guard = crate::terminal::image::set_protocol_for_test( crate::terminal::image::GraphicsProtocol::Kitty, ); crate::terminal::overlay::reset_owner(); seed_static_owner(82); let auth_state = AuthState::Done; let trust_state = TrustState::Done; let sessions = [make_entry("session-1", "summary", "repo")]; let params = render_params(&auth_state, &trust_state, Some(&sessions)); let area = Rect::new(0, 0, 100, 40); let mut buf = Buffer::empty(area); let mut prompt = PromptWidget::new(); let mut picker = PickerState::default(); let result = render_welcome(area, &mut buf, ¶ms, &mut prompt, &mut picker); assert_promptless_clear(result, 82); } /// RENDER half of the header-gate invariant (input half: /// `session_picker::tests::grouped_entry_map_empty_query_with_loading_has_no_header`): /// with stamp==live and a re-search in flight, the "Searching…" header /// must NOT render — a render-only header row shifts arrow-key row /// indices. Control leg: the same search WITHOUT the stamp keeps it. #[test] fn render_header_gate_uses_effective_query() { use ratatui::buffer::Buffer; use ratatui::layout::Rect; let theme = crate::theme::Theme::default(); let area = Rect::new(0, 0, 80, 20); // Content-only hit: title shares nothing with the query "hit". let entries = vec![make_entry("conv-1", "Quarterly roadmap notes", "repo")]; let render = |entries_query: Option<&str>| -> String { let mut buf = Buffer::empty(area); let mut state = PickerState { query: "hit".into(), ..PickerState::default() }; render_session_picker( area, &mut buf, &theme, &mut SessionPickerRenderCtx { state: &mut state, sessions: Some(&entries), cwd: std::path::Path::new("/repo"), loading: false, pending_hint: None, shortcuts_area: None, content_results: None, content_loading: true, entries_query, tick: 0, grouped: false, source_filter: crate::views::session_picker::SourceFilter::All, chat_mode: true, }, ); (0..area.height) .map(|y| { (0..area.width) .map(|x| { buf.cell((x, y)) .map_or(' ', |c| c.symbol().chars().next().unwrap_or(' ')) }) .collect::() }) .collect::>() .join("\n") }; let stamped = render(Some("hit")); assert!( !stamped.contains("Searching session content"), "stamp==live must not render the search header:\n{stamped}" ); assert!( stamped.contains("Quarterly roadmap notes"), "stamped server hit must render:\n{stamped}" ); // Control: unstamped in-flight search keeps the header, proving the // negative assertion above exercises the gate. let unstamped = render(None); assert!( unstamped.contains("Searching session content"), "in-flight search without the stamp must render the header:\n{unstamped}" ); } #[test] fn grouped_entries_insert_headers() { let entries = vec![ make_entry("s1", "Fix auth", "xai"), make_entry("s2", "Add streaming", "xai"), make_entry("s3", "Nuke tables", "fw-1"), ]; let indices: Vec = (0..entries.len()).collect(); let state = PickerState::default(); let built = build_session_entry_data(&entries, &indices, &state, 80); let fields_vecs: Vec> = built.iter().map(|_| Vec::new()).collect(); let (result, non_sel) = build_grouped_picker_entries(&entries, &indices, &built, &fields_vecs, &state, None); // 2 headers + 3 rows = 5 entries assert_eq!(result.len(), 5); // Groups are sorted alphabetically: fw-1 before xai. // Header positions: 0 (fw-1), 2 (xai) assert_eq!(non_sel.len(), 5); assert!(non_sel[0], "first entry should be header (non-selectable)"); assert!(!non_sel[1], "second entry should be selectable row"); assert!(non_sel[2], "third entry should be header (non-selectable)"); assert!(!non_sel[3], "fourth entry should be selectable row"); assert!(!non_sel[4], "fifth entry should be selectable row"); // Verify headers assert!( matches!(&result[0], crate::views::picker::PickerEntry::Header { label } if label == &"fw-1") ); assert!( matches!(&result[2], crate::views::picker::PickerEntry::Header { label } if label == &"xai") ); } #[test] fn grouped_entries_pin_current_repo_first() { // Render path (build_grouped_picker_entries) must pin the current // working directory's repo group ahead of the alphabetical rest, // matching build_entry_map's index ordering. let entries = vec![ make_entry("s1", "Fix auth", "aaa"), make_entry("s2", "Add streaming", "zzz"), ]; let indices: Vec = (0..entries.len()).collect(); let state = PickerState::default(); let built = build_session_entry_data(&entries, &indices, &state, 80); let fields_vecs: Vec> = built.iter().map(|_| Vec::new()).collect(); // Pin "zzz": it leads despite sorting last alphabetically. let (result, _) = build_grouped_picker_entries( &entries, &indices, &built, &fields_vecs, &state, Some("zzz"), ); assert!( matches!(&result[0], crate::views::picker::PickerEntry::Header { label } if label == &"zzz"), "current repo group pinned first" ); assert!( matches!(&result[2], crate::views::picker::PickerEntry::Header { label } if label == &"aaa"), "remaining group follows alphabetically" ); } #[test] fn grouped_entries_single_group_has_one_header() { let entries = vec![ make_entry("s1", "Fix auth", "xai"), make_entry("s2", "Add streaming", "xai"), ]; let indices: Vec = (0..entries.len()).collect(); let state = PickerState::default(); let built = build_session_entry_data(&entries, &indices, &state, 80); let fields_vecs: Vec> = built.iter().map(|_| Vec::new()).collect(); let (result, non_sel) = build_grouped_picker_entries(&entries, &indices, &built, &fields_vecs, &state, None); assert_eq!(result.len(), 3); // 1 header + 2 rows assert!(non_sel[0]); assert!(!non_sel[1]); assert!(!non_sel[2]); } #[test] fn grouped_entries_empty_input() { let entries: Vec = vec![]; let indices: Vec = vec![]; let state = PickerState::default(); let built = build_session_entry_data(&entries, &indices, &state, 80); let fields_vecs: Vec> = vec![]; let (result, non_sel) = build_grouped_picker_entries(&entries, &indices, &built, &fields_vecs, &state, None); assert!(result.is_empty()); assert!(non_sel.is_empty()); } #[test] fn grouped_entries_rows_are_indented() { let entries = vec![make_entry("s1", "Fix auth", "xai")]; let indices: Vec = vec![0]; let state = PickerState::default(); let built = build_session_entry_data(&entries, &indices, &state, 80); let fields_vecs: Vec> = built.iter().map(|_| Vec::new()).collect(); let (result, _) = build_grouped_picker_entries(&entries, &indices, &built, &fields_vecs, &state, None); // The row (second entry) should have indent=1 if let crate::views::picker::PickerEntry::Row(row) = &result[1] { assert_eq!(row.indent, 1); } else { panic!("expected Row, got Header"); } } fn resume_picker_config() -> crate::views::picker::PickerConfig<'static> { crate::views::picker::PickerConfig { title: Some("Resume session"), show_search_hint: true, expandable: true, esc_clears_query: true, shortcuts: None, pending_hint: None, non_selectable: &[], non_selectable_clickable: &[], shortcuts_area: None, tabs: None, active_tab: 0, filter_label: None, filter_key_hint: None, filter_active: false, action_keys: &[], disable_search: false, compact_bottom_bar: false, search_only_on_slash: false, vim_normal_first: false, } } #[test] fn e_key_expands_selected_entry_in_resume_picker() { use crate::views::picker::{PickerOutcome, handle_picker_input}; use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; let mut state = PickerState::default(); let config = resume_picker_config(); let ev = Event::Key(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)); let outcome = handle_picker_input(&ev, &mut state, 3, &config); assert!(matches!(outcome, PickerOutcome::Expand(0))); } #[test] fn e_key_routes_to_search_when_active() { use crate::views::picker::{PickerOutcome, handle_picker_input}; use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; let mut state = PickerState { search_active: true, ..PickerState::default() }; let config = resume_picker_config(); let ev = Event::Key(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)); let outcome = handle_picker_input(&ev, &mut state, 3, &config); assert!(matches!(outcome, PickerOutcome::Changed)); assert_eq!(state.query, "e"); } #[test] fn changelog_hidden_on_short_terminal() { let area = Rect::new(0, 0, 80, 15); let layout = WelcomeLayout::compute(WelcomeLayoutInput { content_area: area, menu_height: 4, changelog_height: 5, ..Default::default() }); assert_eq!(layout.changelog.height, 0); } #[test] fn changelog_shown_on_tall_terminal() { let area = Rect::new(0, 0, 80, 50); let layout = WelcomeLayout::compute(WelcomeLayoutInput { content_area: area, menu_height: 4, changelog_height: 5, ..Default::default() }); assert_eq!(layout.changelog.height, 5); } #[test] fn stacked_slot_sized_for_announcement_over_changelog() { // Narrow terminal (80 cols < 90 → no hero box). With both present, the // stacked info slot is sized for the announcement (priority), not the // changelog. let area = Rect::new(0, 0, 80, 50); let a = long_ann(); let layout = WelcomeLayout::compute(WelcomeLayoutInput { content_area: area, menu_height: 4, changelog_height: 5, announcement: Some(&a), ..Default::default() }); assert!(!layout.has_hero_box()); assert_eq!(layout.changelog.height, 3); } #[test] fn stacked_slot_uses_announcement_when_no_changelog() { // Narrow terminal, announcement but no changelog: the stacked slot is // still allocated for the announcement (it used to be changelog-only). let area = Rect::new(0, 0, 80, 50); let a = long_ann(); let layout = WelcomeLayout::compute(WelcomeLayoutInput { content_area: area, menu_height: 4, announcement: Some(&a), ..Default::default() }); assert!(!layout.has_hero_box()); assert_eq!(layout.changelog.height, 3); } #[test] fn changelog_hidden_when_compact() { let area = Rect::new(0, 0, 80, 60); let layout = WelcomeLayout::compute(WelcomeLayoutInput { content_area: area, menu_height: 4, changelog_height: 5, compact: true, prompt_compact: true, ..Default::default() }); assert_eq!(layout.changelog.height, 0); } #[test] fn changelog_hidden_when_zero_requested() { let area = Rect::new(0, 0, 80, 60); let layout = WelcomeLayout::compute(WelcomeLayoutInput { content_area: area, menu_height: 4, ..Default::default() }); assert_eq!(layout.changelog.height, 0); } #[test] fn changelog_boundary_exact_fit() { // No logo at h < 22. fixed_above = 0 + 1 + 0 + 0 = 1. // fixed_below = 0 (tip) + 0 (tip_gap) + 3 (prompt) + 1 (ver_gap) + 1 (ver) = 5. // min_without_changelog = 1 + 4 (menu) + 1 (flex) + 5 = 11. // changelog slot = 1 (gap) + 5 (height) = 6. Threshold = 11 + 6 = 17. let just_fits = Rect::new(0, 0, 80, 17); let layout = WelcomeLayout::compute(WelcomeLayoutInput { content_area: just_fits, menu_height: 4, changelog_height: 5, ..Default::default() }); assert_eq!(layout.changelog.height, 5); let too_short = Rect::new(0, 0, 80, 16); let layout = WelcomeLayout::compute(WelcomeLayoutInput { content_area: too_short, menu_height: 4, changelog_height: 5, ..Default::default() }); assert_eq!(layout.changelog.height, 0); } #[test] fn changelog_hidden_when_tip_steals_space() { // Use narrow width to avoid hero box path, keeping stacked layout. // With tip_height=2: fixed_below(2) = 8. min = 1 + 4 + 1 + 8 = 14. // Threshold = 14 + 6 = 20. At h=19 the tip pushes changelog out. let with_tip = Rect::new(0, 0, 60, 19); let layout = WelcomeLayout::compute(WelcomeLayoutInput { content_area: with_tip, menu_height: 4, tip_height: 2, changelog_height: 5, ..Default::default() }); assert_eq!(layout.changelog.height, 0); // Same size without tip: threshold = 17 <= 19, changelog fits. let without_tip = Rect::new(0, 0, 60, 19); let layout = WelcomeLayout::compute(WelcomeLayoutInput { content_area: without_tip, menu_height: 4, changelog_height: 5, ..Default::default() }); assert_eq!(layout.changelog.height, 5); } #[test] fn hero_box_active_on_wide_tall_terminal() { // 90 cols, 50 rows: meets the minimum for the hero box. let area = Rect::new(0, 0, 90, 50); let layout = WelcomeLayout::compute(WelcomeLayoutInput { content_area: area, menu_height: 4, ..Default::default() }); assert!(layout.has_hero_box(), "hero box should be active at 90x50"); assert!(layout.hero_box.width > 0); assert!(layout.hero_box.height > 0); // Logo and menu slots are zero in hero box mode (content is inside the box). assert_eq!(layout.logo.width, 0); assert_eq!(layout.menu.width, 0); // Sub-rects inside the hero box are valid. assert!(layout.hero_logo.height > 0); assert!(layout.hero_menu.height > 0); assert_eq!(layout.hero_version.height, 1); } #[test] fn hero_box_inactive_on_narrow_terminal() { // 80 cols is below the 90-col threshold. let area = Rect::new(0, 0, 80, 50); let layout = WelcomeLayout::compute(WelcomeLayoutInput { content_area: area, menu_height: 4, ..Default::default() }); assert!( !layout.has_hero_box(), "hero box should be inactive at 80x50" ); assert_eq!(layout.hero_box.width, 0); } #[test] fn hero_box_boundary_at_min_width() { let just_below = Rect::new(0, 0, 89, 50); let layout = WelcomeLayout::compute(WelcomeLayoutInput { content_area: just_below, menu_height: 4, ..Default::default() }); assert!( !layout.has_hero_box(), "hero box should be inactive at 89 cols" ); let at_threshold = Rect::new(0, 0, 90, 50); let layout = WelcomeLayout::compute(WelcomeLayoutInput { content_area: at_threshold, menu_height: 4, ..Default::default() }); assert!( layout.has_hero_box(), "hero box should be active at 90 cols" ); } #[test] fn hero_box_inactive_when_compact() { // Compact mode (session picker visible) never uses the hero box. let area = Rect::new(0, 0, 120, 50); let layout = WelcomeLayout::compute(WelcomeLayoutInput { content_area: area, menu_height: 4, compact: true, prompt_compact: true, ..Default::default() }); assert!( !layout.has_hero_box(), "hero box should be inactive in compact mode" ); assert_eq!(layout.hero_box.width, 0); } #[test] fn hero_box_inactive_on_short_terminal() { // 16 rows is one short of the 17 the box needs (11 box + 1 flex gap + // 5 fixed-below), so it falls back to the stacked layout. let area = Rect::new(0, 0, 90, 16); let layout = WelcomeLayout::compute(WelcomeLayoutInput { content_area: area, menu_height: 4, ..Default::default() }); assert!( !layout.has_hero_box(), "hero box should be inactive at 90x16 (needs 17 rows)" ); } #[test] fn hero_box_inactive_when_warning_would_overflow() { // Regression: the box is forced to the full 7-row logo, so even a // 3-item menu needs 11 box rows. A startup warning (error_height = 2) // pushes the total past height 19, so the gate must fall back to the // stacked layout instead of overflowing by a row. let area = Rect::new(0, 0, 90, 19); let with_warning = WelcomeLayout::compute(WelcomeLayoutInput { content_area: area, error_height: 2, menu_height: 3, ..Default::default() }); assert!(!with_warning.has_hero_box()); // The same terminal fits the box once the warning is gone. let no_warning = WelcomeLayout::compute(WelcomeLayoutInput { content_area: area, menu_height: 3, ..Default::default() }); assert!(no_warning.has_hero_box()); } #[test] fn blocked_layout_stays_stacked_on_wide_terminal() { // The login / ZDR screens render through render_welcome_blocked, which // only paints the stacked logo/menu rects. compute_stacked must never // hand them a hero-box layout (which zeroes those rects), even on a // wide, tall terminal where the normal path picks the hero box. let area = Rect::new(0, 0, 120, 40); assert!( WelcomeLayout::compute(WelcomeLayoutInput { content_area: area, menu_height: 2, ..Default::default() }) .has_hero_box(), "sanity: the normal path should pick the hero box at 120x40" ); let blocked = WelcomeLayout::compute_stacked(WelcomeLayoutInput { content_area: area, menu_height: 2, ..Default::default() }); assert!(!blocked.has_hero_box()); assert!( blocked.logo.height > 0, "logo must be painted on the login screen" ); assert!( blocked.menu.height > 0, "menu must be painted on the login screen" ); } #[test] fn hero_box_does_not_overflow_with_tall_menu() { // A 6-item menu makes the box 2 rows taller than the default-4 box, so // the centering pad (derived from the default box) must be clamped or // the box gets pushed down and the version row clips at exactly // min_content_height. 19 == min_content_height(0, 6, 0, 0): a 13-row box // + 1 flex gap + 5 fixed-below. let area = Rect::new(0, 0, 100, 19); let layout = WelcomeLayout::compute(WelcomeLayoutInput { content_area: area, menu_height: 6, ..Default::default() }); assert!( layout.has_hero_box(), "hero box should be active at the boundary" ); // top_pad must clamp to 0, so the box sits at the top, not pushed down. assert_eq!( layout.hero_box.y, area.y, "box pushed down by unclamped pad" ); assert!( layout.version.y + layout.version.height <= area.y + area.height, "version row (y={}, h={}) overflows content height {}", layout.version.y, layout.version.height, area.height, ); } #[test] fn hero_box_height_accounts_for_borders_and_padding() { // At h >= 26, logo07 is used (7 lines). With menu_height=3: // right_col = 2 + 0 + 0 + 1 + 3 = 6, inner = max(7, 6) = 7. // hero_box_height = 2 (borders) + 2 (v_pad) + 7 = 11. let area = Rect::new(0, 0, 100, 50); let layout = WelcomeLayout::compute(WelcomeLayoutInput { content_area: area, menu_height: 3, ..Default::default() }); assert!(layout.has_hero_box()); assert_eq!(layout.hero_box.height, 11); } #[test] fn hero_box_logo_top_aligned() { let area = Rect::new(0, 0, 100, 50); let layout = WelcomeLayout::compute(WelcomeLayoutInput { content_area: area, menu_height: 3, ..Default::default() }); // Logo y should be at hero_box.y + 1 (border) + 1 (v_pad). assert_eq!(layout.hero_logo.y, layout.hero_box.y + 2); } #[test] fn hero_box_with_changelog() { // With no announcement, the changelog renders inside the box (info // slot), not in a separate area below it. let area = Rect::new(0, 0, 100, 50); let layout = WelcomeLayout::compute(WelcomeLayoutInput { content_area: area, menu_height: 3, changelog_height: 5, ..Default::default() }); assert!(layout.has_hero_box()); assert_eq!(layout.changelog.height, 0); assert_eq!(layout.hero_info.height, 5); // The subtitle is hidden when the info slot is shown. assert_eq!(layout.hero_subtitle.height, 0); assert!(layout.hero_info.y > layout.hero_version.y); } #[test] fn hero_box_with_announcement() { let area = Rect::new(0, 0, 100, 50); let a = long_ann(); let layout = WelcomeLayout::compute(WelcomeLayoutInput { content_area: area, menu_height: 3, announcement: Some(&a), ..Default::default() }); assert!(layout.has_hero_box()); // Collapsed: title (1) + 2 wrapped message lines. assert_eq!(layout.hero_info.height, 3); // The subtitle is hidden when the info slot is shown. assert_eq!(layout.hero_subtitle.height, 0); assert!(layout.hero_info.y > layout.hero_version.y); // The menu sits one blank row below the info block — no divider line. assert_eq!( layout.hero_menu.y, layout.hero_info.y + layout.hero_info.height + 1 ); } #[test] fn hero_box_announcement_takes_priority_over_changelog() { // When both are present, the info slot is sized for the announcement // and the changelog is suppressed (never shown outside the box). let area = Rect::new(0, 0, 100, 50); let a = long_ann(); let layout = WelcomeLayout::compute(WelcomeLayoutInput { content_area: area, menu_height: 3, changelog_height: 5, announcement: Some(&a), ..Default::default() }); assert!(layout.has_hero_box()); assert_eq!(layout.hero_info.height, 3); // announcement height, not changelog (5) assert_eq!(layout.changelog.height, 0); } #[test] fn hero_box_announcement_clamped_when_tight() { // A real announcement can't disable the hero box: the slot is clamped to // whatever still fits (the renderer trails a `…`), so the box stays // active rather than falling back to the stacked layout. let area = Rect::new(0, 0, 100, 17); let a = long_ann(); let without = WelcomeLayout::compute(WelcomeLayoutInput { content_area: area, menu_height: 3, ..Default::default() }); assert!(without.has_hero_box()); let with_ann = WelcomeLayout::compute(WelcomeLayoutInput { content_area: area, menu_height: 3, announcement: Some(&a), ..Default::default() }); assert!( with_ann.has_hero_box(), "announcement clamps to fit instead of disabling the box" ); assert!(with_ann.hero_info.height > 0); assert!( hero_box::min_content_height(0, 3, 0, with_ann.hero_info.height) <= area.height, "clamped slot must keep the box within the area" ); } #[test] fn hero_box_keeps_one_bottom_pad_below_actions() { // With a changelog/announcement the subtitle is hidden, but there's // still exactly one padding row between the actions and the bottom // border. (menu=4 + info=3 fills the inner, so the menu reaches the pad.) let area = Rect::new(0, 0, 100, 50); let a = long_ann(); let no_info = WelcomeLayout::compute(WelcomeLayoutInput { content_area: area, menu_height: 4, ..Default::default() }); let with_info = WelcomeLayout::compute(WelcomeLayoutInput { content_area: area, menu_height: 4, announcement: Some(&a), ..Default::default() }); assert_eq!(no_info.hero_subtitle.height, 1); assert_eq!(with_info.hero_subtitle.height, 0); let menu_bottom = with_info.hero_menu.y + with_info.hero_menu.height; let border_bottom = with_info.hero_box.y + with_info.hero_box.height - 1; assert_eq!( border_bottom - menu_bottom, 1, "one pad row below the actions" ); } /// Flatten a rendered buffer into one string for substring assertions. fn buffer_text(buf: &Buffer) -> String { let area = *buf.area(); let mut out = String::new(); for y in area.y..area.y + area.height { for x in area.x..area.x + area.width { out.push_str(buf[(x, y)].symbol()); } out.push('\n'); } out } #[test] fn extract_user_code_parses_verification_url() { assert_eq!( extract_user_code("https://accounts.x.ai/oauth2/device?user_code=ABCD-EFGH"), Some("ABCD-EFGH"), ); // Trailing params after the code are ignored. assert_eq!( extract_user_code("https://x.ai/oauth2/device?user_code=WXYZ-1234&foo=bar"), Some("WXYZ-1234"), ); // A param whose name merely ends in `user_code` must not be matched. assert_eq!( extract_user_code("https://x.ai/d?foo_user_code=BAD&user_code=GOOD"), Some("GOOD"), ); // No code param, empty code, and unexpected characters all yield None. assert_eq!(extract_user_code("https://x.ai/oauth2/device"), None); assert_eq!(extract_user_code("https://x.ai/d?user_code="), None); assert_eq!(extract_user_code("https://x.ai/d?user_code=AB%20CD"), None); } #[test] fn device_auth_arm_shows_url_and_no_paste_box() { let area = Rect::new(0, 0, 80, 40); let mut buf = Buffer::empty(area); let theme = Theme::current(); let url = "https://accounts.x.ai/oauth2/device?user_code=ABCD-EFGH"; let (copy_rect, fallback_rect) = render_welcome_authenticating( area, &mut buf, &theme, logo_line_count(area.height), Some(url), AuthMode::Device, "", // auth_code_input — unused in device mode false, // clipboard_copied false, // show_raw_url ); let text = buffer_text(&buf); assert!( text.contains("Approve in your browser"), "device arm must show the approval header, got:\n{text}" ); // Device code shown for the browser-match check (anti-phishing). assert!( text.contains("ABCD-EFGH"), "device arm must show the device code, got:\n{text}" ); assert!( text.contains("Make sure your browser shows this code"), "device arm must show the code caption, got:\n{text}" ); // Copy affordance (click-to-copy line) is present. assert!( text.contains("to copy"), "device arm must show the copy-URL affordance, got:\n{text}" ); // No manual-paste affordance in device mode. assert!( !text.contains("Paste your token"), "device arm must NOT render the token paste box, got:\n{text}" ); // Copy + fallback links are clickable. assert!( copy_rect.is_some(), "device arm must expose a copy hit-rect" ); assert!( fallback_rect.is_some(), "device arm must expose a show-full-URL hit-rect" ); } #[test] fn device_auth_arm_raw_url_mode_shows_full_url() { let area = Rect::new(0, 0, 80, 40); let mut buf = Buffer::empty(area); let theme = Theme::current(); let url = "https://accounts.x.ai/oauth2/device?user_code=WXYZ-1234"; render_welcome_authenticating( area, &mut buf, &theme, logo_line_count(area.height), Some(url), AuthMode::Device, "", false, true, // show_raw_url ); let text = buffer_text(&buf); assert!( text.contains("WXYZ-1234"), "raw URL mode must render the full URL including the user code, got:\n{text}" ); } #[test] fn raw_url_mode_centers_url_that_fits_on_one_line() { let area = Rect::new(0, 0, 80, 40); let mut buf = Buffer::empty(area); let theme = Theme::current(); let url = "https://accounts.x.ai/oauth2/device?user_code=WXYZ-1234"; render_welcome_authenticating( area, &mut buf, &theme, logo_line_count(area.height), Some(url), AuthMode::Device, "", false, true, // show_raw_url ); let text = buffer_text(&buf); let url_line = text .lines() .find(|l| l.contains("https://")) .expect("raw URL mode must render the URL"); // Whole URL on one line, not wrapped. assert!(url_line.contains(url), "URL must be intact: {url_line:?}"); // Centered: leading pad within 1 cell of trailing pad (integer split). let lead = url_line.len() - url_line.trim_start().len(); let trail = url_line.len() - url_line.trim_end().len(); assert!( lead > 0 && lead.abs_diff(trail) <= 1, "URL must be horizontally centered, lead={lead} trail={trail}:\n{text}" ); } #[test] fn raw_url_mode_uses_full_width_for_long_urls() { let area = Rect::new(0, 0, 40, 40); let mut buf = Buffer::empty(area); let theme = Theme::current(); // 40-col terminal; URL longer than one row must wrap at the exact // screen edge with no leading spaces so copy-paste stays intact. let url = "https://accounts.x.ai/oauth2/device?user_code=WXYZ-1234&extra=0123456789"; render_welcome_authenticating( area, &mut buf, &theme, logo_line_count(area.height), Some(url), AuthMode::Device, "", false, true, // show_raw_url ); let text = buffer_text(&buf); let mut lines = text.lines(); let first = lines .by_ref() .find(|l| l.contains("https://")) .expect("raw URL mode must render the URL"); let second = lines.next().expect("URL must wrap to a second row"); // First row flush against both edges (full width), remainder on the // next row starting at column 0. assert_eq!( first, &url[..40], "long URL row must span the full terminal width:\n{text}" ); assert!( second.starts_with(&url[40..]), "wrapped remainder must start at column 0:\n{text}" ); } #[test] fn command_auth_arm_shows_url_and_waiting() { let area = Rect::new(0, 0, 80, 40); let mut buf = Buffer::empty(area); let theme = Theme::current(); let url = "https://accounts.x.ai/oauth2/authorize?client_id=grok"; let (copy_rect, fallback_rect) = render_welcome_authenticating( area, &mut buf, &theme, logo_line_count(area.height), Some(url), AuthMode::Command, "", // auth_code_input — unused false, // clipboard_copied false, // show_raw_url ); let text = buffer_text(&buf); assert!( text.contains("A browser window will open"), "command arm must show the auth header, got:\n{text}" ); assert!( text.contains("Waiting for login to complete"), "command arm must show the waiting status, got:\n{text}" ); // No device code — that's device-flow only. assert!( !text.contains("Make sure your browser shows this code"), "command arm must NOT show the device-code caption, got:\n{text}" ); // No manual-paste affordance in command mode. assert!( !text.contains("Paste your token"), "command arm must NOT render the token paste box, got:\n{text}" ); // Copy + fallback links are clickable. assert!( copy_rect.is_some(), "command arm must expose a copy hit-rect" ); assert!( fallback_rect.is_some(), "command arm must expose a show-full-URL hit-rect" ); } fn long_ann() -> xai_grok_announcements::RemoteAnnouncement { xai_grok_announcements::RemoteAnnouncement { title: Some("Security policy".into()), message: Some( "Report security incidents to the security team promptly through \ the usual channels. " .repeat(60), ), ..Default::default() } } #[test] fn announcement_expands_for_long_message() { // Wide + tall → hero box; the measured info slot grows when expanded. let area = Rect::new(0, 0, 120, 60); let a = long_ann(); let collapsed = WelcomeLayout::compute(WelcomeLayoutInput { content_area: area, menu_height: 4, announcement: Some(&a), ..Default::default() }); let expanded = WelcomeLayout::compute(WelcomeLayoutInput { content_area: area, menu_height: 4, announcement: Some(&a), expanded: true, ..Default::default() }); assert!(collapsed.has_hero_box() && expanded.has_hero_box()); // Collapsed is title (1) + 2 wrapped lines; expanded shows much more. assert_eq!(collapsed.hero_info.height, 3); assert!( expanded.hero_info.height > collapsed.hero_info.height, "expanded {} should exceed collapsed {}", expanded.hero_info.height, collapsed.hero_info.height ); } #[test] fn announcement_equal_for_short_message() { let area = Rect::new(0, 0, 120, 60); let a = xai_grok_announcements::RemoteAnnouncement { title: Some("FYI".into()), message: Some("All good.".into()), ..Default::default() }; let collapsed = WelcomeLayout::compute(WelcomeLayoutInput { content_area: area, menu_height: 4, announcement: Some(&a), ..Default::default() }); let expanded = WelcomeLayout::compute(WelcomeLayoutInput { content_area: area, menu_height: 4, announcement: Some(&a), expanded: true, ..Default::default() }); // Title (1) + a single wrapped line, identical whether expanded or not. assert_eq!(collapsed.hero_info.height, 2); assert_eq!(collapsed.hero_info.height, expanded.hero_info.height); } #[test] fn announcement_clamped_in_short_box() { let tall = Rect::new(0, 0, 120, 60); let short = Rect::new(0, 0, 120, 30); let a = long_ann(); let tall_expanded = WelcomeLayout::compute(WelcomeLayoutInput { content_area: tall, menu_height: 4, announcement: Some(&a), expanded: true, ..Default::default() }); let short_expanded = WelcomeLayout::compute(WelcomeLayoutInput { content_area: short, menu_height: 4, announcement: Some(&a), expanded: true, ..Default::default() }); assert!(tall_expanded.has_hero_box() && short_expanded.has_hero_box()); // The short box clamps the expansion below the tall-box height... assert!( short_expanded.hero_info.height < tall_expanded.hero_info.height, "short {} should be clamped below tall {}", short_expanded.hero_info.height, tall_expanded.hero_info.height ); // ...and the clamped height still keeps the hero box within the area. assert!( hero_box::min_content_height(0, 4, 0, short_expanded.hero_info.height) <= short.height ); } #[test] fn no_announcement_uses_changelog_for_info_slot() { // Without an announcement the info slot falls back to the changelog // height (0 here → empty slot). let area = Rect::new(0, 0, 120, 60); let layout = WelcomeLayout::compute(WelcomeLayoutInput { content_area: area, menu_height: 4, ..Default::default() }); assert_eq!(layout.hero_info.height, 0); } #[test] fn stacked_info_width_clamps_to_available() { // Min menu width is MENU_MIN_WIDTH, capped at the available (inset) slot. let unclamped = logo::logo_visual_width(50).max(30).max(MENU_MIN_WIDTH); assert_eq!(stacked_info_width(200, 50, MENU_MIN_WIDTH), unclamped); assert_eq!(stacked_info_width(40, 50, MENU_MIN_WIDTH), 40); } #[test] fn stacked_expanded_announcement_allocates_slot() { // Narrow terminal → stacked layout. A long expanded announcement must // still get a nonzero info slot wherever the column has room (regression: // over-reserving once collapsed the whole slot to zero, hiding it). let a = long_ann(); for height in 20u16..=60 { let area = Rect::new(0, 0, 80, height); assert!(area.width < hero_box::HERO_BOX_MIN_WIDTH); let layout = WelcomeLayout::compute(WelcomeLayoutInput { content_area: area, menu_height: 4, announcement: Some(&a), expanded: true, ..Default::default() }); assert!(!layout.has_hero_box()); let budget = stacked_info_budget(area, 0, 4, 0, false); if budget > 0 { assert!( layout.changelog.height > 0, "height {height}: stacked slot dropped to 0 with budget {budget}" ); } } } }