From 15dec05cd3748fd13b9587ed3b69a358c59c17b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Dec 2025 20:48:22 +0000 Subject: [PATCH] feat(tui): Add Nori welcome banner widget with ASCII art Add a self-contained NoriBanner widget that displays: - Green ANSI-colored ASCII art for "NORI" logo - Profile name status line - "powered by Nori" tagline The widget is implemented in a new file to minimize conflicts with upstream work. Includes: - Unit tests with insta snapshots (vt100-tests feature) - E2E test stubs in tui-pty-e2e (TDD RED phase - will pass once banner is integrated into startup flow) --- codex-rs/tui-pty-e2e/tests/nori_banner.rs | 85 +++++++++++ codex-rs/tui/src/lib.rs | 2 + codex-rs/tui/src/nori_banner.rs | 142 ++++++++++++++++++ ..._banner__tests__nori_banner_ascii_art.snap | 12 ++ ...r__tests__nori_banner_profile_tagline.snap | 12 ++ 5 files changed, 253 insertions(+) create mode 100644 codex-rs/tui-pty-e2e/tests/nori_banner.rs create mode 100644 codex-rs/tui/src/nori_banner.rs create mode 100644 codex-rs/tui/src/snapshots/codex_tui__nori_banner__tests__nori_banner_ascii_art.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__nori_banner__tests__nori_banner_profile_tagline.snap diff --git a/codex-rs/tui-pty-e2e/tests/nori_banner.rs b/codex-rs/tui-pty-e2e/tests/nori_banner.rs new file mode 100644 index 000000000..e6d00c68d --- /dev/null +++ b/codex-rs/tui-pty-e2e/tests/nori_banner.rs @@ -0,0 +1,85 @@ +//! E2E tests for the Nori welcome banner display. +//! +//! These tests verify that the Nori ASCII art banner is displayed +//! correctly during startup. + +use insta::assert_snapshot; +use tui_pty_e2e::normalize_for_snapshot; +use tui_pty_e2e::SessionConfig; +use tui_pty_e2e::TuiSession; +use tui_pty_e2e::TIMEOUT; +use tui_pty_e2e::TIMEOUT_INPUT; + +#[test] +fn test_startup_shows_nori_banner() { + let mut session = TuiSession::spawn_with_config( + 24, + 80, + SessionConfig::default() + // Don't include the values that would bypass welcome + .without_approval_policy() + .without_sandbox() + .with_config_toml(""), + ) + .expect("Failed to spawn codex"); + + // Wait for the Nori ASCII art to appear + session + .wait_for_text("|_| \\_|", TIMEOUT) + .expect("Nori ASCII art banner did not appear"); + std::thread::sleep(TIMEOUT_INPUT); + + let contents = session.screen_contents(); + + // Verify Nori ASCII art is present (distinctive part of the logo) + assert!( + contents.contains("|_| \\_|"), + "Expected Nori ASCII art banner, but got: {}", + contents + ); + + // Verify the tagline is present + assert!( + contents.contains("powered by Nori"), + "Expected Nori tagline, but got: {}", + contents + ); + + assert_snapshot!( + "startup_nori_banner", + normalize_for_snapshot(session.screen_contents()) + ); +} + +#[test] +fn test_nori_banner_shows_profile() { + let mut session = TuiSession::spawn_with_config( + 24, + 80, + SessionConfig::default() + .without_approval_policy() + .without_sandbox() + .with_config_toml(""), + ) + .expect("Failed to spawn codex"); + + // Wait for the Nori banner to appear + session + .wait_for_text("|_| \\_|", TIMEOUT) + .expect("Nori ASCII art banner did not appear"); + std::thread::sleep(TIMEOUT_INPUT); + + let contents = session.screen_contents(); + + // Verify profile line is displayed + assert!( + contents.contains("profile:"), + "Expected profile line in banner, but got: {}", + contents + ); + + assert_snapshot!( + "nori_banner_profile", + normalize_for_snapshot(session.screen_contents()) + ); +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index ca7cba9c2..fe0b6958e 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -58,6 +58,7 @@ mod markdown; mod markdown_render; mod markdown_stream; mod model_migration; +mod nori_banner; pub mod onboarding; mod oss_selection; mod pager_overlay; @@ -92,6 +93,7 @@ use crate::onboarding::onboarding_screen::run_onboarding_app; use crate::tui::Tui; pub use cli::Cli; pub use markdown_render::render_markdown_text; +pub use nori_banner::NoriBanner; pub use public_widgets::composer_input::ComposerAction; pub use public_widgets::composer_input::ComposerInput; use std::io::Write as _; diff --git a/codex-rs/tui/src/nori_banner.rs b/codex-rs/tui/src/nori_banner.rs new file mode 100644 index 000000000..b6a2d3cf9 --- /dev/null +++ b/codex-rs/tui/src/nori_banner.rs @@ -0,0 +1,142 @@ +//! Nori welcome banner widget with ASCII art and status line. +//! +//! This module provides a self-contained banner widget that displays: +//! - Green ANSI-colored ASCII art for "NORI" +//! - A status line with profile name and tagline + +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; +use ratatui::widgets::WidgetRef; + +/// ASCII art for "NORI" logo +const NORI_ASCII_ART: &[&str] = &[ + r" _ _ ___ ____ ___ ", + r"| \ | |/ _ \| _ \|_ _|", + r"| \| | | | | |_) || | ", + r"| |\ | |_| | _ < | | ", + r"|_| \_|\___/|_| \_\___|", +]; + +/// A welcome banner widget displaying the Nori ASCII art and status line. +pub struct NoriBanner { + profile: String, +} + +impl NoriBanner { + /// Creates a new NoriBanner with the given profile name. + pub fn new(profile: impl Into) -> Self { + Self { + profile: profile.into(), + } + } + + /// Renders the banner lines with styling applied. + fn render_lines(&self) -> Vec> { + let mut lines: Vec> = Vec::new(); + + // Add ASCII art lines in green + for art_line in NORI_ASCII_ART { + lines.push(Line::from(Span::styled( + art_line.to_string(), + ratatui::style::Style::default().fg(Color::Green), + ))); + } + + // Add empty line for spacing + lines.push(Line::from("")); + + // Add profile line + lines.push(Line::from(format!("profile: {}", self.profile))); + + // Add tagline + lines.push(Line::from("🍙 powered by Nori 🍙")); + + lines + } +} + +impl WidgetRef for &NoriBanner { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + let lines = self.render_lines(); + Paragraph::new(lines).render(area, buf); + } +} + +#[cfg(all(test, feature = "vt100-tests"))] +mod tests { + use super::*; + use crate::test_backend::VT100Backend; + use ratatui::Terminal; + + #[test] + fn nori_banner_renders_ascii_art() { + let banner = NoriBanner::new("clifford"); + let mut terminal = + Terminal::new(VT100Backend::new(80, 12)).expect("Failed to create terminal"); + terminal + .draw(|f| (&banner).render_ref(f.area(), f.buffer_mut())) + .expect("Failed to draw"); + + let contents = terminal.backend().to_string(); + + // Verify ASCII art is present (check for distinctive parts of the art) + assert!( + contents.contains(r"|_| \_|"), + "Banner should contain ASCII art for NORI logo" + ); + assert!( + contents.contains(r"| \ | |"), + "Banner should contain ASCII art characters" + ); + + insta::assert_snapshot!("nori_banner_ascii_art", terminal.backend().to_string()); + } + + #[test] + fn nori_banner_shows_profile_and_tagline() { + let banner = NoriBanner::new("test-profile"); + let mut terminal = + Terminal::new(VT100Backend::new(80, 12)).expect("Failed to create terminal"); + terminal + .draw(|f| (&banner).render_ref(f.area(), f.buffer_mut())) + .expect("Failed to draw"); + + let contents = terminal.backend().to_string(); + + assert!( + contents.contains("profile: test-profile"), + "Banner should show profile name" + ); + assert!( + contents.contains("🍙 powered by Nori 🍙"), + "Banner should show tagline" + ); + + insta::assert_snapshot!( + "nori_banner_profile_tagline", + terminal.backend().to_string() + ); + } + + #[test] + fn nori_banner_uses_green_color() { + let banner = NoriBanner::new("clifford"); + let lines = banner.render_lines(); + + // Check that the first line (ASCII art) has green styling + let first_line = &lines[0]; + assert!(!first_line.spans.is_empty(), "First line should have spans"); + + let first_span = &first_line.spans[0]; + assert_eq!( + first_span.style.fg, + Some(Color::Green), + "ASCII art should be styled with green color" + ); + } +} diff --git a/codex-rs/tui/src/snapshots/codex_tui__nori_banner__tests__nori_banner_ascii_art.snap b/codex-rs/tui/src/snapshots/codex_tui__nori_banner__tests__nori_banner_ascii_art.snap new file mode 100644 index 000000000..5ac220482 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__nori_banner__tests__nori_banner_ascii_art.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/nori_banner.rs +expression: terminal.backend().to_string() +--- + _ _ ___ ____ ___ +| \ | |/ _ \| _ \|_ _| +| \| | | | | |_) || | +| |\ | |_| | _ < | | +|_| \_|\___/|_| \_\___| + +profile: clifford +🍙 powered by Nori 🍙 diff --git a/codex-rs/tui/src/snapshots/codex_tui__nori_banner__tests__nori_banner_profile_tagline.snap b/codex-rs/tui/src/snapshots/codex_tui__nori_banner__tests__nori_banner_profile_tagline.snap new file mode 100644 index 000000000..9a109e823 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__nori_banner__tests__nori_banner_profile_tagline.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/nori_banner.rs +expression: terminal.backend().to_string() +--- + _ _ ___ ____ ___ +| \ | |/ _ \| _ \|_ _| +| \| | | | | |_) || | +| |\ | |_| | _ < | | +|_| \_|\___/|_| \_\___| + +profile: test-profile +🍙 powered by Nori 🍙