grok-build-upstream-mirror/crates/codegen/xai-grok-pager/src/views/announcements.rs

1632 lines
66 KiB
Rust
Raw Normal View History

//! Session announcement banner: one slot, critical always wins over promo.
//!
//! Critical layout (always 2 rows when shown):
//! ```text
//! ! Title [hide] (prefix+title error red, [hide] dim + clickable)
//! Message… hide: /announcements hide (message default fg, CTA dim gray)
//! ```
//!
//! The message row indents past the `! ` prefix so its column matches the
//! title's; the CTA keeps its full reserved width and the message truncates.
//!
//! Promo layout (1 row, only when no critical is selected; the caption is
//! pinned-only and the hide affordances dismissible-only, so they never
//! co-occur):
//! ```text
//! [Label] {cta.caption} (pinned)
//! [Label] hide: /announcements hide [hide] (dismissible)
//! ```
//!
//! The `[Label]` button is the promo's CTA (semantic warning yellow,
//! clickable); it is omitted when the announcement has no usable CTA. A pinned
//! (non-dismissible) promo also paints its dim `cta.caption` helper text (e.g.
//! "or use Ctrl+O") after the button when one is configured (dropped whole when
//! it can't fit, or while a permission prompt owns the chord). The promo
//! `message` is not painted here (it renders on the roomy welcome hero
//! instead). Both hide affordances sit right-aligned (dismissible promos only).
//!
//! `dismissible: false` suppresses every hide affordance on either kind and
//! the text reclaims the reserved columns (absent/`true` = hideable).
use std::collections::BTreeSet;
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Modifier, Style},
text::Span,
};
use crate::render::line_utils::truncate_str;
use crate::theme::Theme;
use xai_grok_announcements::visible_announcements;
const HIDE_CTA: &str = "hide: /announcements hide";
/// Clickable hide button, far right of the title row.
const HIDE_BUTTON: &str = "[hide]";
/// Alert prefix on the title row; the message row indents by its width.
const TITLE_PREFIX: &str = "! ";
/// Columns between title/message text and the right-hand button/CTA.
const GAP: usize = 2;
/// Columns the `[label]` button (+ optional ` {caption}`) wants — the
/// reservation every surface subtracts from its own budget so the adjacent text
/// (message/path/location) truncates first. Excludes any surface-specific lead
/// space (callers add their own).
pub(crate) fn upgrade_cta_reserve(label: &str, caption: Option<&str>) -> u16 {
use unicode_width::UnicodeWidthStr;
let cap_w = caption.map_or(0, |c| UnicodeWidthStr::width(c) + 1);
(UnicodeWidthStr::width(format!("[{label}]").as_str()) + cap_w) as u16
}
/// Paint the promo upgrade `[label]` button (semantic warning yellow; hovered →
/// warning fg on `bg_hover`) at (`x`, `y`), then the dim `caption` one space
/// after it when it fits. The label truncates to `max_width` and the caption is
/// dropped whole when it no longer fits, so the button never overpaints past
/// `max_width`. Returns the clickable button rect (caption excluded), or `None`
/// when not even a clipped button fits. The ONE painter every surface (banner,
/// hero, in-session header, dashboard) shares so the button/caption style,
/// truncation, and clamping can't drift.
#[allow(clippy::too_many_arguments)]
pub(crate) fn render_cta_button(
buf: &mut Buffer,
theme: &Theme,
x: u16,
y: u16,
max_width: u16,
label: &str,
caption: Option<&str>,
hovered: bool,
) -> Option<Rect> {
use unicode_width::UnicodeWidthStr;
let max_w = max_width as usize;
if max_w == 0 {
return None;
}
let button = format!("[{label}]");
let disp = truncate_str(&button, max_w);
let disp_w = UnicodeWidthStr::width(disp.as_str()).min(max_w);
if disp_w == 0 {
return None;
}
let cta_style = if hovered {
Style::default().fg(theme.warning).bg(theme.bg_hover)
} else {
Style::default().fg(theme.warning).bg(theme.bg_base)
};
buf.set_span(x, y, &Span::styled(disp, cta_style), disp_w as u16);
// Reservation-first: the button is already painted; the dim caption follows
// one space later and drops WHOLE when it won't fit (never a partial).
if let Some(caption) = caption {
let cap = format!(" {caption}");
let cap_w = UnicodeWidthStr::width(cap.as_str());
if disp_w + cap_w <= max_w {
let cap_style = Style::default()
.fg(theme.gray)
.bg(theme.bg_base)
.add_modifier(Modifier::DIM);
buf.set_span(
x + disp_w as u16,
y,
&Span::styled(cap, cap_style),
cap_w as u16,
);
}
}
Some(Rect::new(x, y, disp_w as u16, 1))
}
fn is_critical(a: &xai_grok_announcements::RemoteAnnouncement) -> bool {
a.severity.as_deref() == Some("critical")
}
fn is_promo(a: &xai_grok_announcements::RemoteAnnouncement) -> bool {
a.severity.as_deref() == Some("promo")
}
/// One definition of "live critical" (visible message + critical + not expired)
/// shared by every predicate below so the meanings cannot drift.
fn is_live_critical(
a: &xai_grok_announcements::RemoteAnnouncement,
now: chrono::DateTime<chrono::Utc>,
) -> bool {
is_critical(a) && !xai_grok_announcements::is_expired_at(a, now)
}
/// Promo twin of [`is_live_critical`]. A CTA is NOT required: a promo without
/// one is still a valid 1-line message row (the selection's visible-message
/// guarantee already skips items with nothing to render).
fn is_live_promo(
a: &xai_grok_announcements::RemoteAnnouncement,
now: chrono::DateTime<chrono::Utc>,
) -> bool {
is_promo(a) && !xai_grok_announcements::is_expired_at(a, now)
}
/// The session-surfaced severities (critical or promo) — the one name the
/// hide-key set and the slash-gate predicate share so the set of severities
/// that open the in-session slot cannot drift between them.
fn is_live_session_announcement(
a: &xai_grok_announcements::RemoteAnnouncement,
now: chrono::DateTime<chrono::Utc>,
) -> bool {
is_live_critical(a, now) || is_live_promo(a, now)
}
/// Hideable unless the server says otherwise: absent/`true` = dismissible
/// (back-compat with every pre-flag announcement), only an explicit `false`
/// pins the banner. Shared by the selection seam, both painters, and the
/// hide dispatch so the meanings cannot drift.
pub fn is_dismissible(a: &xai_grok_announcements::RemoteAnnouncement) -> bool {
a.dismissible != Some(false)
}
/// The hidden-ids filter the selection gates share. It applies only to
/// dismissible items: an explicit `dismissible: false` stays selectable even
/// with its hide key stored, so flipping the flag server-side resurrects a
/// previously-hidden banner (the remote config stays source of truth).
fn is_hidden(
a: &xai_grok_announcements::RemoteAnnouncement,
hidden_ids: &BTreeSet<String>,
) -> bool {
is_dismissible(a) && hidden_ids.contains(&xai_grok_announcements::announcement_hide_key(a))
}
/// The promo's CTA when it is renderable: both label and url trimmed
/// non-empty (the server validates this pair; the tolerant client re-checks
/// so a partial object never paints a dead button), and the url scheme
/// allowed by the same filter the click's open path enforces. This is the
/// ONE gate — paint, hit-rect, OSC 8 emission, and dispatch all inherit it.
/// The scheme re-check fails closed here because OSC 8 activation is
/// terminal-native and would otherwise hand a raw remote URL (`file://`,
/// custom schemes) past `open_url_if_safe` entirely; a non-https CTA renders
/// as a plain message row instead of a dead or unsafe button.
fn usable_cta(a: &xai_grok_announcements::RemoteAnnouncement) -> Option<(&str, &str)> {
let cta = a.cta.as_ref()?;
let label = cta
.label
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())?;
let url = cta
.url
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.filter(|u| {
crate::app::link_opener::is_safe_to_open(
u,
crate::terminal::hyperlinks::SchemeFilter::Standard,
)
})?;
Some((label, url))
}
/// The CTA's optional dim helper caption (`cta.caption`), trimmed-non-empty.
/// Decorative only: deliberately independent of [`usable_cta`] so a caption can
/// never gate, resurrect, or invalidate the button — surfaces AND this with
/// their own pinned/chord gates, and no button painted means no caption shown.
pub(crate) fn usable_cta_caption(a: &xai_grok_announcements::RemoteAnnouncement) -> Option<&str> {
let caption = a.cta.as_ref()?.caption.as_deref()?.trim();
(!caption.is_empty()).then_some(caption)
}
/// Wall-clock [`first_critical_session_announcement_at`] — test convenience.
#[cfg(test)]
fn first_critical_session_announcement<'a>(
announcements: &'a [xai_grok_announcements::RemoteAnnouncement],
hidden_ids: &BTreeSet<String>,
) -> Option<&'a xai_grok_announcements::RemoteAnnouncement> {
first_critical_session_announcement_at(announcements, hidden_ids, chrono::Utc::now())
}
/// The critical the session banner shows: first live critical whose hide key
/// is NOT in `hidden_ids` — hiding the first critical reveals the next
/// unhidden one. Info/warning stay welcome-only and do not open the
/// in-session slot. Private: prod consumers go through
/// [`first_session_announcement`]'s `.or_else` leg so slot precedence is
/// structurally enforced. Skips expired items at selection (draw) time so an
/// `expires_at` crossed mid-session stops rendering before the next server
/// push; the per-call timestamp parse and hide-key build are
/// allocation-light and the gate runs at most a few times per frame over a
/// tiny list, so no caching is needed.
fn first_critical_session_announcement_at<'a>(
announcements: &'a [xai_grok_announcements::RemoteAnnouncement],
hidden_ids: &BTreeSet<String>,
now: chrono::DateTime<chrono::Utc>,
) -> Option<&'a xai_grok_announcements::RemoteAnnouncement> {
visible_announcements(announcements)
.into_iter()
.find(|a| is_live_critical(a, now) && !is_hidden(a, hidden_ids))
}
/// Wall-clock [`first_promo_session_announcement_at`] — test convenience.
#[cfg(test)]
fn first_promo_session_announcement<'a>(
announcements: &'a [xai_grok_announcements::RemoteAnnouncement],
hidden_ids: &BTreeSet<String>,
) -> Option<&'a xai_grok_announcements::RemoteAnnouncement> {
first_promo_session_announcement_at(announcements, hidden_ids, chrono::Utc::now())
}
/// Promo sibling of [`first_critical_session_announcement_at`] (same expiry
/// seam and hidden-ids filtering). Private for the same reason: only the
/// slot gate's `.or_else` leg consumes it, so nothing can bypass "critical
/// wins" again.
fn first_promo_session_announcement_at<'a>(
announcements: &'a [xai_grok_announcements::RemoteAnnouncement],
hidden_ids: &BTreeSet<String>,
now: chrono::DateTime<chrono::Utc>,
) -> Option<&'a xai_grok_announcements::RemoteAnnouncement> {
visible_announcements(announcements)
.into_iter()
.find(|a| is_live_promo(a, now) && !is_hidden(a, hidden_ids))
}
/// The single banner-slot item: the critical selection when one exists
/// (critical always wins the slot), else the promo selection. Per-frame
/// derivation makes the swap automatic when a critical arrives mid-promo.
pub fn first_session_announcement<'a>(
announcements: &'a [xai_grok_announcements::RemoteAnnouncement],
hidden_ids: &BTreeSet<String>,
) -> Option<&'a xai_grok_announcements::RemoteAnnouncement> {
first_session_announcement_at(announcements, hidden_ids, chrono::Utc::now())
}
Synced from monorepo Synced from monorepo Changes: - Workspace server: report `/ready` as failed with dwell on hub connect failure - Refresh OIDC token for the Grok agent in the shell - ACP terminal output recorder - Cross-platform provider auth commands in the shell - Default `/resume` to Grok sessions with a hint for hidden external sessions - Resume sessions by title with `--resume` - Limit app-builder archive size - Data-driven tag labels for slash commands - Doctor fixes for tmux - Custom provider gateways and subprocess environment policy in the shell - `/tutorial` — opt-in onboarding tour of Grok Build - Soft and required CLI version checks in the shell - Privacy banner env overrides survive live settings updates - Add remote flag to override the image-edit model - Return profile fields from auth info even when the access token is expired - Add edit control on queued prompt rows - Keep fail-closed policy when clearing orphans with no team - Setting to disable the Ctrl+Space/F8 voice shortcut - Pass `--raw` to pw-record so Linux dictation works on older PipeWire - Validate git URLs when adding marketplace entries - Stop shipping stale tool-doc parameter and tool names - Re-point dashboard attach after `/fork` only when the parent was attached - Surface Grok Computer media-generation results as file-path chunks - Clear web background-task tray on kill and keep the task description - Show privacy upsell banner in agent view until acted on - Add tools-server client callback surface - Protect persistent global hook sources Source-Revision: 95d84f443eddcbed6cbfd6eed22e2eafe6b3939d
2026-07-23 17:12:33 +00:00
/// Whether a live critical session announcement exists. Used by the banner
/// slot ranking: critical outranks the privacy upsell banner (an outage
/// notice must not be hidden by a persistent nag), promo does not.
pub fn has_critical_session_announcement(
announcements: &[xai_grok_announcements::RemoteAnnouncement],
hidden_ids: &BTreeSet<String>,
) -> bool {
first_critical_session_announcement_at(announcements, hidden_ids, chrono::Utc::now()).is_some()
}
/// [`first_session_announcement`] with an injectable clock.
pub fn first_session_announcement_at<'a>(
announcements: &'a [xai_grok_announcements::RemoteAnnouncement],
hidden_ids: &BTreeSet<String>,
now: chrono::DateTime<chrono::Utc>,
) -> Option<&'a xai_grok_announcements::RemoteAnnouncement> {
first_critical_session_announcement_at(announcements, hidden_ids, now)
.or_else(|| first_promo_session_announcement_at(announcements, hidden_ids, now))
}
/// The upgrade CTA to surface: `(owner, label, url)` resolved through the
/// banner-slot gate. The single resolution shared by every surface that paints
/// the `[label]` button (welcome hero, in-session header, dashboard, banner)
/// and by the click/keyboard/OSC 8 open paths — so show-logic, https-safety,
/// critical-preemption, and expiry are inherited once. `is_dismissible(owner)`
/// tells a surface whether the `Ctrl+O` override applies (pinned promos only).
/// Resolving through [`first_session_announcement`] keeps dispatch
/// slot-consistent: a critical owning the slot yields no target, so a click
/// through a stale prior-frame rect (critical preempted the promo between
/// draws) no-ops.
pub(crate) fn promo_cta<'a>(
announcements: &'a [xai_grok_announcements::RemoteAnnouncement],
hidden_ids: &BTreeSet<String>,
) -> Option<(
&'a xai_grok_announcements::RemoteAnnouncement,
&'a str,
&'a str,
)> {
let owner = first_session_announcement(announcements, hidden_ids).filter(|a| is_promo(a))?;
let (label, url) = usable_cta(owner)?;
Some((owner, label, url))
}
/// The `[label]` button's target: the promo owner + its validated url. The
/// url-only projection of [`promo_cta`] the click dispatch (url + announcement
/// id for telemetry) and the OSC 8 emission share.
pub fn promo_cta_target<'a>(
announcements: &'a [xai_grok_announcements::RemoteAnnouncement],
hidden_ids: &BTreeSet<String>,
) -> Option<(&'a xai_grok_announcements::RemoteAnnouncement, &'a str)> {
promo_cta(announcements, hidden_ids).map(|(owner, _label, url)| (owner, url))
}
/// Hide keys of every live (non-expired) session-surfaced announcement
/// (critical or promo) — the set `/announcements show` clears, matching the
/// selection's meaning of visible; prune owns cleanup of keys for
/// expired-but-still-listed items.
pub fn session_announcement_hide_keys(
announcements: &[xai_grok_announcements::RemoteAnnouncement],
) -> Vec<String> {
session_announcement_hide_keys_at(announcements, chrono::Utc::now())
}
/// [`session_announcement_hide_keys`] with an injectable clock.
pub fn session_announcement_hide_keys_at(
announcements: &[xai_grok_announcements::RemoteAnnouncement],
now: chrono::DateTime<chrono::Utc>,
) -> Vec<String> {
visible_announcements(announcements)
.into_iter()
.filter(|a| is_live_session_announcement(a, now))
.map(xai_grok_announcements::announcement_hide_key)
.collect()
}
/// Slash-gate predicate: any live session-surfaced announcement (critical or
/// promo) exists, deliberately IGNORING the hidden set (unlike the banner
/// selection above) so `/announcements show` stays reachable while
/// everything is hidden.
pub fn has_session_announcements(
announcements: &[xai_grok_announcements::RemoteAnnouncement],
) -> bool {
let now = chrono::Utc::now();
visible_announcements(announcements)
.into_iter()
.any(|a| is_live_session_announcement(a, now))
}
/// Height for the session banner (0 when the selection is empty): 2 when a
/// critical is shown (title row + message row), 1 for the single promo row.
/// Derived from [`first_session_announcement`] so slot precedence lives in
/// exactly one function.
pub fn session_banner_height(
announcements: &[xai_grok_announcements::RemoteAnnouncement],
hidden_ids: &BTreeSet<String>,
) -> u16 {
match first_session_announcement(announcements, hidden_ids) {
Some(a) if is_critical(a) => 2,
Some(_) => 1,
None => 0,
}
}
/// Clickable rects painted by [`render_banner`] (`None` = not painted).
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct BannerHits {
/// The `[hide]` button.
pub hide: Option<Rect>,
/// The promo `[label]` CTA button (critical rows never paint one).
pub cta: Option<Rect>,
}
/// Shared dim style for the hide affordances (CTA text and resting button).
fn dim_hide_style(theme: &Theme) -> Style {
Style::default()
.fg(theme.gray)
.bg(theme.bg_base)
.add_modifier(Modifier::DIM)
}
/// Right-aligned `[hide]` button on `row`, painted FIRST so its width is
/// reserved before any text budget is computed (text truncates, never the
/// button). Hover mirrors the turn-status [stop] affordance: error red on
/// hover, dim at rest. Returns the clickable rect (`None` when it cannot
/// fit) — the one paint/hover/reserve rule both banner painters share.
fn paint_hide_button(
buf: &mut Buffer,
area: Rect,
row: u16,
hovered: bool,
theme: &Theme,
) -> Option<Rect> {
use unicode_width::UnicodeWidthStr;
let max_w = area.width as usize;
let button_w = UnicodeWidthStr::width(HIDE_BUTTON);
if max_w < button_w {
return None;
}
let hide_x = area.x + (max_w - button_w) as u16;
let button_style = if hovered {
Style::default().fg(theme.accent_error).bg(theme.bg_base)
} else {
dim_hide_style(theme)
};
buf.set_span(
hide_x,
row,
&Span::styled(HIDE_BUTTON, button_style),
button_w as u16,
);
Some(Rect::new(hide_x, row, button_w as u16, 1))
}
/// Session top banner: paints the [`first_session_announcement`] selection
/// (slot precedence lives there alone) with the severity-matched painter.
///
/// `caption_allowed` gates the promo row's dim `cta.caption`: the caller
/// passes `false` while a permission prompt owns `Ctrl+O` (it toggles YOLO
/// there, so advertising the CTA open would be a mislabeled control). The
/// `[label]` button + its mouse/OSC 8 open are unaffected.
///
/// Returns the painted clickable rects so the caller can hit-test mouse
/// clicks against them.
pub fn render_banner(
area: Rect,
buf: &mut Buffer,
announcements: &[xai_grok_announcements::RemoteAnnouncement],
hidden_ids: &BTreeSet<String>,
hide_hovered: bool,
cta_hovered: bool,
caption_allowed: bool,
) -> BannerHits {
if area.height == 0 || area.width == 0 {
return BannerHits::default();
}
match first_session_announcement(announcements, hidden_ids) {
Some(a) if is_critical(a) => render_critical_rows(area, buf, a, hide_hovered),
Some(a) => render_promo_row(area, buf, a, hide_hovered, cta_hovered, caption_allowed),
None => BannerHits::default(),
}
}
/// The selected critical announcement, two lines.
///
/// Row 0: `! Title` (error red, title bold) with a right-aligned dim
/// `[hide]` button; row 1: the message (default fg) indented to the title
/// column, then the dim `hide: /announcements hide` CTA. The CTA width is
/// reserved up front so a long message truncates with `…` instead of pushing
/// the CTA off-screen. A non-dismissible announcement paints neither hide
/// affordance and the title/message reclaim the reserved widths.
fn render_critical_rows(
area: Rect,
buf: &mut Buffer,
ann: &xai_grok_announcements::RemoteAnnouncement,
hide_hovered: bool,
) -> BannerHits {
use unicode_width::UnicodeWidthStr;
let theme = Theme::current();
buf.set_style(area, Style::default().bg(theme.bg_base));
let title = ann
.title
.as_deref()
.map(str::trim)
.filter(|t| !t.is_empty());
let message = ann
.message
.as_deref()
.map(str::trim)
.filter(|m| !m.is_empty());
if title.is_none() && message.is_none() {
return BannerHits::default();
}
// Semantic error red (theme-aware) rather than a raw palette color.
let alert_fg = theme.accent_error;
// One visual unit: the `! ` prefix and the title share this style.
let alert_style = Style::default()
.fg(alert_fg)
.bg(theme.bg_base)
.add_modifier(Modifier::BOLD);
let dim_style = dim_hide_style(&theme);
let max_w = area.width as usize;
let prefix_w = UnicodeWidthStr::width(TITLE_PREFIX);
let button_w = UnicodeWidthStr::width(HIDE_BUTTON);
let row0 = area.y;
let row1 = area.y.saturating_add(1);
let max_y = area.y.saturating_add(area.height);
let mut hide_rect = None;
let dismissible = is_dismissible(ann);
if row0 < max_y {
// `! ` prefix anchors the alert even when the title is missing.
let prefix_disp = truncate_str(TITLE_PREFIX, max_w);
buf.set_span(
area.x,
row0,
&Span::styled(prefix_disp, alert_style),
area.width,
);
// Non-dismissible: no [hide] button — the no-button budget branch
// below hands its columns back to the title.
if dismissible {
hide_rect = paint_hide_button(buf, area, row0, hide_hovered, &theme);
}
let title_budget = if hide_rect.is_some() {
max_w.saturating_sub(prefix_w + button_w + GAP)
} else {
max_w.saturating_sub(prefix_w)
};
if let Some(t) = title
&& title_budget > 0
{
let t_disp = truncate_str(t, title_budget);
buf.set_span(
area.x + prefix_w as u16,
row0,
&Span::styled(t_disp, alert_style),
title_budget as u16,
);
}
}
if row1 < max_y {
// Message column == title column: indent past the `! ` prefix.
let mut x = area.x.saturating_add(prefix_w as u16);
let mut remaining = max_w.saturating_sub(prefix_w);
let cta_w = UnicodeWidthStr::width(HIDE_CTA);
// Reserve the CTA (plus gap) up front: the message truncates, never
// the CTA. Non-dismissible reserves nothing — the message reclaims
// the full row past the prefix (`W2`).
let msg_budget = if dismissible {
remaining.saturating_sub(cta_w + GAP)
} else {
remaining
};
if let Some(m) = message
&& msg_budget > 0
{
let msg_style = Style::default().fg(theme.text_primary).bg(theme.bg_base);
let m_disp = truncate_str(m, msg_budget);
let m_w = UnicodeWidthStr::width(m_disp.as_str()).min(msg_budget);
if m_w > 0 {
buf.set_span(x, row1, &Span::styled(m_disp, msg_style), m_w as u16);
// dismissible: m_w <= msg_budget keeps the gap + full CTA
// fitting after it; non-dismissible paints no CTA below.
x = x.saturating_add((m_w + GAP) as u16);
remaining = remaining.saturating_sub(m_w + GAP);
}
}
if dismissible && remaining > 0 {
// Degenerate widths still truncate the CTA itself rather than panic.
let cta_disp = truncate_str(HIDE_CTA, remaining);
buf.set_span(
x,
row1,
&Span::styled(cta_disp, dim_style),
remaining as u16,
);
}
}
BannerHits {
hide: hide_rect,
cta: None,
}
}
/// The selected promo announcement, one line (see the module doc for the
/// pinned/dismissible row sketches).
///
/// The `[Label]` CTA button (semantic warning yellow) leads the row and is
/// omitted when the promo has no usable CTA. The promo `message` is NOT painted
/// here (it renders on the roomy welcome hero instead); a pinned
/// (non-dismissible) promo shows its dim `cta.caption` after the button when
/// one is configured and `caption_allowed` (a dismissible twin keeps `Ctrl+O`
/// on YOLO, so no caption; no caption configured = bare button).
/// The right-hand hide affordances are reserved first (dismissible promos only)
/// so the button + caption never overpaint them.
fn render_promo_row(
area: Rect,
buf: &mut Buffer,
ann: &xai_grok_announcements::RemoteAnnouncement,
hide_hovered: bool,
cta_hovered: bool,
caption_allowed: bool,
) -> BannerHits {
use unicode_width::UnicodeWidthStr;
let theme = Theme::current();
buf.set_style(area, Style::default().bg(theme.bg_base));
// Selection guarantees a visible message; re-check defensively so a
// message-less promo paints nothing (the message itself is not drawn).
let has_message = ann
.message
.as_deref()
.map(str::trim)
.is_some_and(|m| !m.is_empty());
if !has_message {
return BannerHits::default();
}
let dim_style = dim_hide_style(&theme);
let max_w = area.width as usize;
let button_w = UnicodeWidthStr::width(HIDE_BUTTON);
let hide_cta_w = UnicodeWidthStr::width(HIDE_CTA);
let row = area.y;
let mut hits = BannerHits::default();
// Non-dismissible: neither hide affordance paints and `right_reserved`
// stays 0, so the button reclaims the right-hand columns.
let mut right_reserved = 0usize;
if is_dismissible(ann) {
hits.hide = paint_hide_button(buf, area, row, hide_hovered, &theme);
}
if hits.hide.is_some() {
right_reserved = button_w;
// Dim hide CTA directly left of the button; skipped whole when it
// cannot fit (it is redundant with [hide], so no partial paint).
if max_w >= button_w + GAP + hide_cta_w {
let hide_cta_x = area.x + (max_w - button_w - GAP - hide_cta_w) as u16;
buf.set_span(
hide_cta_x,
row,
&Span::styled(HIDE_CTA, dim_style),
hide_cta_w as u16,
);
right_reserved = button_w + GAP + hide_cta_w;
}
}
// Left side: the [Label] CTA button (+ pinned-only `cta.caption`), clear of
// the reserved right-hand hide block. The shared painter owns the style,
// truncation, and drop-whole caption.
let remaining = if right_reserved > 0 {
max_w.saturating_sub(right_reserved + GAP)
} else {
max_w
};
if remaining > 0
&& let Some((label, _url)) = usable_cta(ann)
{
// Caption only for a pinned promo whose `Ctrl+O` actually opens the CTA
// (suppressed while a permission prompt owns the chord).
let caption = (caption_allowed && !is_dismissible(ann))
.then(|| usable_cta_caption(ann))
.flatten();
hits.cta = render_cta_button(
buf,
&theme,
area.x,
row,
remaining as u16,
label,
caption,
cta_hovered,
);
}
hits
}
#[cfg(test)]
mod tests {
use super::*;
use xai_grok_announcements::RemoteAnnouncement;
fn ann(severity: Option<&str>, message: Option<&str>) -> RemoteAnnouncement {
RemoteAnnouncement {
severity: severity.map(str::to_string),
message: message.map(str::to_string),
..Default::default()
}
}
fn no_hidden() -> BTreeSet<String> {
BTreeSet::new()
}
fn promo(id: &str, message: &str, cta: Option<(&str, &str)>) -> RemoteAnnouncement {
RemoteAnnouncement {
id: Some(id.into()),
severity: Some("promo".into()),
message: Some(message.into()),
cta: cta.map(|(label, url)| xai_grok_announcements::AnnouncementCta {
label: Some(label.into()),
url: Some(url.into()),
caption: None,
}),
..Default::default()
}
}
#[test]
fn first_critical_session_announcement_skips_non_critical_and_empty() {
let input = vec![
ann(Some("info"), Some("info only")),
ann(Some("warning"), Some("warn only")),
ann(Some("critical"), None),
ann(Some("critical"), Some(" ")),
ann(Some("critical"), Some("crit one")),
ann(Some("critical"), Some("crit two")),
ann(None, Some("no severity")),
];
let got =
first_critical_session_announcement(&input, &no_hidden()).expect("critical present");
assert_eq!(got.message.as_deref(), Some("crit one"));
assert!(first_critical_session_announcement(&[], &no_hidden()).is_none());
let no_critical = vec![
ann(Some("info"), Some("hello")),
ann(Some("critical"), Some(" ")),
];
assert!(first_critical_session_announcement(&no_critical, &no_hidden()).is_none());
}
/// Hidden is a selection-level filter: hiding the first critical reveals
/// the next unhidden one instead of closing the whole slot, while the
/// slash-gate predicate keeps ignoring hidden so `show` stays reachable.
#[test]
fn first_critical_selection_skips_hidden_and_reveals_next() {
let list = vec![
RemoteAnnouncement {
id: Some("a".into()),
severity: Some("critical".into()),
message: Some("A msg".into()),
..Default::default()
},
RemoteAnnouncement {
id: Some("b".into()),
severity: Some("critical".into()),
message: Some("B msg".into()),
..Default::default()
},
];
let hide_a: BTreeSet<String> = ["a".to_string()].into_iter().collect();
assert_eq!(
first_critical_session_announcement(&list, &hide_a).and_then(|a| a.id.as_deref()),
Some("b"),
"hiding the first critical must reveal the next one"
);
assert_eq!(session_banner_height(&list, &hide_a), 2);
let hide_both: BTreeSet<String> = ["a".to_string(), "b".to_string()].into_iter().collect();
assert!(first_critical_session_announcement(&list, &hide_both).is_none());
assert_eq!(session_banner_height(&list, &hide_both), 0);
assert!(
has_session_announcements(&list),
"slash gate ignores hidden so /announcements show stays reachable"
);
}
/// Draw-time expiry: the selection gate must skip a critical whose
/// `expires_at` has passed even though it is still in the ingested list.
#[test]
fn first_critical_session_announcement_at_skips_expired() {
let expiring = RemoteAnnouncement {
severity: Some("critical".into()),
message: Some("expiring".into()),
expires_at: Some("2030-01-01T00:00:00Z".into()),
..Default::default()
};
let evergreen = RemoteAnnouncement {
severity: Some("critical".into()),
message: Some("evergreen".into()),
..Default::default()
};
let list = vec![expiring, evergreen];
let expiry = chrono::DateTime::parse_from_rfc3339("2030-01-01T00:00:00Z")
.unwrap()
.with_timezone(&chrono::Utc);
let before = expiry - chrono::Duration::seconds(1);
assert_eq!(
first_critical_session_announcement_at(&list, &no_hidden(), before)
.and_then(|a| a.message.as_deref()),
Some("expiring")
);
assert_eq!(
first_critical_session_announcement_at(&list, &no_hidden(), expiry)
.and_then(|a| a.message.as_deref()),
Some("evergreen"),
"expired first critical must yield to the next live one"
);
let only_expired = vec![list[0].clone()];
assert!(
first_critical_session_announcement_at(&only_expired, &no_hidden(), expiry).is_none(),
"all-expired list must close the banner slot"
);
}
/// Show's clear set matches the selection's meaning of visible: live
/// (non-expired) criticals and promos only — expired keys are prune's job.
#[test]
fn session_hide_keys_cover_live_criticals_and_promos_only() {
let mut expired_promo = promo("promo-expired", "gone promo", None);
expired_promo.expires_at = Some("2000-01-01T00:00:00Z".into());
let list = vec![
ann(Some("info"), Some("skip me")),
RemoteAnnouncement {
id: Some("crit-1".into()),
severity: Some("critical".into()),
message: Some("one".into()),
..Default::default()
},
RemoteAnnouncement {
id: None,
title: Some("T".into()),
severity: Some("critical".into()),
message: Some("two".into()),
..Default::default()
},
RemoteAnnouncement {
id: Some("crit-expired".into()),
severity: Some("critical".into()),
message: Some("gone".into()),
expires_at: Some("2000-01-01T00:00:00Z".into()),
..Default::default()
},
ann(Some("critical"), None), // no message → not visible
promo("promo-1", "upsell", Some(("Go", "https://x.ai"))),
expired_promo,
];
let now = chrono::DateTime::parse_from_rfc3339("2020-01-01T00:00:00Z")
.unwrap()
.with_timezone(&chrono::Utc);
let keys = session_announcement_hide_keys_at(&list, now);
assert_eq!(
keys,
vec![
"crit-1".to_string(),
"content:T\u{1f}two".to_string(),
"promo-1".to_string(),
],
"expired keys must not be cleared by show; live promo keys must be"
);
}
#[test]
fn session_banner_height_zero_for_unfiltered_info_only() {
let info_only = vec![
ann(Some("info"), Some("hello")),
ann(Some("warning"), Some("careful")),
];
assert_eq!(session_banner_height(&info_only, &no_hidden()), 0);
}
#[test]
fn session_banner_height_is_two_for_critical() {
let msg_only = vec![ann(Some("critical"), Some("outage"))];
assert_eq!(session_banner_height(&msg_only, &no_hidden()), 2);
let hide_it: BTreeSet<String> = msg_only
.iter()
.map(xai_grok_announcements::announcement_hide_key)
.collect();
assert_eq!(session_banner_height(&msg_only, &hide_it), 0);
let with_title = vec![RemoteAnnouncement {
severity: Some("critical".into()),
title: Some("Outage".into()),
message: Some("Do not deploy".into()),
..Default::default()
}];
assert_eq!(session_banner_height(&with_title, &no_hidden()), 2);
}
fn buf_row(buf: &Buffer, area: Rect, y: u16) -> String {
(0..area.width)
.filter_map(|x| buf.cell((x, y)).map(|c| c.symbol().to_string()))
.collect::<String>()
.trim_end()
.to_string()
}
#[test]
fn render_banner_title_row_with_hide_button_message_row_with_cta() {
let anns = [RemoteAnnouncement {
severity: Some("critical".into()),
title: Some("Outage".into()),
message: Some("Do not deploy".into()),
..Default::default()
}];
let area = Rect::new(0, 0, 60, 2);
let mut buf = Buffer::empty(area);
let hits = render_banner(area, &mut buf, &anns, &no_hidden(), false, false, true);
// Row 0: `! Title` left, `[hide]` right-aligned; row 1: message
// indented to the title column, then the dim CTA after a gap.
let row0 = buf_row(&buf, area, 0);
assert!(row0.starts_with("! Outage"), "row0={row0:?}");
assert!(row0.ends_with(HIDE_BUTTON), "row0={row0:?}");
assert_eq!(
buf_row(&buf, area, 1),
" Do not deploy hide: /announcements hide"
);
assert_eq!(hits.hide, Some(Rect::new(54, 0, 6, 1)), "[hide] hit rect");
assert_eq!(hits.cta, None, "critical rows paint no CTA button");
for y in 0..2 {
let r = buf_row(&buf, area, y);
assert!(!r.contains('‼') && !r.contains('⚠') && !r.contains(''));
}
let theme = Theme::current();
let prefix = buf.cell((0, 0)).unwrap();
assert_eq!(prefix.fg, theme.accent_error, "! prefix uses error red");
let title = buf.cell((2, 0)).unwrap();
assert_eq!(title.fg, theme.accent_error, "title uses error red");
assert!(title.modifier.contains(Modifier::BOLD));
let button = buf.cell((54, 0)).unwrap();
assert_eq!(button.fg, theme.gray);
assert!(
button.modifier.contains(Modifier::DIM),
"[hide] dim at rest"
);
let msg = buf.cell((2, 1)).unwrap();
assert_eq!(msg.fg, theme.text_primary, "message uses default fg");
assert!(!msg.modifier.contains(Modifier::BOLD));
let cta = buf.cell((17, 1)).unwrap();
assert_eq!(cta.fg, theme.gray);
assert!(cta.modifier.contains(Modifier::DIM), "CTA dim");
}
#[test]
fn render_banner_hide_button_highlights_on_hover() {
let anns = [ann(Some("critical"), Some("outage"))];
let area = Rect::new(0, 0, 60, 2);
let mut buf = Buffer::empty(area);
let hits = render_banner(area, &mut buf, &anns, &no_hidden(), true, false, true);
let rect = hits.hide.expect("hide button painted");
let button = buf.cell((rect.x, rect.y)).unwrap();
assert_eq!(button.fg, Theme::current().accent_error);
assert!(!button.modifier.contains(Modifier::DIM));
}
/// Row-1 reservation: the CTA keeps its full width and the message is the
/// part that truncates (with an ellipsis), never the other way around.
#[test]
fn render_banner_truncates_message_never_cta() {
let anns = [RemoteAnnouncement {
severity: Some("critical".into()),
title: Some("Outage".into()),
message: Some("0123456789ABCDEFGHIJ".into()),
..Default::default()
}];
let area = Rect::new(0, 0, 40, 2);
let mut buf = Buffer::empty(area);
render_banner(area, &mut buf, &anns, &no_hidden(), false, false, true);
// width 40 2 indent (25 CTA + 2 gap) = 11 message columns.
assert_eq!(
buf_row(&buf, area, 1),
" 0123456789… hide: /announcements hide"
);
}
#[test]
fn render_banner_shows_first_critical_only() {
let anns = [
RemoteAnnouncement {
severity: Some("info".into()),
title: Some("Info".into()),
message: Some("ignored".into()),
..Default::default()
},
RemoteAnnouncement {
id: Some("first".into()),
severity: Some("critical".into()),
title: Some("First".into()),
message: Some("one".into()),
..Default::default()
},
RemoteAnnouncement {
severity: Some("critical".into()),
title: Some("Second".into()),
message: Some("two".into()),
..Default::default()
},
];
let area = Rect::new(0, 0, 40, 2);
let mut buf = Buffer::empty(area);
render_banner(area, &mut buf, &anns, &no_hidden(), false, false, true);
let painted = buf_row(&buf, area, 0);
assert!(painted.starts_with("! First"), "row0={painted:?}");
assert!(!painted.contains("Second"));
assert!(!painted.contains("Info"));
// Hiding the painted critical must paint the next unhidden one.
let hide_first: BTreeSet<String> = ["first".to_string()].into_iter().collect();
let mut buf = Buffer::empty(area);
render_banner(area, &mut buf, &anns, &hide_first, false, false, true);
let painted = buf_row(&buf, area, 0);
assert!(painted.starts_with("! Second"), "row0={painted:?}");
assert!(!painted.contains("First"));
}
#[test]
fn render_banner_ignores_info_only() {
let anns = [ann(Some("info"), Some("hello"))];
let area = Rect::new(0, 0, 40, 2);
let mut buf = Buffer::empty(area);
let hits = render_banner(area, &mut buf, &anns, &no_hidden(), false, false, true);
assert_eq!(hits, BannerHits::default(), "no banner, no hit rects");
let any: String = (0..area.height)
.flat_map(|y| (0..area.width).map(move |x| (x, y)))
.filter_map(|(x, y)| buf.cell((x, y)).map(|c| c.symbol().to_string()))
.collect();
assert!(!any.contains("hello"));
assert!(!any.contains(HIDE_BUTTON));
}
/// The [hide] button width is reserved before the title budget, so a long
/// title truncates with an ellipsis instead of overpainting the button.
#[test]
fn render_banner_long_title_truncates_before_hide_button() {
let anns = [RemoteAnnouncement {
severity: Some("critical".into()),
title: Some("A".repeat(80)),
message: Some("MSGBODY".into()),
..Default::default()
}];
let area = Rect::new(0, 0, 40, 2);
let mut buf = Buffer::empty(area);
let hits = render_banner(area, &mut buf, &anns, &no_hidden(), false, false, true);
let row0 = buf_row(&buf, area, 0);
assert!(row0.starts_with("! AAA"), "row0={row0:?}");
assert!(
row0.contains('…'),
"long title must show ellipsis; row0={row0:?}"
);
assert!(row0.ends_with(HIDE_BUTTON), "row0={row0:?}");
assert_eq!(hits.hide, Some(Rect::new(34, 0, 6, 1)));
assert!(
buf_row(&buf, area, 1).contains("MSGBODY"),
"long title must not drop the message row"
);
}
// ── Promo ───────────────────────────────────────────────────────────
/// Promo selection mirrors the critical gate: severity filter, hidden
/// skip-reveals-next, and the slash gate stays hidden-agnostic.
#[test]
fn first_promo_selection_filters_severity_and_hidden() {
let list = vec![
ann(Some("info"), Some("info only")),
ann(Some("promo"), None), // no message → not visible
promo("p-a", "A promo", Some(("Go", "https://x.ai"))),
promo("p-b", "B promo", None),
];
assert_eq!(
first_promo_session_announcement(&list, &no_hidden()).and_then(|a| a.id.as_deref()),
Some("p-a")
);
assert_eq!(session_banner_height(&list, &no_hidden()), 1);
let hide_a: BTreeSet<String> = ["p-a".to_string()].into_iter().collect();
assert_eq!(
first_promo_session_announcement(&list, &hide_a).and_then(|a| a.id.as_deref()),
Some("p-b"),
"hiding the first promo must reveal the next one"
);
let hide_both: BTreeSet<String> =
["p-a".to_string(), "p-b".to_string()].into_iter().collect();
assert!(first_promo_session_announcement(&list, &hide_both).is_none());
assert_eq!(session_banner_height(&list, &hide_both), 0);
assert!(
has_session_announcements(&list),
"slash gate ignores hidden so /announcements show stays reachable"
);
let info_only = vec![ann(Some("info"), Some("hello"))];
assert!(!has_session_announcements(&info_only));
}
/// Draw-time expiry for promo, via the same injectable clock seam.
#[test]
fn first_promo_session_announcement_at_skips_expired() {
let mut expiring = promo("p-exp", "expiring", None);
expiring.expires_at = Some("2030-01-01T00:00:00Z".into());
let list = vec![expiring, promo("p-live", "evergreen", None)];
let expiry = chrono::DateTime::parse_from_rfc3339("2030-01-01T00:00:00Z")
.unwrap()
.with_timezone(&chrono::Utc);
let before = expiry - chrono::Duration::seconds(1);
assert_eq!(
first_promo_session_announcement_at(&list, &no_hidden(), before)
.and_then(|a| a.id.as_deref()),
Some("p-exp")
);
assert_eq!(
first_promo_session_announcement_at(&list, &no_hidden(), expiry)
.and_then(|a| a.id.as_deref()),
Some("p-live"),
"expired first promo must yield to the next live one"
);
}
/// Critical always wins the single banner slot, regardless of list order;
/// hiding the critical hands the slot to the promo.
#[test]
fn first_session_announcement_prefers_critical_over_promo() {
let list = vec![
promo("p", "upsell", Some(("Go", "https://x.ai"))),
RemoteAnnouncement {
id: Some("c".into()),
severity: Some("critical".into()),
message: Some("outage".into()),
..Default::default()
},
];
assert_eq!(
first_session_announcement(&list, &no_hidden()).and_then(|a| a.id.as_deref()),
Some("c")
);
assert_eq!(session_banner_height(&list, &no_hidden()), 2);
let hide_crit: BTreeSet<String> = ["c".to_string()].into_iter().collect();
assert_eq!(
first_session_announcement(&list, &hide_crit).and_then(|a| a.id.as_deref()),
Some("p"),
"hidden critical hands the slot to the promo"
);
assert_eq!(session_banner_height(&list, &hide_crit), 1);
}
/// The hidden-ids filter applies only to dismissible items: an explicit
/// `dismissible: false` stays selectable with its hide key stored (a
/// server-side flag flip resurrects a previously-hidden banner), while
/// absent/`true` keep today's hidden behavior.
#[test]
fn non_dismissible_selected_despite_stored_hide_key() {
let mut crit = RemoteAnnouncement {
id: Some("c".into()),
severity: Some("critical".into()),
message: Some("pinned outage".into()),
dismissible: Some(false),
..Default::default()
};
let mut pinned_promo = promo("p", "pinned promo", None);
pinned_promo.dismissible = Some(false);
let hidden: BTreeSet<String> = ["c".to_string(), "p".to_string()].into_iter().collect();
let list = vec![crit.clone(), pinned_promo];
assert_eq!(
first_session_announcement(&list, &hidden).and_then(|a| a.id.as_deref()),
Some("c"),
"stored hide key must not filter a non-dismissible critical"
);
assert_eq!(session_banner_height(&list, &hidden), 2);
assert_eq!(
first_promo_session_announcement(&list, &hidden).and_then(|a| a.id.as_deref()),
Some("p"),
"stored hide key must not filter a non-dismissible promo"
);
// Back-compat: absent and explicit `true` still honor the hidden set.
for dismissible in [None, Some(true)] {
crit.dismissible = dismissible;
assert!(
first_session_announcement(&[crit.clone()], &hidden).is_none(),
"dismissible={dismissible:?} must stay hidden"
);
}
}
/// Non-dismissible critical: neither hide affordance paints and the
/// title/message reclaim the reserved widths (title `W2`, message `W2`
/// vs the dismissible `W262` / `W227`).
#[test]
fn render_critical_rows_non_dismissible_reclaims_hide_columns() {
let anns = [RemoteAnnouncement {
severity: Some("critical".into()),
title: Some("T".repeat(50)),
message: Some("0123456789ABCDEFGHIJ".into()),
dismissible: Some(false),
..Default::default()
}];
let area = Rect::new(0, 0, 40, 2);
let mut buf = Buffer::empty(area);
let hits = render_banner(area, &mut buf, &anns, &no_hidden(), false, false, true);
assert_eq!(hits.hide, None, "no [hide] target on a pinned banner");
// Title budget 402=38: 37 chars + ellipsis fill to the right edge.
let row0 = buf_row(&buf, area, 0);
assert_eq!(row0, format!("! {}", "T".repeat(37)));
assert!(!row0.contains(HIDE_BUTTON), "row0={row0:?}");
// Message budget 402=38: the 20-char message fits whole, no hide CTA
// (the dismissible twin truncates it to 11 columns at this width).
assert_eq!(buf_row(&buf, area, 1), " 0123456789ABCDEFGHIJ");
}
/// Non-dismissible promo: no right-hand hide block, no message — the
/// clickable `[Go]` button, plus its dim `cta.caption` when one is
/// configured and `caption_allowed` (suppressed while a permission prompt
/// owns the chord); no configured caption = bare button. The hit-rect
/// stays the button only (the caption is not clickable).
#[test]
fn render_promo_row_non_dismissible_shows_configured_caption() {
let mut ann = promo("p", &"M".repeat(60), Some(("Go", "https://x.ai")));
ann.dismissible = Some(false);
ann.cta.as_mut().unwrap().caption = Some("or use Ctrl+O".into());
let anns = [ann];
let area = Rect::new(0, 0, 50, 1);
// caption_allowed: the dim caption follows the button; rect is button-only.
let mut buf = Buffer::empty(area);
let hits = render_banner(area, &mut buf, &anns, &no_hidden(), false, false, true);
assert_eq!(hits.hide, None, "no [hide] target on a pinned promo");
assert_eq!(
hits.cta,
Some(Rect::new(0, 0, 4, 1)),
"rect is the button only"
);
assert_eq!(
buf_row(&buf, area, 0),
"[Go] or use Ctrl+O",
"button + configured caption; no message painted"
);
// Not allowed (a permission prompt owns Ctrl+O): button only, no caption.
let mut buf = Buffer::empty(area);
let hits = render_banner(area, &mut buf, &anns, &no_hidden(), false, false, false);
assert_eq!(
hits.cta,
Some(Rect::new(0, 0, 4, 1)),
"button still clickable"
);
assert_eq!(
buf_row(&buf, area, 0),
"[Go]",
"caption suppressed when not allowed"
);
// No caption configured: the pinned row stays a bare button even with
// `caption_allowed` (nothing hardcoded fills in).
let mut bare = promo("p", &"M".repeat(60), Some(("Go", "https://x.ai")));
bare.dismissible = Some(false);
let mut buf = Buffer::empty(area);
let hits = render_banner(area, &mut buf, &[bare], &no_hidden(), false, false, true);
assert_eq!(hits.cta, Some(Rect::new(0, 0, 4, 1)));
assert_eq!(
buf_row(&buf, area, 0),
"[Go]",
"absent caption renders nothing after the button"
);
}
/// `promo_cta_target` requires BOTH trimmed-non-empty label and url — a
/// partial CTA never produces an openable target (or a painted button).
#[test]
fn promo_cta_target_requires_usable_pair() {
let full = vec![promo("p", "msg", Some(("Go", " https://x.ai/promo ")))];
let (a, url) = promo_cta_target(&full, &no_hidden()).expect("usable target");
assert_eq!(a.id.as_deref(), Some("p"));
assert_eq!(url, "https://x.ai/promo");
let mut label_only = promo("p", "msg", None);
label_only.cta = Some(xai_grok_announcements::AnnouncementCta {
label: Some("Go".into()),
url: None,
caption: None,
});
assert!(usable_cta(&label_only).is_none());
assert!(promo_cta_target(&[label_only], &no_hidden()).is_none());
let mut blank_url = promo("p", "msg", Some(("Go", " ")));
assert!(usable_cta(&blank_url).is_none());
blank_url.cta = None;
assert!(promo_cta_target(&[blank_url], &no_hidden()).is_none());
// Hidden promo: no click target exists, so nothing to open.
let hidden: BTreeSet<String> = ["p".to_string()].into_iter().collect();
assert!(promo_cta_target(&full, &hidden).is_none());
}
/// `usable_cta_caption` is trim-nonempty of `cta.caption` and deliberately
/// independent of CTA validity — but an unusable CTA paints no button, so
/// its caption can never surface on the promo row either.
#[test]
fn usable_cta_caption_trims_and_never_resurrects_unusable_cta() {
let mut p = promo("p", "msg", Some(("Go", "https://x.ai")));
assert_eq!(usable_cta_caption(&p), None, "absent caption");
p.cta.as_mut().unwrap().caption = Some(" or use Ctrl+O ".into());
assert_eq!(usable_cta_caption(&p), Some("or use Ctrl+O"), "trimmed");
p.cta.as_mut().unwrap().caption = Some(" ".into());
assert_eq!(usable_cta_caption(&p), None, "whitespace-only = none");
// Caption on a url-less CTA: the accessor still reads it (validity is
// usable_cta's job alone)…
let mut label_only = promo("q", &"M".repeat(60), None);
label_only.dismissible = Some(false);
label_only.cta = Some(xai_grok_announcements::AnnouncementCta {
label: Some("Go".into()),
url: None,
caption: Some("or use Ctrl+O".into()),
});
assert!(usable_cta(&label_only).is_none());
assert_eq!(usable_cta_caption(&label_only), Some("or use Ctrl+O"));
// …but the promo row paints no button, hence no caption anywhere.
let area = Rect::new(0, 0, 50, 1);
let mut buf = Buffer::empty(area);
let hits = render_banner(
area,
&mut buf,
&[label_only],
&no_hidden(),
false,
false,
true,
);
assert_eq!(hits.cta, None, "unusable CTA never arms a button rect");
assert_eq!(
buf_row(&buf, area, 0),
"",
"no button means the caption cannot surface"
);
}
/// `promo_cta` projects the owner + validated `(label, url)` all surfaces
/// paint from; `is_dismissible(owner)` distinguishes the pinned (Ctrl+O)
/// promo from a dismissible one.
#[test]
fn promo_cta_returns_label_and_pinned_flag() {
let mut pinned = promo("p", "msg", Some(("Upgrade Account", "https://x.ai/promo")));
pinned.dismissible = Some(false);
let pinned = [pinned];
let (owner, label, url) = promo_cta(&pinned, &no_hidden()).expect("usable cta");
assert_eq!(label, "Upgrade Account");
assert_eq!(url, "https://x.ai/promo");
assert!(
!is_dismissible(owner),
"pinned promo drives the Ctrl+O override"
);
let dismissible = [promo("d", "msg", Some(("Go", "https://x.ai")))];
let (owner, label, _) = promo_cta(&dismissible, &no_hidden()).expect("usable cta");
assert_eq!(label, "Go");
assert!(
is_dismissible(owner),
"absent flag = dismissible = no override"
);
assert!(promo_cta(&[promo("n", "msg", None)], &no_hidden()).is_none());
}
/// The shared CTA-button painter (used by all four surfaces) clamps the
/// button to `max_width` — never overpainting past it — and returns the
/// clickable button rect. This is what keeps the in-session header /
/// dashboard CTA from writing over the right-aligned status group / chips.
#[test]
fn render_cta_button_clamps_to_max_width() {
let theme = Theme::current();
// Fits: the full button; the rect covers it (no caption requested).
let area = Rect::new(0, 0, 30, 1);
let mut buf = Buffer::empty(area);
let rect = render_cta_button(&mut buf, &theme, 0, 0, 30, "Upgrade", None, false)
.expect("button fits");
assert_eq!(rect, Rect::new(0, 0, 9, 1), "rect covers `[Upgrade]` only");
assert_eq!(buf_row(&buf, area, 0), "[Upgrade]");
// Caption fits: painted after the button; the rect stays button-only.
let area = Rect::new(0, 0, 40, 1);
let mut buf = Buffer::empty(area);
let rect = render_cta_button(&mut buf, &theme, 0, 0, 40, "Go", Some("hi there"), false)
.expect("button fits");
assert_eq!(rect, Rect::new(0, 0, 4, 1), "rect excludes the caption");
assert_eq!(buf_row(&buf, area, 0), "[Go] hi there");
// Caption drops WHOLE when only the button fits (never a partial caption).
let area = Rect::new(0, 0, 6, 1);
let mut buf = Buffer::empty(area);
let rect = render_cta_button(&mut buf, &theme, 0, 0, 6, "Go", Some("hi there"), false)
.expect("button still paints");
assert_eq!(rect, Rect::new(0, 0, 4, 1));
assert_eq!(buf_row(&buf, area, 0), "[Go]", "caption dropped whole");
// Tight: the button truncates — nothing exceeds `max_width`, so the
// header/dashboard CTA can't overpaint the status group.
let area = Rect::new(0, 0, 6, 1);
let mut buf = Buffer::empty(area);
let rect = render_cta_button(&mut buf, &theme, 0, 0, 6, "Upgrade Account", None, false)
.expect("clipped button still paints");
assert!(
rect.width <= 6,
"button clamped to max_width; rect={rect:?}"
);
let row = buf_row(&buf, area, 0);
assert!(
row.chars().count() <= 6,
"no overpaint past max_width; row={row:?}"
);
// Zero budget paints nothing and arms no rect.
let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
assert!(render_cta_button(&mut buf, &theme, 0, 0, 0, "X", None, false).is_none());
}
/// The reserve helper counts the `[label]` button plus the optional caption.
#[test]
fn upgrade_cta_reserve_counts_button_and_caption() {
assert_eq!(upgrade_cta_reserve("Go", None), 4); // `[Go]`
// `[Go]` (4) + leading space (1) + caption width (5).
assert_eq!(upgrade_cta_reserve("Go", Some("hello")), 4 + 1 + 5);
}
/// Slot consistency: `promo_cta_target` resolves through the banner-slot
/// gate, so a live critical owning the slot yields no target (a click
/// through a stale prior-frame rect must not open the promo URL) — and
/// the promo resolves again once the critical is hidden or expired.
#[test]
fn promo_cta_target_yields_to_critical_slot_owner() {
let promo_ann = promo("p", "upsell", Some(("Go", "https://x.ai/promo")));
let crit = RemoteAnnouncement {
id: Some("c".into()),
severity: Some("critical".into()),
message: Some("outage".into()),
..Default::default()
};
let both = vec![promo_ann.clone(), crit.clone()];
assert!(
promo_cta_target(&both, &no_hidden()).is_none(),
"a critical slot owner must yield no CTA target"
);
// Hiding the (dismissible) critical hands the slot back to the promo.
let hide_crit: BTreeSet<String> = ["c".to_string()].into_iter().collect();
assert_eq!(
promo_cta_target(&both, &hide_crit).map(|(a, url)| (a.id.as_deref(), url)),
Some((Some("p"), "https://x.ai/promo"))
);
// So does the critical expiring (same draw/dispatch-time expiry gate).
let mut expired_crit = crit;
expired_crit.expires_at = Some("2000-01-01T00:00:00Z".into());
let with_expired = vec![promo_ann, expired_crit];
assert_eq!(
promo_cta_target(&with_expired, &no_hidden()).and_then(|(a, _)| a.id.as_deref()),
Some("p")
);
}
/// The one CTA gate fails closed on schemes outside the Standard open
/// allowlist: no painted button, no OSC 8 target, no dispatch url — the
/// promo renders no button (its message is never painted on the banner).
#[test]
fn usable_cta_rejects_unsafe_schemes() {
for bad in [
"javascript:alert(1)",
"file:///etc/passwd",
"vscode://open",
"not a url",
] {
let a = promo("p", "msg", Some(("Go", bad)));
assert!(usable_cta(&a).is_none(), "scheme must be rejected: {bad}");
assert!(promo_cta_target(&[a], &no_hidden()).is_none());
}
for good in ["https://x.ai/promo", "http://x.ai/promo"] {
let a = promo("p", "msg", Some(("Go", good)));
assert!(usable_cta(&a).is_some(), "scheme must be allowed: {good}");
}
// Unsafe url arms no button; the message is never painted here, so a
// dismissible promo shows only its hide affordances.
let anns = [promo("p", "Plain message", Some(("Go", "file:///x")))];
let area = Rect::new(0, 0, 60, 1);
let mut buf = Buffer::empty(area);
let hits = render_banner(area, &mut buf, &anns, &no_hidden(), false, false, true);
assert_eq!(hits.cta, None, "unsafe CTA must not arm a click target");
let row0 = buf_row(&buf, area, 0);
assert!(!row0.contains("[Go]"), "row0={row0:?}");
assert!(!row0.contains("Plain message"), "row0={row0:?}");
assert!(row0.ends_with(HIDE_BUTTON), "row0={row0:?}");
}
/// Dismissible promo row: `[Label]` leads (warning yellow), NO message and
/// NO caption even when one is configured (dismissible keeps `Ctrl+O` on
/// YOLO, so the caption is pinned-only regardless of `caption_allowed`),
/// hide affordances right-aligned; rects for both buttons.
#[test]
fn render_promo_row_button_and_hide_affordances() {
let mut ann = promo(
"p",
"New promo",
Some(("Get SuperGrok", "https://x.ai/grok")),
);
ann.cta.as_mut().unwrap().caption = Some("or use Ctrl+O".into());
let anns = [ann];
let area = Rect::new(0, 0, 80, 1);
let mut buf = Buffer::empty(area);
let hits = render_banner(area, &mut buf, &anns, &no_hidden(), false, false, true);
let row0 = buf_row(&buf, area, 0);
assert!(row0.starts_with("[Get SuperGrok]"), "row0={row0:?}");
assert!(
!row0.contains("New promo"),
"message must not paint on the banner; row0={row0:?}"
);
assert!(
!row0.contains("Ctrl+O"),
"a dismissible promo suppresses its configured caption; row0={row0:?}"
);
assert!(row0.ends_with(HIDE_BUTTON), "row0={row0:?}");
assert!(row0.contains(HIDE_CTA), "row0={row0:?}");
// [Label] = 15 cols at x 0; [hide] right-aligned at 806=74; the hide
// CTA ends gap-adjacent to it (74225=47).
assert_eq!(hits.cta, Some(Rect::new(0, 0, 15, 1)), "[Label] hit rect");
assert_eq!(hits.hide, Some(Rect::new(74, 0, 6, 1)), "[hide] hit rect");
let theme = Theme::current();
let button = buf.cell((0, 0)).unwrap();
assert_eq!(button.fg, theme.warning, "[Label] uses semantic warning");
let hide = buf.cell((74, 0)).unwrap();
assert_eq!(hide.fg, theme.gray);
assert!(hide.modifier.contains(Modifier::DIM), "[hide] dim at rest");
let hide_cta = buf.cell((47, 0)).unwrap();
assert_eq!(hide_cta.fg, theme.gray);
assert!(hide_cta.modifier.contains(Modifier::DIM), "hide CTA dim");
}
#[test]
fn render_promo_row_hover_styles() {
let anns = [promo("p", "msg", Some(("Go", "https://x.ai")))];
let area = Rect::new(0, 0, 80, 1);
let theme = Theme::current();
let mut buf = Buffer::empty(area);
let hits = render_banner(area, &mut buf, &anns, &no_hidden(), true, false, true);
let hide = hits.hide.expect("hide painted");
let cell = buf.cell((hide.x, hide.y)).unwrap();
assert_eq!(cell.fg, theme.accent_error, "[hide] hover uses error red");
assert!(!cell.modifier.contains(Modifier::DIM));
let mut buf = Buffer::empty(area);
let hits = render_banner(area, &mut buf, &anns, &no_hidden(), false, true, true);
let cta = hits.cta.expect("cta painted");
let cell = buf.cell((cta.x, cta.y)).unwrap();
assert_eq!(cell.fg, theme.warning, "[Label] keeps warning fg on hover");
assert_eq!(cell.bg, theme.bg_hover, "[Label] hover highlights bg");
}
/// Reservation-first budget: the hide affordances keep their full width and
/// only the `[Label]` button truncates when the row is tight (dismissible
/// promo, width 50: hide block 25+2+6 reserved → ~15 cols for the button).
#[test]
fn render_promo_row_truncates_button_never_affordances() {
let anns = [promo(
"p",
"msg",
Some((
"Upgrade to SuperGrok Heavy for the exclusive preview",
"https://x.ai",
)),
)];
let area = Rect::new(0, 0, 50, 1);
let mut buf = Buffer::empty(area);
let hits = render_banner(area, &mut buf, &anns, &no_hidden(), false, false, true);
let row0 = buf_row(&buf, area, 0);
assert!(row0.starts_with("[Upgrade"), "row0={row0:?}");
assert!(
row0.contains('…'),
"button label must truncate; row0={row0:?}"
);
assert!(row0.contains(HIDE_CTA), "row0={row0:?}");
assert!(row0.ends_with(HIDE_BUTTON), "row0={row0:?}");
assert!(hits.cta.is_some());
assert_eq!(hits.hide, Some(Rect::new(44, 0, 6, 1)));
}
/// No usable CTA → no button and no cta rect; the message is not painted on
/// the banner, so a dismissible promo shows only its hide affordances.
#[test]
fn render_promo_row_without_cta_paints_no_button() {
let anns = [promo("p", "Plain promo message", None)];
let area = Rect::new(0, 0, 60, 1);
let mut buf = Buffer::empty(area);
let hits = render_banner(area, &mut buf, &anns, &no_hidden(), false, false, true);
let row0 = buf_row(&buf, area, 0);
assert!(!row0.contains("Plain promo message"), "row0={row0:?}");
assert!(row0.ends_with(HIDE_BUTTON), "row0={row0:?}");
assert_eq!(hits.cta, None, "no usable CTA, no click target");
assert!(hits.hide.is_some());
}
/// Degenerate width: the hide CTA text is skipped whole (redundant with
/// [hide]) instead of painting a clipped fragment; nothing panics.
#[test]
fn render_promo_row_narrow_width_drops_hide_cta_text() {
let anns = [promo("p", "msg body", Some(("Go", "https://x.ai")))];
let area = Rect::new(0, 0, 20, 1);
let mut buf = Buffer::empty(area);
let hits = render_banner(area, &mut buf, &anns, &no_hidden(), false, false, true);
let row0 = buf_row(&buf, area, 0);
assert!(!row0.contains("hide:"), "row0={row0:?}");
assert!(row0.ends_with(HIDE_BUTTON), "row0={row0:?}");
assert!(row0.starts_with("[Go]"), "row0={row0:?}");
assert_eq!(hits.hide, Some(Rect::new(14, 0, 6, 1)));
}
}