From 07812dfbaf791f30599dd23b954d9c8ed30e5c68 Mon Sep 17 00:00:00 2001 From: Warp Date: Thu, 30 Jul 2026 21:56:07 +0000 Subject: [PATCH 1/3] Make TUI zero-state section visibility configurable Add per-section `appearance.zero_state.show_*` settings so the Warp Agent CLI zero state can hide the signed-in account line, the "What's new" changelog, the project info block, MCP, and the rotating object plus its starfield. Every toggle defaults to true, so the default zero state is unchanged; the title and version lines stay always-on. Hidden sections contribute no rows at all (no orphaned headers or spacer rows), and hiding the animation switches to a copy-only layout so no empty animation panel or starfield is reserved. The zero-state view subscribes to the settings group, so toggling a section re-renders live without a restart. Co-Authored-By: Warp Co-Authored-By: Oz --- app/src/settings/tui_zero_state.rs | 53 ++++ app/src/settings/tui_zero_state_tests.rs | 52 +++- app/src/tui_test_support.rs | 1 + crates/warp_tui/src/zero_state.rs | 231 ++++++++++++--- .../src/zero_state_animation_tests.rs | 9 +- crates/warp_tui/src/zero_state_tests.rs | 267 +++++++++++++++++- 6 files changed, 561 insertions(+), 52 deletions(-) diff --git a/app/src/settings/tui_zero_state.rs b/app/src/settings/tui_zero_state.rs index c3170feab48..a41c8efcec5 100644 --- a/app/src/settings/tui_zero_state.rs +++ b/app/src/settings/tui_zero_state.rs @@ -163,6 +163,59 @@ define_settings_group!(TuiZeroStateSettings, settings: [ toml_path: "appearance.zero_state.extrusion_depth", description: "Normalized half-depth of the extruded Warp Agent CLI zero-state object, from 0.02 through 0.5.", }, + // Per-section visibility toggles. Each defaults to true so the zero state + // keeps rendering every section unless a section is explicitly turned off. + // The title and version lines are always shown and have no toggle. + show_signed_in_user: TuiZeroStateShowSignedInUserSetting { + type: bool, + default: true, + supported_platforms: SupportedPlatforms::DESKTOP, + sync_to_cloud: SyncToCloud::Never, + surface: settings::SettingSurfaces::TUI, + private: false, + toml_path: "appearance.zero_state.show_signed_in_user", + description: "Whether the Warp Agent CLI zero state shows the signed-in account line.", + }, + show_changelog: TuiZeroStateShowChangelogSetting { + type: bool, + default: true, + supported_platforms: SupportedPlatforms::DESKTOP, + sync_to_cloud: SyncToCloud::Never, + surface: settings::SettingSurfaces::TUI, + private: false, + toml_path: "appearance.zero_state.show_changelog", + description: "Whether the Warp Agent CLI zero state shows the \"What's new\" changelog section.", + }, + show_project_info: TuiZeroStateShowProjectInfoSetting { + type: bool, + default: true, + supported_platforms: SupportedPlatforms::DESKTOP, + sync_to_cloud: SyncToCloud::Never, + surface: settings::SettingSurfaces::TUI, + private: false, + toml_path: "appearance.zero_state.show_project_info", + description: "Whether the Warp Agent CLI zero state shows the project path and its discovered rules and skills.", + }, + show_mcp: TuiZeroStateShowMcpSetting { + type: bool, + default: true, + supported_platforms: SupportedPlatforms::DESKTOP, + sync_to_cloud: SyncToCloud::Never, + surface: settings::SettingSurfaces::TUI, + private: false, + toml_path: "appearance.zero_state.show_mcp", + description: "Whether the Warp Agent CLI zero state shows the MCP section.", + }, + show_animation: TuiZeroStateShowAnimationSetting { + type: bool, + default: true, + supported_platforms: SupportedPlatforms::DESKTOP, + sync_to_cloud: SyncToCloud::Never, + surface: settings::SettingSurfaces::TUI, + private: false, + toml_path: "appearance.zero_state.show_animation", + description: "Whether the Warp Agent CLI zero state shows the rotating object and its starfield.", + }, ]); #[cfg(test)] diff --git a/app/src/settings/tui_zero_state_tests.rs b/app/src/settings/tui_zero_state_tests.rs index 9dbcff73ae6..a0aa1ad9d2d 100644 --- a/app/src/settings/tui_zero_state_tests.rs +++ b/app/src/settings/tui_zero_state_tests.rs @@ -10,7 +10,9 @@ use super::{ MIN_TUI_ZERO_STATE_EXTRUSION_DEPTH, MIN_TUI_ZERO_STATE_ROTATION_PERIOD_SECONDS, TuiZeroStateExtrusionDepth, TuiZeroStateExtrusionDepthSetting, TuiZeroStateObject, TuiZeroStateObjectSetting, TuiZeroStateRotationPeriodSeconds, - TuiZeroStateRotationPeriodSecondsSetting, + TuiZeroStateRotationPeriodSecondsSetting, TuiZeroStateShowAnimationSetting, + TuiZeroStateShowChangelogSetting, TuiZeroStateShowMcpSetting, + TuiZeroStateShowProjectInfoSetting, TuiZeroStateShowSignedInUserSetting, }; #[test] @@ -114,7 +116,7 @@ fn zero_state_schema_entries_are_tui_only() { .filter(|entry| entry.hierarchy == Some("appearance.zero_state")) .collect::>(); - assert_eq!(zero_state_entries.len(), 3); + assert_eq!(zero_state_entries.len(), 8); for entry in zero_state_entries { assert!(entry.description.contains("Warp Agent CLI")); assert!(!entry.description.contains("TUI")); @@ -123,3 +125,49 @@ fn zero_state_schema_entries_are_tui_only() { assert!(!surfaces.includes(SettingsMode::Gui)); } } + +#[test] +fn section_visibility_settings_default_to_visible() { + assert!(TuiZeroStateShowSignedInUserSetting::default_value()); + assert!(TuiZeroStateShowChangelogSetting::default_value()); + assert!(TuiZeroStateShowProjectInfoSetting::default_value()); + assert!(TuiZeroStateShowMcpSetting::default_value()); + assert!(TuiZeroStateShowAnimationSetting::default_value()); +} + +#[test] +fn section_visibility_settings_are_tui_local_file_settings() { + for (toml_path, sync_to_cloud) in [ + ( + TuiZeroStateShowSignedInUserSetting::toml_path(), + TuiZeroStateShowSignedInUserSetting::sync_to_cloud(), + ), + ( + TuiZeroStateShowChangelogSetting::toml_path(), + TuiZeroStateShowChangelogSetting::sync_to_cloud(), + ), + ( + TuiZeroStateShowProjectInfoSetting::toml_path(), + TuiZeroStateShowProjectInfoSetting::sync_to_cloud(), + ), + ( + TuiZeroStateShowMcpSetting::toml_path(), + TuiZeroStateShowMcpSetting::sync_to_cloud(), + ), + ( + TuiZeroStateShowAnimationSetting::toml_path(), + TuiZeroStateShowAnimationSetting::sync_to_cloud(), + ), + ] { + let toml_path = toml_path.expect("visibility settings are file settings"); + assert!(toml_path.starts_with("appearance.zero_state.show_")); + assert_eq!(sync_to_cloud, SyncToCloud::Never); + } +} + +#[test] +fn unset_section_visibility_settings_fall_back_to_visible() { + assert!(*TuiZeroStateShowSignedInUserSetting::new(None).value()); + assert!(*TuiZeroStateShowMcpSetting::new(None).value()); + assert!(!*TuiZeroStateShowMcpSetting::new(Some(false)).value()); +} diff --git a/app/src/tui_test_support.rs b/app/src/tui_test_support.rs index d4380b96475..458093181fe 100644 --- a/app/src/tui_test_support.rs +++ b/app/src/tui_test_support.rs @@ -305,6 +305,7 @@ pub fn register_tui_session_view_test_singletons(app: &mut warpui::App) { app.add_singleton_model(|_| ai::project_context::model::ProjectContextModel::default()); app.update(crate::settings::TuiAutoupdateSettings::register); app.update(crate::settings::TuiThemeSettings::register); + app.update(crate::settings::TuiZeroStateSettings::register); app.update(crate::settings::CodeSettings::register); app.update(crate::settings::FontSettings::register); app.update(crate::settings::InputSettings::register); diff --git a/crates/warp_tui/src/zero_state.rs b/crates/warp_tui/src/zero_state.rs index 5c809fbda03..64458cf5fac 100644 --- a/crates/warp_tui/src/zero_state.rs +++ b/crates/warp_tui/src/zero_state.rs @@ -6,6 +6,10 @@ //! slot while the transcript has no visible content, so it dismisses once //! the first accepted submission produces a block and returns whenever the //! transcript empties out again. +//! +//! Which sections the zero state draws is user-configurable: every section +//! except the title and version has an `appearance.zero_state.show_*` toggle +//! (see [`ZeroStateSectionVisibility`]). use std::path::PathBuf; use std::sync::Arc; @@ -14,12 +18,14 @@ use std::time::Duration; use ai::project_context::model::{ ProjectContextModel, ProjectContextModelEvent, ProjectRulesResult, }; +use warp::settings::{TuiZeroStateSettings, TuiZeroStateSettingsChangedEvent}; use warp::tui_export::{ ActiveSession, ActiveSessionEvent, ChangelogModel, ChangelogModelEvent, ChangelogState, SkillManager, SkillManagerEvent, TuiMcpConfigState, TuiMcpManager, TuiMcpServerStatus, TuiUserInfoManager, TuiUserInfoManagerEvent, }; use warp_core::channel::ChannelState; +use warp_core::settings::Setting; use warp_util::local_or_remote_path::LocalOrRemotePath; use warpui::SingletonEntity; use warpui_core::elements::animation::AnimationClock; @@ -50,6 +56,65 @@ const LEFT_COLUMN_COLS: u16 = 48; /// Width of the right-aligned animation region. This keeps the logo secondary /// to the copy and input while leaving enough cells for its wireframe detail. const ANIMATION_PANEL_COLS: u16 = 32; + +// --------------------------------------------------------------------------- +// ZeroStateSectionVisibility +// --------------------------------------------------------------------------- + +/// Which optional zero-state sections are rendered, resolved from the +/// `appearance.zero_state.show_*` settings. +/// +/// Every field defaults to `true`, so an unset (or unreadable) setting keeps +/// the section visible and the zero state looks exactly as it did before the +/// toggles existed. The title and version lines are always drawn and are +/// deliberately not represented here. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct ZeroStateSectionVisibility { + /// The `Signed in as …` account line. + signed_in_user: bool, + /// The "What's new" changelog header and its bullets. + changelog: bool, + /// The project path header together with its rules/skills body. + project_info: bool, + /// The MCP header and status line. + mcp: bool, + /// The rotating object together with its starfield. + animation: bool, +} + +impl Default for ZeroStateSectionVisibility { + fn default() -> Self { + Self { + signed_in_user: true, + changelog: true, + project_info: true, + mcp: true, + animation: true, + } + } +} + +impl ZeroStateSectionVisibility { + /// Reads the current toggles from [`TuiZeroStateSettings`]. + /// + /// Falls back to [`Self::default`] (everything visible) when the settings + /// group is not registered, which is the case in focused unit tests that + /// render zero-state elements without the full app settings stack. + fn from_settings(ctx: &AppContext) -> Self { + if !ctx.has_singleton_model::() { + return Self::default(); + } + let settings = TuiZeroStateSettings::as_ref(ctx); + Self { + signed_in_user: *settings.show_signed_in_user.value(), + changelog: *settings.show_changelog.value(), + project_info: *settings.show_project_info.value(), + mcp: *settings.show_mcp.value(), + animation: *settings.show_animation.value(), + } + } +} + // --------------------------------------------------------------------------- // TuiZeroStateView // --------------------------------------------------------------------------- @@ -62,6 +127,7 @@ pub(crate) struct TuiZeroStateView { clock: AnimationClock, animation_config: Arc, active_session: ModelHandle, + section_visibility: ZeroStateSectionVisibility, } impl TuiZeroStateView { @@ -121,11 +187,45 @@ impl TuiZeroStateView { ZeroStateAnimationConfigEvent::LoadFailed(_) => {} }, ); + // Re-render as soon as a section is toggled so the zero state follows + // the settings file without restarting. The object/rotation/extrusion + // settings are owned by `ZeroStateAnimationConfig`, which emits its own + // `Updated` event, so they are ignored here. + if ctx.has_singleton_model::() { + ctx.subscribe_to_model(&TuiZeroStateSettings::handle(ctx), |view, _, event, ctx| { + match event { + TuiZeroStateSettingsChangedEvent::TuiZeroStateShowSignedInUserSetting { + .. + } + | TuiZeroStateSettingsChangedEvent::TuiZeroStateShowChangelogSetting { + .. + } + | TuiZeroStateSettingsChangedEvent::TuiZeroStateShowProjectInfoSetting { + .. + } + | TuiZeroStateSettingsChangedEvent::TuiZeroStateShowMcpSetting { .. } + | TuiZeroStateSettingsChangedEvent::TuiZeroStateShowAnimationSetting { + .. + } => { + view.section_visibility = ZeroStateSectionVisibility::from_settings(ctx); + ctx.notify(); + } + TuiZeroStateSettingsChangedEvent::TuiZeroStateObjectSetting { .. } + | TuiZeroStateSettingsChangedEvent::TuiZeroStateRotationPeriodSecondsSetting { + .. + } + | TuiZeroStateSettingsChangedEvent::TuiZeroStateExtrusionDepthSetting { + .. + } => {} + } + }); + } Self { clock: AnimationClock::starting_at(Duration::ZERO), animation_config: animation_config_snapshot, active_session, + section_visibility: ZeroStateSectionVisibility::from_settings(ctx), } } } @@ -147,6 +247,13 @@ impl TuiView for TuiZeroStateView { .ok() .map(|cwd| cwd.to_string_lossy().into_owned()) }); + let overlay = + build_zero_state_overlay(cwd.as_deref(), self.section_visibility, &builder, ctx); + if !self.section_visibility.animation { + // No animation means no starfield and no reserved animation panel: + // the copy is the whole zero state. + return build_zero_state_copy_only_layout(overlay); + } let animation = ZeroStateAnimationElement::new( self.clock, self.animation_config.clone(), @@ -166,7 +273,6 @@ impl TuiView for TuiZeroStateView { ANIMATION_PANEL_COLS, ) .finish(); - let overlay = build_zero_state_overlay(cwd.as_deref(), &builder, ctx); build_zero_state_layout(starfield, animation, overlay) } } @@ -197,19 +303,29 @@ fn build_zero_state_layout( .flex_child(animation_region) .finish(); + TuiStack::new() + .child(starfield) + .child(animation_layer) + .child(centered_overlay_layer(overlay)) + .finish() +} + +/// The layout used when the animation section is hidden: just the vertically +/// centered copy, with no starfield behind it and no animation panel reserved +/// beside it. +fn build_zero_state_copy_only_layout(overlay: Box) -> Box { + centered_overlay_layer(overlay) +} + +/// Vertically centers the opaque copy block within the available rows. +fn centered_overlay_layer(overlay: Box) -> Box { let overlay = TuiContainer::new(overlay) .with_background(Color::Reset) .finish(); - let overlay_layer = TuiFlex::column() + TuiFlex::column() .flex_child(TuiText::new("").finish()) .child(overlay) .flex_child(TuiText::new("").finish()) - .finish(); - - TuiStack::new() - .child(starfield) - .child(animation_layer) - .child(overlay_layer) .finish() } /// Assembles the text-overlay column placed on top of the animation layer. @@ -217,16 +333,22 @@ fn build_zero_state_layout( /// Both [`TuiZeroStateView::render`] and the regression tests call this function so /// that a change to how `render` composes the overlay (e.g. moving the path header /// back inside the `LEFT_COLUMN_COLS` constrained box) is caught by the test suite. +/// +/// `visibility` decides which optional sections are drawn; a hidden section +/// contributes no rows at all (no header, no spacer). fn build_zero_state_overlay( cwd: Option<&str>, + visibility: ZeroStateSectionVisibility, builder: &TuiUiBuilder, ctx: &AppContext, ) -> Box { // Compute project context once — find_applicable_project_rules walks the // directory tree and clones rule file contents, so resolving it once // avoids a redundant allocation on every zero-state re-render (pwd change, - // changelog load, MCP update, PathIndexed). - let (path_header_text, project_rules) = match cwd { + // changelog load, MCP update, PathIndexed). Skipped entirely when the + // project section is hidden. + let project_cwd = visibility.project_info.then_some(cwd).flatten(); + let (path_header_text, project_rules) = match project_cwd { Some(cwd) => { let cwd_path = LocalOrRemotePath::Local(PathBuf::from(cwd)); let rules = ProjectContextModel::as_ref(ctx).find_applicable_project_rules(&cwd_path); @@ -236,23 +358,16 @@ fn build_zero_state_overlay( None => (None, None), }; - // Title, version, and changelog — constrained to LEFT_COLUMN_COLS so changelog - // bullets (which lack `.truncate()`) do not wrap against the full terminal width. - let constrained_top = TuiConstrainedBox::new(render_top_section(builder, ctx).finish()) - .with_min_cols(LEFT_COLUMN_COLS) - .with_max_cols(LEFT_COLUMN_COLS) - .finish(); + // Title, version, and (when visible) the account line and changelog — + // constrained to LEFT_COLUMN_COLS so changelog bullets (which lack + // `.truncate()`) do not wrap against the full terminal width. + let constrained_top = + TuiConstrainedBox::new(render_top_section(visibility, builder, ctx).finish()) + .with_min_cols(LEFT_COLUMN_COLS) + .with_max_cols(LEFT_COLUMN_COLS) + .finish(); - // Project context body (rules / skills / placeholder) and MCP — also constrained - // to LEFT_COLUMN_COLS, keeping those rows stable. - // Pass the pre-computed rules so find_applicable_project_rules is not called twice. - let rules_ref = project_rules.flatten(); - let constrained_bottom = TuiConstrainedBox::new( - render_bottom_section(cwd, rules_ref.as_ref(), builder, ctx).finish(), - ) - .with_min_cols(LEFT_COLUMN_COLS) - .with_max_cols(LEFT_COLUMN_COLS) - .finish(); + let mut column = TuiFlex::column().child(constrained_top); // The project path header lives *outside* the 48-column constrained boxes so it // can expand to the full available terminal width. Give it a blank-row separator @@ -264,26 +379,40 @@ fn build_zero_state_overlay( let path_header = TuiText::new(path_header_text) .with_style(header_style) .finish(); - TuiFlex::column() - .child(constrained_top) - .child(blank_row()) - .child(path_header) - .child(constrained_bottom) - .finish() - } else { - TuiFlex::column() - .child(constrained_top) - .child(constrained_bottom) - .finish() + column = column.child(blank_row()).child(path_header); } + + // Project context body (rules / skills / placeholder) and MCP — also constrained + // to LEFT_COLUMN_COLS, keeping those rows stable. Omitted altogether when both + // sections are hidden so no empty box is measured. + // Pass the pre-computed rules so find_applicable_project_rules is not called twice. + if project_cwd.is_some() || visibility.mcp { + let rules_ref = project_rules.flatten(); + let constrained_bottom = TuiConstrainedBox::new( + render_bottom_section(project_cwd, rules_ref.as_ref(), visibility, builder, ctx) + .finish(), + ) + .with_min_cols(LEFT_COLUMN_COLS) + .with_max_cols(LEFT_COLUMN_COLS) + .finish(); + column = column.child(constrained_bottom); + } + + column.finish() } -/// Top section of the overlay column: title, version, and changelog bullets. +/// Top section of the overlay column: title, version, the signed-in account +/// line, and changelog bullets. The account line and changelog are each drawn +/// only when `visibility` enables them; the title and version are always drawn. /// /// This is wrapped in a [`TuiConstrainedBox`] with `min = max = LEFT_COLUMN_COLS` by the /// caller so that changelog bullets (which lack `.truncate()`) do not word-wrap against /// the full terminal width while still rendering stably during async content loads. -fn render_top_section(builder: &TuiUiBuilder, app: &AppContext) -> TuiFlex { +fn render_top_section( + visibility: ZeroStateSectionVisibility, + builder: &TuiUiBuilder, + app: &AppContext, +) -> TuiFlex { let title_style = builder.accent_text_style().add_modifier(Modifier::BOLD); let header_style = builder.primary_text_style().add_modifier(Modifier::BOLD); let muted = builder.muted_text_style(); @@ -295,10 +424,16 @@ fn render_top_section(builder: &TuiUiBuilder, app: &AppContext) -> TuiFlex { .truncate() .finish(), ) - .child(render_version_line(builder, app)) - .child(render_login_line(builder, app)); + .child(render_version_line(builder, app)); + if visibility.signed_in_user { + column = column.child(render_login_line(builder, app)); + } - let bullets = changelog_bullets(app); + let bullets = if visibility.changelog { + changelog_bullets(app) + } else { + Vec::new() + }; if !bullets.is_empty() { column = column.child(blank_row()).child( TuiText::new("What's new") @@ -321,7 +456,10 @@ fn render_top_section(builder: &TuiUiBuilder, app: &AppContext) -> TuiFlex { } /// Bottom section of the overlay column: project context body (rules / skills / placeholder) -/// when a `cwd` is present, followed by the MCP section. +/// when a `cwd` is present, followed by the MCP section when it is visible. +/// +/// `cwd` is already `None` when the project section is hidden, so this function only has to +/// gate the MCP rows on `visibility`. /// /// The project path *header* is intentionally omitted here — it lives outside the constrained /// box so it can expand to the full available terminal width (see [`TuiZeroStateView::render`]). @@ -331,6 +469,7 @@ fn render_top_section(builder: &TuiUiBuilder, app: &AppContext) -> TuiFlex { fn render_bottom_section( cwd: Option<&str>, rules: Option<&ProjectRulesResult>, + visibility: ZeroStateSectionVisibility, builder: &TuiUiBuilder, app: &AppContext, ) -> TuiFlex { @@ -340,7 +479,11 @@ fn render_bottom_section( } else { column }; - render_mcp_section(column, builder, app) + if visibility.mcp { + render_mcp_section(column, builder, app) + } else { + column + } } /// Returns the abbreviated path text displayed as the project section header. diff --git a/crates/warp_tui/src/zero_state_animation_tests.rs b/crates/warp_tui/src/zero_state_animation_tests.rs index 6e5c647c6f8..1e557f0b1f7 100644 --- a/crates/warp_tui/src/zero_state_animation_tests.rs +++ b/crates/warp_tui/src/zero_state_animation_tests.rs @@ -6,7 +6,9 @@ use tempfile::TempDir; use warp::settings::{ TuiZeroStateExtrusionDepthSetting, TuiZeroStateObject, TuiZeroStateObjectSetting, TuiZeroStateRotationPeriodSeconds, TuiZeroStateRotationPeriodSecondsSetting, - TuiZeroStateSettings, + TuiZeroStateSettings, TuiZeroStateShowAnimationSetting, TuiZeroStateShowChangelogSetting, + TuiZeroStateShowMcpSetting, TuiZeroStateShowProjectInfoSetting, + TuiZeroStateShowSignedInUserSetting, }; use warp_core::settings::Setting as _; use warpui::SingletonEntity as _; @@ -520,6 +522,11 @@ fn settings_model_reloads_only_object_changes() { })), rotation_period_seconds: TuiZeroStateRotationPeriodSecondsSetting::new(None), extrusion_depth: TuiZeroStateExtrusionDepthSetting::new(None), + show_signed_in_user: TuiZeroStateShowSignedInUserSetting::new(None), + show_changelog: TuiZeroStateShowChangelogSetting::new(None), + show_project_info: TuiZeroStateShowProjectInfoSetting::new(None), + show_mcp: TuiZeroStateShowMcpSetting::new(None), + show_animation: TuiZeroStateShowAnimationSetting::new(None), }); ZeroStateAnimationConfig::register(ctx); }); diff --git a/crates/warp_tui/src/zero_state_tests.rs b/crates/warp_tui/src/zero_state_tests.rs index a1be565c0f8..32b32c992f5 100644 --- a/crates/warp_tui/src/zero_state_tests.rs +++ b/crates/warp_tui/src/zero_state_tests.rs @@ -2,11 +2,20 @@ use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; +use channel_versions::{Changelog, Section}; +use chrono::DateTime; use uuid::Uuid; +use warp::settings::{ + TuiZeroStateExtrusionDepthSetting, TuiZeroStateObjectSetting, + TuiZeroStateRotationPeriodSecondsSetting, TuiZeroStateSettings, + TuiZeroStateShowAnimationSetting, TuiZeroStateShowChangelogSetting, TuiZeroStateShowMcpSetting, + TuiZeroStateShowProjectInfoSetting, TuiZeroStateShowSignedInUserSetting, +}; use warp::tui_export::{ - TuiMcpConfigState, TuiMcpServerId, TuiMcpServerSnapshot, TuiMcpServerStatus, TuiMcpSnapshot, - TuiMcpTransport, register_tui_session_view_test_singletons, + ChangelogModel, ChangelogState, TuiMcpConfigState, TuiMcpServerId, TuiMcpServerSnapshot, + TuiMcpServerStatus, TuiMcpSnapshot, TuiMcpTransport, register_tui_session_view_test_singletons, }; +use warp_core::settings::Setting as _; use warpui::{EntityIdMap, SingletonEntity}; use warpui_core::elements::animation::AnimationClock; use warpui_core::elements::tui::{ @@ -16,7 +25,8 @@ use warpui_core::elements::tui::{ use warpui_core::{App, AppContext}; use super::{ - ANIMATION_PANEL_COLS, LEFT_COLUMN_COLS, build_zero_state_layout, build_zero_state_overlay, + ANIMATION_PANEL_COLS, LEFT_COLUMN_COLS, ZeroStateSectionVisibility, + build_zero_state_copy_only_layout, build_zero_state_layout, build_zero_state_overlay, mcp_status_label, }; use crate::tui_builder::TuiUiBuilder; @@ -24,6 +34,36 @@ use crate::zero_state_animation::{ WarpLogoStyles, ZeroStateAnimationConfig, ZeroStateAnimationElement, ZeroStateStarfieldElement, }; +/// Every optional zero-state section hidden. +fn all_sections_hidden() -> ZeroStateSectionVisibility { + ZeroStateSectionVisibility { + signed_in_user: false, + changelog: false, + project_info: false, + mcp: false, + animation: false, + } +} + +/// Installs a changelog with a single bullet so the "What's new" section has +/// content to render. +fn add_test_changelog(app: &mut App) { + app.update(|ctx| { + ChangelogModel::handle(ctx).update(ctx, |model, _| { + model.changelog = ChangelogState::Some(Changelog { + date: DateTime::parse_from_rfc3339("2026-01-01T00:00:00+00:00").unwrap(), + sections: vec![Section { + title: "New features".to_string(), + items: vec!["Configurable zero state".to_string()], + }], + markdown_sections: Vec::new(), + image_url: None, + oz_updates: Vec::new(), + }); + }); + }); +} + fn server(id: u64, status: TuiMcpServerStatus) -> TuiMcpServerSnapshot { TuiMcpServerSnapshot { id: TuiMcpServerId(id), @@ -319,7 +359,12 @@ fn zero_state_path_header_not_truncated_at_wide_terminal() { // Give the overlay exactly enough width for the displayed path. // Call build_zero_state_overlay -- the same function render() calls. - let overlay = build_zero_state_overlay(Some(long_cwd), &builder, app_ctx); + let overlay = build_zero_state_overlay( + Some(long_cwd), + ZeroStateSectionVisibility::default(), + &builder, + app_ctx, + ); let buffer = render_to_buffer(overlay, app_ctx, text_width(&header_text), 12); let lines = buffer.to_lines(); @@ -404,7 +449,12 @@ fn zero_state_path_header_wraps_without_losing_content_at_narrow_terminal() { "test path must wrap at the narrow terminal width" ); - let overlay = build_zero_state_overlay(Some(long_cwd), &builder, app_ctx); + let overlay = build_zero_state_overlay( + Some(long_cwd), + ZeroStateSectionVisibility::default(), + &builder, + app_ctx, + ); let buffer = render_to_buffer(overlay, app_ctx, narrow_width, 12); let lines = buffer.to_lines(); @@ -429,3 +479,210 @@ fn zero_state_path_header_wraps_without_losing_content_at_narrow_terminal() { }); }); } + +// --------------------------------------------------------------------------- +// Per-section visibility (APP-5070) +// --------------------------------------------------------------------------- + +/// With the default settings every section is rendered, so the overlay keeps +/// showing the account line, the changelog, the project path, and MCP. +#[test] +fn zero_state_shows_every_section_by_default() { + App::test((), |mut app| async move { + register_tui_session_view_test_singletons(&mut app); + app.update(crate::autoupdate::TuiAutoupdater::register); + add_test_changelog(&mut app); + + app.read(|ctx| { + let builder = TuiUiBuilder::from_app(ctx); + let overlay = build_zero_state_overlay( + Some("/tmp/project"), + ZeroStateSectionVisibility::default(), + &builder, + ctx, + ); + let rendered = render_element_lines(overlay, ctx, 60, 24).join("\n"); + + assert!(rendered.contains("Warp Agent CLI"), "{rendered}"); + assert!( + rendered.contains("Signed in as test_user@warp.dev"), + "{rendered}" + ); + assert!(rendered.contains("What's new"), "{rendered}"); + assert!(rendered.contains("Configurable zero state"), "{rendered}"); + assert!(rendered.contains("/tmp/project"), "{rendered}"); + assert!(rendered.contains("MCP"), "{rendered}"); + }); + }); +} + +/// Each toggle hides exactly its own section and leaves the others alone. +#[test] +fn zero_state_hides_only_the_section_whose_toggle_is_off() { + App::test((), |mut app| async move { + register_tui_session_view_test_singletons(&mut app); + app.update(crate::autoupdate::TuiAutoupdater::register); + add_test_changelog(&mut app); + + app.read(|ctx| { + let builder = TuiUiBuilder::from_app(ctx); + let render_with = |visibility| { + let overlay = + build_zero_state_overlay(Some("/tmp/project"), visibility, &builder, ctx); + render_element_lines(overlay, ctx, 60, 24).join("\n") + }; + + let hidden_account = render_with(ZeroStateSectionVisibility { + signed_in_user: false, + ..ZeroStateSectionVisibility::default() + }); + assert!(!hidden_account.contains("Signed in"), "{hidden_account}"); + assert!(hidden_account.contains("What's new"), "{hidden_account}"); + assert!(hidden_account.contains("/tmp/project"), "{hidden_account}"); + assert!(hidden_account.contains("MCP"), "{hidden_account}"); + + let hidden_changelog = render_with(ZeroStateSectionVisibility { + changelog: false, + ..ZeroStateSectionVisibility::default() + }); + assert!( + !hidden_changelog.contains("What's new"), + "{hidden_changelog}" + ); + assert!( + !hidden_changelog.contains("Configurable zero state"), + "{hidden_changelog}" + ); + assert!(hidden_changelog.contains("Signed in"), "{hidden_changelog}"); + + let hidden_project = render_with(ZeroStateSectionVisibility { + project_info: false, + ..ZeroStateSectionVisibility::default() + }); + assert!(!hidden_project.contains("/tmp/project"), "{hidden_project}"); + assert!(hidden_project.contains("MCP"), "{hidden_project}"); + + let hidden_mcp = render_with(ZeroStateSectionVisibility { + mcp: false, + ..ZeroStateSectionVisibility::default() + }); + assert!(!hidden_mcp.contains("MCP"), "{hidden_mcp}"); + assert!(hidden_mcp.contains("/tmp/project"), "{hidden_mcp}"); + }); + }); +} + +/// Turning every section off leaves only the always-on title and version rows — +/// no orphaned headers and no blank spacer rows for the hidden sections. +#[test] +fn zero_state_with_all_sections_hidden_renders_only_title_and_version() { + App::test((), |mut app| async move { + register_tui_session_view_test_singletons(&mut app); + app.update(crate::autoupdate::TuiAutoupdater::register); + add_test_changelog(&mut app); + + app.read(|ctx| { + let builder = TuiUiBuilder::from_app(ctx); + let overlay = build_zero_state_overlay( + Some("/tmp/project"), + all_sections_hidden(), + &builder, + ctx, + ); + let lines = render_element_lines(overlay, ctx, 60, 24); + + assert_eq!( + lines.len(), + 2, + "only the title and version rows should remain;\ngot lines:\n{}", + lines.join("\n") + ); + assert_eq!(lines[0].trim_end(), "Warp Agent CLI"); + assert!(!lines[1].trim_end().is_empty(), "{:?}", lines[1]); + }); + }); +} + +/// Hiding the animation drops the starfield and the reserved animation panel +/// instead of leaving an empty region beside the copy. +#[test] +fn zero_state_copy_only_layout_reserves_no_animation_panel() { + App::test((), |app| async move { + app.read(|ctx| { + let copy_only = render_to_buffer( + build_zero_state_copy_only_layout(TuiText::new("copy here").finish()), + ctx, + 120, + 9, + ); + assert_eq!( + copy_only.area.width, + text_width("copy here"), + "the copy-only layout should occupy just the copy, not the animation panel" + ); + assert!( + copy_only + .to_lines() + .iter() + .any(|line| line.trim_end() == "copy here") + ); + + let with_animation = render_to_buffer( + build_zero_state_layout( + TuiText::new("*".repeat(120)).finish(), + TuiText::new("").finish(), + TuiText::new("copy here").finish(), + ), + ctx, + 120, + 9, + ); + assert!( + with_animation.area.width > copy_only.area.width, + "the animated layout still spans the full width" + ); + }); + }); +} + +/// The visibility snapshot reads the settings group when it is registered and +/// falls back to "everything visible" when it is not. +#[test] +fn zero_state_visibility_reads_settings_and_defaults_to_visible() { + App::test((), |mut app| async move { + app.read(|ctx| { + assert_eq!( + ZeroStateSectionVisibility::from_settings(ctx), + ZeroStateSectionVisibility::default(), + "an unregistered settings group must keep every section visible" + ); + }); + + app.update(|ctx| { + ctx.add_singleton_model(|_| TuiZeroStateSettings { + object: TuiZeroStateObjectSetting::new(None), + rotation_period_seconds: TuiZeroStateRotationPeriodSecondsSetting::new(None), + extrusion_depth: TuiZeroStateExtrusionDepthSetting::new(None), + show_signed_in_user: TuiZeroStateShowSignedInUserSetting::new(Some(false)), + show_changelog: TuiZeroStateShowChangelogSetting::new(None), + show_project_info: TuiZeroStateShowProjectInfoSetting::new(Some(false)), + show_mcp: TuiZeroStateShowMcpSetting::new(None), + show_animation: TuiZeroStateShowAnimationSetting::new(Some(false)), + }); + }); + + app.read(|ctx| { + assert_eq!( + ZeroStateSectionVisibility::from_settings(ctx), + ZeroStateSectionVisibility { + signed_in_user: false, + changelog: true, + project_info: false, + mcp: true, + animation: false, + }, + "unset toggles keep their visible default while set ones are honored" + ); + }); + }); +} From 7e32e58eb31f1d6e4bd93d862f1e91eea87db1ca Mon Sep 17 00:00:00 2001 From: Yunfan Yang Date: Fri, 31 Jul 2026 19:45:09 -0400 Subject: [PATCH 2/3] Update zero-state test call sites Keep existing upstream tests compiling with the new visibility settings without adding feature-specific coverage.\n\nCo-Authored-By: Warp --- crates/warp_tui/src/zero_state_animation_tests.rs | 9 ++++++++- crates/warp_tui/src/zero_state_tests.rs | 9 +++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/crates/warp_tui/src/zero_state_animation_tests.rs b/crates/warp_tui/src/zero_state_animation_tests.rs index 559d2210af2..ad2b1de52ab 100644 --- a/crates/warp_tui/src/zero_state_animation_tests.rs +++ b/crates/warp_tui/src/zero_state_animation_tests.rs @@ -9,7 +9,9 @@ use tempfile::TempDir; use warp::settings::{ TuiZeroStateExtrusionDepthSetting, TuiZeroStateObject, TuiZeroStateObjectSetting, TuiZeroStateRotationPeriodSeconds, TuiZeroStateRotationPeriodSecondsSetting, - TuiZeroStateSettings, + TuiZeroStateSettings, TuiZeroStateShowAnimationSetting, TuiZeroStateShowChangelogSetting, + TuiZeroStateShowMcpSetting, TuiZeroStateShowProjectInfoSetting, + TuiZeroStateShowSignedInUserSetting, }; use warp_core::settings::Setting as _; use warpui::{EntityIdMap, SingletonEntity as _}; @@ -609,6 +611,11 @@ fn settings_model_reloads_only_object_changes() { })), rotation_period_seconds: TuiZeroStateRotationPeriodSecondsSetting::new(None), extrusion_depth: TuiZeroStateExtrusionDepthSetting::new(None), + show_signed_in_user: TuiZeroStateShowSignedInUserSetting::new(None), + show_changelog: TuiZeroStateShowChangelogSetting::new(None), + show_project_info: TuiZeroStateShowProjectInfoSetting::new(None), + show_mcp: TuiZeroStateShowMcpSetting::new(None), + show_animation: TuiZeroStateShowAnimationSetting::new(None), }); ZeroStateAnimationConfig::register(ctx); }); diff --git a/crates/warp_tui/src/zero_state_tests.rs b/crates/warp_tui/src/zero_state_tests.rs index 38e2ea1c460..51e01ad45ce 100644 --- a/crates/warp_tui/src/zero_state_tests.rs +++ b/crates/warp_tui/src/zero_state_tests.rs @@ -18,9 +18,9 @@ use warpui_core::elements::tui::{ use warpui_core::{App, AppContext}; use super::{ - ANIMATION_PANEL_COLS, LEFT_COLUMN_COLS, build_zero_state_layout, build_zero_state_overlay, - build_zero_state_stack_layout, changelog_bullets_from_changelog, mcp_status_label, - render_first_run_top_section, + ANIMATION_PANEL_COLS, LEFT_COLUMN_COLS, ZeroStateSectionVisibility, build_zero_state_layout, + build_zero_state_overlay, build_zero_state_stack_layout, changelog_bullets_from_changelog, + mcp_status_label, render_first_run_top_section, }; use crate::tui_builder::TuiUiBuilder; use crate::zero_state_animation::{ @@ -83,7 +83,8 @@ fn first_zero_state_matches_welcome_design_copy() { let lines = app.read(|ctx| { let builder = TuiUiBuilder::from_app(ctx); render_element_lines( - render_first_run_top_section(&builder, ctx).finish(), + render_first_run_top_section(&builder, ZeroStateSectionVisibility::default(), ctx) + .finish(), ctx, LEFT_COLUMN_COLS, 16, From 52a0f9387d149bc13c16626a6dcf6e16797b7687 Mon Sep 17 00:00:00 2001 From: Yunfan Yang Date: Fri, 31 Jul 2026 22:37:56 -0400 Subject: [PATCH 3/3] Remove brittle TUI zero-state schema test --- app/src/settings/tui_zero_state_tests.rs | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/app/src/settings/tui_zero_state_tests.rs b/app/src/settings/tui_zero_state_tests.rs index 9dbcff73ae6..58363f125cb 100644 --- a/app/src/settings/tui_zero_state_tests.rs +++ b/app/src/settings/tui_zero_state_tests.rs @@ -1,8 +1,7 @@ use std::path::PathBuf; use serde::Deserialize as _; -use settings::schema::SettingSchemaEntry; -use settings::{Setting, SettingSurfaces, SettingsMode, SyncToCloud}; +use settings::{Setting, SyncToCloud}; use settings_value::SettingsValue; use super::{ @@ -106,20 +105,3 @@ fn zero_state_settings_are_tui_local_file_settings() { ); assert_eq!(TuiZeroStateObjectSetting::max_table_depth(), Some(0)); } - -#[test] -fn zero_state_schema_entries_are_tui_only() { - let zero_state_entries = inventory::iter:: - .into_iter() - .filter(|entry| entry.hierarchy == Some("appearance.zero_state")) - .collect::>(); - - assert_eq!(zero_state_entries.len(), 3); - for entry in zero_state_entries { - assert!(entry.description.contains("Warp Agent CLI")); - assert!(!entry.description.contains("TUI")); - let surfaces: SettingSurfaces = (entry.surfaces_fn)(); - assert!(surfaces.includes(SettingsMode::Tui)); - assert!(!surfaces.includes(SettingsMode::Gui)); - } -}