diff --git a/DESIGN-CHANGES.md b/DESIGN-CHANGES.md index c18852d33..e18075d48 100644 --- a/DESIGN-CHANGES.md +++ b/DESIGN-CHANGES.md @@ -147,6 +147,43 @@ dependencies were added. expand chevron toggles the persistent collapsed state off. - Covered the manual collapse mode resolver with targeted unit tests. +### Bundled theme set reduced to six + +The bundled catalog went from 24 themes to **6**: `CastCodes Dark` (default), +`Dark`, `Light`, `Dracula`, `Gruvbox Dark`, `Solarized Dark` +([`app/src/themes/theme.rs`](app/src/themes/theme.rs)). + +Retired: 10 background-image themes (Jellyfish, Koi, Leafy, Marble, Pink City, +Snowy, Dark City, Red Rock, Solar Flare, Phenomenon), 3 gradient themes +(Cyber Wave, Willow Dream, Fancy Dracula), the 2 referral-reward themes, plus +Adeberry, Solarized Light, and Gruvbox Light. The decorative themes conflict +with the Phase 1 contract (calm, dense, editor-grade, no decorative chrome), +and the referral themes could never unlock in an OSS build. + +Compatibility: + +- Retired `ThemeKind` variants are kept and still deserialize, so existing + settings files load. They are marked `#[schemars(skip)]` so they no longer + appear in the settings JSON schema. +- `ThemeKind::retired_replacement()` maps each retired kind to a surviving + theme of the same lightness — light themes resolve to `Light`, Fancy Dracula + to `Dracula`, the rest to `CastCodes Dark` — so a user on a light theme never + snaps onto a dark background. `WarpThemeConfig::theme()` consults it before + falling back. +- Deleting the active custom theme now resets to `ThemeKind::default()` + (CastCodes Dark) instead of the generic `Dark`. +- The upstream `DefaultAdeberryTheme` new-user override was removed; CastCodes + Dark is already the default. +- The onboarding picker now offers CastCodes Dark, Dark, Light, and Dracula. + +### Radius contract cleanup + +- `app/src/env_vars/env_var_collection_block.rs` — collection card was 9px, + now the named `ENV_VAR_COLLECTION_RADIUS` at the 8px contract maximum. +- `app/src/ai/document/orchestration_config_block.rs` — the toggle track used a + literal 9px on an 18px-tall pill; now `Radius::Percentage(50.)`, matching the + thumb and expressing the pill intent (pixel-identical). + ## Deferred (follow-up PRs) The remainder of Phase 1 was originally deferred here. Items 1.3–1.8 have @@ -206,3 +243,82 @@ Recommended verification before merge: ./script/check_rebrand cargo check -p warp-app --bin cast-codes --features gui ``` + +## Contrast fixes and a sizing scale + +Follow-up to the UI/UX review. The review found the design system was sound +where it existed (theme tokens, semantic color accessors) but unenforced: +colors were re-derived as literals at some render sites, and radius, spacing +and type had no code representation at all. + +### Theme-independent literals that broke on half the themes + +These were not stylistic preferences — each one hard-codes a color that only +works against one background lightness, so it degrades or disappears on the +other bundled themes. + +- **Settings working-directory input** + (`app/src/settings_view/features/working_directory.rs`): text was + `ColorU::black()` over a `surface_2()` background, i.e. black-on-dark and + effectively invisible in every dark theme. Now + `main_text_color(surface_2())`. +- **Tab bar icons** (`app/src/tab.rs`, three sites): the maximize indicator + and both tab-kind icons fell back to `ColorU::white()` when the tab style + carried no font color, which is white-on-light in the Light theme. Now + fall back to `active_ui_text_color()`, matching the pattern the ambient + agent icon already used a few lines away. +- **Account avatar** (`app/src/workspace/view.rs`): black initials on the + `#7c3aed` accent fill fails contrast. Now + `active_highlighted_text_color()`. +- **Shared-block overflow icon** + (`app/src/settings_view/show_blocks_view.rs`): fixed `#b3bab8` glyph, low + contrast on light backgrounds. Now `nonactive_ui_text_color()`. Its + container also moved from a 5px to the 4px contract radius. +- **Dead command-palette statics** + (`app/src/search/command_search/view.rs`): `SEARCH_ICON_COLOR` and + `INPUT_FIELD_BG_COLOR` were translucent-white constants with no remaining + callers. Removed rather than re-themed. + +`active_highlighted_text_color()` now reads the `primary_foreground` token +before deriving a color from the accent. The token was defined in +`castcodes_ui_tokens` but nothing consumed it, and the accessor itself had no +callers — the avatar above is its first. Themes without a `ui` block keep the +previous derived value, covered by +`active_highlighted_text_color_falls_back_to_text_on_accent`. + +Deliberately left alone after checking: + +- `icon_with_status.rs` draws a black glyph on a fixed light-purple ambient + badge. The background is a brand constant, not a theme surface, so black is + the contrast-correct choice. +- `traffic_lights.rs` uses white on `WINDOWS_BRIGHT_RED` for the Windows + close-button hover, which is the platform convention. +- `login_slide.rs` copy reads as hosted-service marketing, but the login gate + is unreachable in OSS builds (`root_view.rs` requires + `ChannelState::cloud_services_available()`, and `auth_state.rs` returns + early without it). Retoning it would be churn on a dead path. + +### A sizing scale to reference + +`app/src/ui_components/design.rs` is new: radius, spacing, type and motion +steps from the Phase 1 contract, expressed as constants. Colors stay in the +theme accessors, since those must vary per theme while these do not. + +Adopted at `ui_components::BORDER_RADIUS`, the shared status chip +(`ai/conversation_status_ui.rs`), the env-var collection surface, the tab +top radius and the avatar type size. Inherited Warp views still use bare +literals; 48 radii sit outside the 4/6/8 scale, mostly 1-3px hairlines on +badges and glyphs. Those are migrated as views are touched rather than in one +sweep, which would churn hundreds of visually unverifiable call sites. + +The scale is declared in full even where the fork has not adopted a step yet, +because a partial vocabulary just sends the next author back to a bare +literal; the module carries a scoped `#![allow(dead_code)]` for that reason. +`motion` has no adopters at all — warpui still has no transition primitive — +so it documents the intended timing rather than driving it. + +### Build verification + +- `cargo check -p warp-app --features gui --lib` ✅ zero warnings. +- `cargo test -p warp_core ui::theme` ✅ including the two new + `active_highlighted_text_color` tests. diff --git a/app/Cargo.toml b/app/Cargo.toml index 45d415676..6b17f071f 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -576,6 +576,7 @@ default = [ "configurable_toolbar", "transfer_control_tool", "open_code_notifications", + "pluggable_notifications", "cli_agent_rich_input", "vertical_tabs", "tab_configs", diff --git a/app/src/ai/conversation_status_ui.rs b/app/src/ai/conversation_status_ui.rs index 2146ffd9f..e4f6e929c 100644 --- a/app/src/ai/conversation_status_ui.rs +++ b/app/src/ai/conversation_status_ui.rs @@ -2,15 +2,19 @@ use warp_core::ui::appearance::Appearance; use warp_core::ui::color::coloru_with_opacity; use warp_core::ui::theme::{Fill, WarpTheme}; use warpui::color::ColorU; -use warpui::elements::{ConstrainedBox, Container, CornerRadius, Radius}; +use warpui::elements::{ + ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Flex, MainAxisSize, Padding, + ParentElement, Radius, Text, +}; use warpui::Element; use crate::ai::agent::conversation::ConversationStatus; use crate::ai::agent_conversations_model::AgentRunDisplayStatus; +use crate::ui_components::design; use crate::ui_components::icons::Icon; /// Padding around the status icon -pub const STATUS_ELEMENT_PADDING: f32 = 2.; +pub const STATUS_ELEMENT_PADDING: f32 = design::space::HAIRLINE; pub trait StatusElementStyle { fn status_icon_and_color(&self, theme: &WarpTheme) -> (Icon, ColorU); @@ -45,6 +49,53 @@ pub fn render_status_element( ) .with_uniform_padding(STATUS_ELEMENT_PADDING) .with_background(coloru_with_opacity(color, 10)) - .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.))) + .with_corner_radius(CornerRadius::with_all(Radius::Pixels( + design::radius::COMPACT, + ))) + .finish() +} + +/// Render a compact status pill with the status icon and a short text label. +/// Shared by pane chrome surfaces (vertical tab detail rows, pane headers) so +/// each status reads identically everywhere it appears with a label. +pub fn render_status_element_with_label( + status: &impl StatusElementStyle, + label: impl Into, + appearance: &Appearance, +) -> Box { + let theme = appearance.theme(); + let (icon, color) = status.status_icon_and_color(theme); + + Container::new( + Flex::row() + .with_main_axis_size(MainAxisSize::Min) + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_spacing(design::space::XS) + .with_child( + ConstrainedBox::new(icon.to_warpui_icon(Fill::from(color)).finish()) + .with_width(12.) + .with_height(12.) + .finish(), + ) + .with_child( + Text::new_inline( + label.into(), + appearance.ui_font_family(), + design::font_size::MICRO, + ) + .with_color(Fill::from(color).into()) + .finish(), + ) + .finish(), + ) + .with_padding( + Padding::uniform(design::space::HAIRLINE) + .with_left(design::space::XS) + .with_right(design::space::XS), + ) + .with_background(coloru_with_opacity(color, 10)) + .with_corner_radius(CornerRadius::with_all(Radius::Pixels( + design::radius::COMPACT, + ))) .finish() } diff --git a/app/src/ai/document/orchestration_config_block.rs b/app/src/ai/document/orchestration_config_block.rs index c9962e659..4bf022716 100644 --- a/app/src/ai/document/orchestration_config_block.rs +++ b/app/src/ai/document/orchestration_config_block.rs @@ -56,7 +56,7 @@ fn render_pill_toggle(is_on: bool, theme: &WarpTheme) -> Box { .finish(); ConstrainedBox::new( Container::new(switch_inner) - .with_corner_radius(CornerRadius::with_all(Radius::Pixels(9.))) + .with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.))) .with_background_color(track_bg) .finish(), ) diff --git a/app/src/auth/login_slide.rs b/app/src/auth/login_slide.rs index 4d764d0b0..5f0c64519 100644 --- a/app/src/auth/login_slide.rs +++ b/app/src/auth/login_slide.rs @@ -235,10 +235,7 @@ fn resolve_visual_path( OnboardingIntention::Terminal => "terminal_intention", }; let name_key = match theme_name { - "Phenomenon" => "phenomenon", - "Dark" => "dark", "Light" => "light", - "Adeberry" => "adeberry", _ => "dark", }; let orientation = if use_vertical_tabs { diff --git a/app/src/env_vars/env_var_collection_block.rs b/app/src/env_vars/env_var_collection_block.rs index b67b80f92..99c6ecc69 100644 --- a/app/src/env_vars/env_var_collection_block.rs +++ b/app/src/env_vars/env_var_collection_block.rs @@ -43,7 +43,8 @@ use crate::{ /// The vertical padding applied to the env var collection block's content body. /// For horizontal padding, use [`INLINE_ACTION_HORIZONTAL_PADDING`] for consistency. const ENV_VAR_COLLECTION_BODY_VERTICAL_PADDING: f32 = 16.; - +/// Large-surface corner radius, capped at the 8px design-contract maximum. +const ENV_VAR_COLLECTION_RADIUS: f32 = crate::ui_components::design::radius::SURFACE; const ENV_VAR_COLLECTION_CANCEL_LABEL: &str = "Cancel"; const ENV_VAR_COLLECTION_ACCEPT_LABEL: &str = "Run"; @@ -422,9 +423,9 @@ impl View for EnvVarCollectionBlock { }) // Rounded corners will make the header feel disconnected from its expanded details. .with_corner_radius(if should_expand_downward { - CornerRadius::with_top(Radius::Pixels(9.)) + CornerRadius::with_top(Radius::Pixels(ENV_VAR_COLLECTION_RADIUS)) } else { - CornerRadius::with_all(Radius::Pixels(9.)) + CornerRadius::with_all(Radius::Pixels(ENV_VAR_COLLECTION_RADIUS)) }) .with_border(Border::all(1.).with_border_fill(border_color)) .finish(); diff --git a/app/src/lib.rs b/app/src/lib.rs index 8a63a9813..5ce82ff3e 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -2954,7 +2954,6 @@ pub fn enabled_features() -> HashSet { | FeatureFlag::WarpManagedSecrets | FeatureFlag::CloudConversations | FeatureFlag::AmbientAgentsRTC - | FeatureFlag::PluggableNotifications | FeatureFlag::AgentViewConversationListView | FeatureFlag::CloudMode | FeatureFlag::CloudModeFromLocalSession @@ -2976,3 +2975,20 @@ pub fn enabled_features() -> HashSet { flags } + +#[cfg(test)] +mod enabled_features_tests { + use super::*; + use warp_core::channel::Channel; + + #[test] + #[serial_test::serial] + fn oss_enables_local_cli_agent_notifications() { + let _channel = ChannelState::override_channel_for_test(Channel::Oss); + + assert!( + enabled_features().contains(&FeatureFlag::PluggableNotifications), + "OSS builds need OSC 9/777 events for local CLI agent status" + ); + } +} diff --git a/app/src/pane_group/mod.rs b/app/src/pane_group/mod.rs index 60afab15e..5bd9c30c4 100644 --- a/app/src/pane_group/mod.rs +++ b/app/src/pane_group/mod.rs @@ -6661,6 +6661,16 @@ impl PaneGroup { .collect() } + /// Number of visible terminal panes in this pane group whose agent run is + /// blocked awaiting the user's response (e.g. a permission request or a + /// question from the agent). + pub fn panes_awaiting_user_response(&self, ctx: &AppContext) -> usize { + self.terminal_views(ctx) + .into_iter() + .filter(|view| view.as_ref(ctx).is_awaiting_user_response(ctx)) + .count() + } + pub fn code_views(&self, ctx: &AppContext) -> Vec> { self.panes_of::() .map(|p| p.file_view(ctx)) diff --git a/app/src/search/command_search/view.rs b/app/src/search/command_search/view.rs index 396d95c6b..ed5c73b37 100644 --- a/app/src/search/command_search/view.rs +++ b/app/src/search/command_search/view.rs @@ -1061,8 +1061,6 @@ impl CommandSearchView { } pub mod styles { - use lazy_static::lazy_static; - use pathfinder_color::ColorU; use warpui::elements::{Border, DropShadow, ScrollbarWidth}; use crate::{appearance::Appearance, themes::theme::Fill}; @@ -1073,11 +1071,6 @@ pub mod styles { pub const TOP_PADDING: f32 = CORNER_RADIUS; pub const SCROLLBAR_WIDTH: ScrollbarWidth = ScrollbarWidth::Auto; - lazy_static! { - pub static ref SEARCH_ICON_COLOR: ColorU = ColorU::new(255, 255, 255, 204); - pub static ref INPUT_FIELD_BG_COLOR: ColorU = ColorU::new(255, 255, 255, 50); - } - /// Returns the `Fill` to be used as the background of the search results panel and details /// panel. pub fn panel_background_fill(appearance: &Appearance) -> Fill { diff --git a/app/src/settings/initializer.rs b/app/src/settings/initializer.rs index ab482173f..5b0762b39 100644 --- a/app/src/settings/initializer.rs +++ b/app/src/settings/initializer.rs @@ -8,9 +8,8 @@ use crate::{ auth::auth_state::AuthState, report_if_error, settings::input::InputBoxType, - settings::{InputSettings, PrivacySettings, ThemeSettings}, + settings::{InputSettings, PrivacySettings}, terminal::session_settings::SessionSettings, - themes::theme::ThemeKind, }; pub struct SettingsInitializer; @@ -45,15 +44,6 @@ impl SettingsInitializer { settings.disable_default_regex_trigger(ctx); }); - if FeatureFlag::DefaultAdeberryTheme.is_enabled() { - log::debug!("Setting default theme to Adeberry for new user"); - ThemeSettings::handle(ctx).update(ctx, |settings, ctx| { - if !settings.theme_kind.is_value_explicitly_set() { - report_if_error!(settings.theme_kind.set_value(ThemeKind::Adeberry, ctx)); - } - }); - } - if cfg!(windows) { log::debug!("Setting default font size to 16px (12pt) for a new Windows user"); FontSettings::handle(ctx).update(ctx, |settings, ctx| { diff --git a/app/src/settings_view/features/working_directory.rs b/app/src/settings_view/features/working_directory.rs index 1b067452d..8285361d7 100644 --- a/app/src/settings_view/features/working_directory.rs +++ b/app/src/settings_view/features/working_directory.rs @@ -267,7 +267,12 @@ fn render_row( .text_input(editor.clone()) .with_style(UiComponentStyles { height: Some(TOP_MENU_BAR_HEIGHT), - font_color: Some(pathfinder_color::ColorU::black()), + font_color: Some( + appearance + .theme() + .main_text_color(appearance.theme().surface_2()) + .into(), + ), font_size: Some(appearance.ui_font_size()), padding: Some(Coords::uniform(7.)), margin: Some(Coords::default().left(8.).right(8.)), diff --git a/app/src/settings_view/show_blocks_view.rs b/app/src/settings_view/show_blocks_view.rs index 4a0888914..fb7e1594c 100644 --- a/app/src/settings_view/show_blocks_view.rs +++ b/app/src/settings_view/show_blocks_view.rs @@ -11,6 +11,7 @@ use crate::{ channel::{Channel, ChannelState}, menu::{Event as MenuEvent, Event, Menu, MenuItem, MenuItemFields}, server::{block::Block, server_api::block::BlockClient}, + ui_components::design, view_components::ToastFlavor, }; use anyhow::Result; @@ -18,6 +19,7 @@ use chrono::{DateTime, FixedOffset, Local}; use pathfinder_geometry::vector::vec2f; use std::sync::Arc; use warp_core::ui::theme::color::internal_colors; +use warpui::elements::Radius; use warpui::ui_components::button::ButtonVariant; use warpui::ui_components::components::{UiComponent, UiComponentStyles}; use warpui::{ @@ -30,7 +32,6 @@ use warpui::{ ScrollableElement, Shrinkable, Stack, UniformList, UniformListState, }, }; -use warpui::{color::ColorU, elements::Radius}; use warpui::{elements::ScrollbarWidth, fonts::Weight}; use warpui::{ AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle, @@ -109,15 +110,20 @@ impl UserOwnedBlock { Hoverable::new(self.overflow_button_mouse_state_handle.clone(), |state| { let container = Container::new( ConstrainedBox::new( - Icon::new("bundled/svg/overflow.svg", ColorU::new(179, 186, 184, 255)) - .finish(), + Icon::new( + "bundled/svg/overflow.svg", + appearance.theme().nonactive_ui_text_color(), + ) + .finish(), ) .with_height(20.) .with_width(20.) .finish(), ) - .with_uniform_padding(4.) - .with_corner_radius(CornerRadius::with_all(Radius::Pixels(5.))); + .with_uniform_padding(design::space::XS) + .with_corner_radius(CornerRadius::with_all(Radius::Pixels( + design::radius::COMPACT, + ))); let container = if state.is_clicked() || state.is_hovered() { container.with_background(appearance.theme().surface_2()) diff --git a/app/src/tab.rs b/app/src/tab.rs index 3d8be5b34..046666ce6 100644 --- a/app/src/tab.rs +++ b/app/src/tab.rs @@ -584,6 +584,11 @@ enum Indicator { Maximized, /// We should show a shell indicator for the tab. Shell(ShellIndicatorType), + /// One or more panes in this tab have an agent blocked awaiting the + /// user's response. + NeedsResponse { + pane_count: usize, + }, Agent { conversation_status: Option, }, @@ -769,6 +774,8 @@ impl<'a> TabComponent<'a> { Indicator::UnsavedChanges } else if !should_show_indicators { Indicator::None + } else if let Some(needs_response) = Self::needs_response_indicator(tab, ctx) { + needs_response } else if are_inputs_synced { Indicator::Synced } else if let Some(agent) = Self::agent_indicator(tab, ctx) { @@ -821,6 +828,14 @@ impl<'a> TabComponent<'a> { self } + /// Returns the needs-response indicator when any visible pane in this tab + /// (focused or not, Oz conversation or CLI agent session) is blocked + /// awaiting the user's response, so the user can spot it from the tab bar. + fn needs_response_indicator(tab: &TabData, ctx: &AppContext) -> Option { + let pane_count = tab.pane_group.as_ref(ctx).panes_awaiting_user_response(ctx); + (pane_count > 0).then_some(Indicator::NeedsResponse { pane_count }) + } + /// Returns the agent indicator for the focused session's active conversation, /// or `None` if there is no non-empty, non-passive conversation to display. /// When a shell command is long-running the status is overridden to @@ -881,6 +896,14 @@ impl<'a> TabComponent<'a> { tab: &TabData, ctx: &AppContext, ) -> Option { + if let Indicator::NeedsResponse { pane_count } = indicator { + return Some(if *pane_count > 1 { + format!("{pane_count} agents need your response") + } else { + "An agent needs your response".to_string() + }); + } + if Self::is_agent_task_indicator(indicator) { return Self::get_agent_task_tooltip_message(tab, ctx); } @@ -1159,7 +1182,9 @@ impl<'a> TabComponent<'a> { self.styles .default .font_color - .unwrap_or(ColorU::white()) + .unwrap_or_else(|| { + self.appearance.theme().active_ui_text_color().into() + }) .into(), ) .finish(), @@ -1170,6 +1195,17 @@ impl<'a> TabComponent<'a> { .to_warpui_icon(internal_colors::neutral_5(self.appearance.theme()).into()) .finish(), ), + Indicator::NeedsResponse { .. } => { + let status = ConversationStatus::Blocked { + blocked_action: String::new(), + }; + if FeatureFlag::NewTabStyling.is_enabled() { + let icon_size = 22.0 - STATUS_ELEMENT_PADDING * 2.; + Some(render_status_element(&status, icon_size, self.appearance)) + } else { + Some(status.render_icon(self.appearance).finish()) + } + } Indicator::Agent { conversation_status, } => { @@ -1248,7 +1284,9 @@ impl<'a> TabComponent<'a> { self.styles .default .font_color - .unwrap_or(ColorU::white()) + .unwrap_or_else(|| { + self.appearance.theme().active_ui_text_color().into() + }) .into(), ) .finish(), @@ -1385,7 +1423,9 @@ impl<'a> TabComponent<'a> { self.styles .default .font_color - .unwrap_or(ColorU::white()) + .unwrap_or_else(|| { + self.appearance.theme().active_ui_text_color().into() + }) .into(), ) .finish() @@ -1517,7 +1557,9 @@ impl<'a> TabComponent<'a> { // that merges into the content area below. The active tab's accent // underline (added below) sits along that flat edge. Tabs are spaced // by a small gap (added in `build`) rather than abutting separators. - tab = tab.with_corner_radius(CornerRadius::with_top(Radius::Pixels(6.0))); + tab = tab.with_corner_radius(CornerRadius::with_top(Radius::Pixels( + crate::ui_components::design::radius::CONTROL, + ))); // Only the active/hovered card draws a subtle outline (top + sides, // open bottom); idle tabs stay borderless for a calm, uncluttered // strip. diff --git a/app/src/terminal/view/pane_impl.rs b/app/src/terminal/view/pane_impl.rs index 21e81df2e..5defdc552 100644 --- a/app/src/terminal/view/pane_impl.rs +++ b/app/src/terminal/view/pane_impl.rs @@ -8,6 +8,7 @@ use crate::ai::agent::conversation::{ use crate::ai::blocklist::agent_view::agent_view_bg_fill; use crate::ai::blocklist::agent_view::orchestration_conversation_links::parent_conversation_navigation_card; use crate::ai::blocklist::BlocklistAIHistoryModel; +use crate::ai::conversation_status_ui::render_status_element_with_label; use crate::appearance::Appearance; use crate::features::FeatureFlag; use crate::menu::{MenuItem, MenuItemFields}; @@ -19,8 +20,10 @@ use crate::pane_group::pane::view::header::components::{ use crate::pane_group::pane::view::header::PANE_HEADER_HEIGHT; use crate::pane_group::pane::PaneStack; use crate::pane_group::{pane::view, pane::view::PaneHeaderAction, BackingView, SplitPaneState}; +use crate::terminal::cli_agent_sessions::listener::agent_supports_rich_status; use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel; use crate::terminal::model::terminal_model::ConversationTranscriptViewerStatus; +use crate::terminal::CLIAgent; use crate::terminal::TerminalManager; use crate::terminal::TerminalView; use crate::ui_components::agent_icon::terminal_view_agent_icon_variant; @@ -297,6 +300,20 @@ impl TerminalView { center_row.add_child(title_element); } + // Surface a "Needs response" chip directly in the pane header when + // this pane's agent is blocked awaiting the user, so the pane itself + // signals it needs attention. + if self.is_awaiting_user_response(app) { + let pill = render_status_element_with_label( + &ConversationStatus::Blocked { + blocked_action: String::new(), + }, + "Needs response", + appearance, + ); + center_row.add_child(Container::new(pill).with_margin_left(6.).finish()); + } + center_row.finish() } @@ -918,6 +935,27 @@ impl TerminalView { } } + /// Returns `true` when this pane's agent run is blocked awaiting the user's + /// response (e.g. a permission request or a question from the agent). + /// + /// Mirrors the status sources used by the vertical-tab status pills: a + /// plugin-backed CLI agent session with rich status, or the selected Oz + /// conversation's display status. + pub fn is_awaiting_user_response(&self, ctx: &AppContext) -> bool { + let cli_session_blocked = CLIAgentSessionsModel::as_ref(ctx) + .session(self.view_id) + .filter(|session| session.listener.is_some()) + .filter(|session| !matches!(session.agent, CLIAgent::Unknown)) + .filter(|session| agent_supports_rich_status(&session.agent)) + .is_some_and(|session| session.status.to_conversation_status().is_blocked()); + if cli_session_blocked { + return true; + } + + self.selected_conversation_status_for_display(ctx) + .is_some_and(|status| status.is_blocked()) + } + pub fn selected_conversation_display_title(&self, ctx: &AppContext) -> Option { let is_ambient_agent = self.is_ambient_agent_session(ctx); self.selected_conversation_for_user_facing_chrome(ctx) diff --git a/app/src/terminal/view_tests.rs b/app/src/terminal/view_tests.rs index 98db277ff..82ec3c102 100644 --- a/app/src/terminal/view_tests.rs +++ b/app/src/terminal/view_tests.rs @@ -4889,6 +4889,104 @@ fn cli_session_status_updates_active_child_conversation() { }) } +#[test] +fn is_awaiting_user_response_tracks_cli_session_blocked_status() { + App::test((), |mut app| async move { + initialize_app_for_terminal_view(&mut app); + let _agent_view = FeatureFlag::AgentView.override_enabled(true); + + let terminal = add_window_with_terminal(&mut app, None); + + // A plain terminal with no agent session is not awaiting a response. + terminal.read(&app, |view, ctx| { + assert!(!view.is_awaiting_user_response(ctx)); + }); + + terminal.update(&mut app, |view, ctx| { + let listener = ctx.add_model(|ctx| { + CLIAgentSessionListener::new( + view.view_id, + CLIAgent::Claude, + &view.model_events_handle, + ctx, + ) + }); + CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| { + sessions.set_session( + view.view_id, + CLIAgentSession { + agent: CLIAgent::Claude, + status: CLIAgentSessionStatus::InProgress, + session_context: CLIAgentSessionContext::default(), + input_state: CLIAgentInputState::Closed, + should_auto_toggle_input: false, + listener: Some(listener), + remote_host: None, + plugin_version: Some("1.0.0".to_owned()), + draft_text: None, + custom_command_prefix: None, + }, + ctx, + ); + }); + }); + + // An in-progress run is not awaiting a response. + terminal.read(&app, |view, ctx| { + assert!(!view.is_awaiting_user_response(ctx)); + }); + + // A permission request blocks the run on the user's response. + terminal.update(&mut app, |view, ctx| { + CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| { + sessions.update_from_event( + view.view_id, + &CLIAgentEvent { + v: 1, + agent: CLIAgent::Claude, + event: CLIAgentEventType::PermissionRequest, + session_id: None, + cwd: None, + project: None, + payload: CLIAgentEventPayload { + summary: Some("Approve?".to_owned()), + ..Default::default() + }, + }, + ctx, + ); + }); + }); + + terminal.read(&app, |view, ctx| { + assert!(view.is_awaiting_user_response(ctx)); + }); + + // Replying to the permission request unblocks the run. + terminal.update(&mut app, |view, ctx| { + CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| { + sessions.update_from_event( + view.view_id, + &CLIAgentEvent { + v: 1, + agent: CLIAgent::Claude, + event: CLIAgentEventType::PermissionReplied, + session_id: None, + cwd: None, + project: None, + payload: CLIAgentEventPayload::default(), + }, + ctx, + ); + }); + }); + + terminal.read(&app, |view, ctx| { + assert!(!view.is_awaiting_user_response(ctx)); + }); + }) +} + #[test] fn cli_session_status_updates_single_child_conversation_without_agent_view() { App::test((), |mut app| async move { diff --git a/app/src/themes/default_themes.rs b/app/src/themes/default_themes.rs index 31a45f126..15bddd251 100644 --- a/app/src/themes/default_themes.rs +++ b/app/src/themes/default_themes.rs @@ -1,11 +1,16 @@ -use asset_macro::bundled_or_fetched_asset; +//! Bundled themes shipped with CastCodes. +//! +//! The set is deliberately small: the brand default plus a neutral pair and the +//! three most-used community palettes. Decorative background-image, gradient, +//! and referral-reward themes were retired — they conflict with the Phase 1 +//! design contract (calm, dense, editor-grade, no decorative chrome), and the +//! referral themes could never unlock in an OSS build. Retired +//! [`super::theme::ThemeKind`] variants still deserialize and are remapped to +//! the nearest surviving theme, so saved settings keep working. use pathfinder_color::ColorU; use warp_core::ui::{ - color::{blend::Blend, coloru_with_opacity, OPAQUE}, - theme::{ - color::CustomDetails, AnsiColor, AnsiColors, Details, Fill, HorizontalGradient, Image, - TerminalColors, UiTokens, VerticalGradient, WarpTheme, - }, + color::OPAQUE, + theme::{AnsiColor, AnsiColors, Details, Fill, TerminalColors, UiTokens, WarpTheme}, }; const DARK_MODE_NORMAL_COLORS: AnsiColors = AnsiColors::new( @@ -92,27 +97,6 @@ const SOLARIZED_DARK_BRIGHT_COLORS: AnsiColors = AnsiColors::new( AnsiColor::from_u32(0xFDF6E3FF), ); -const SOLARIZED_LIGHT_NORMAL_COLORS: AnsiColors = AnsiColors::new( - AnsiColor::from_u32(0x073642FF), - AnsiColor::from_u32(0xDC322FFF), - AnsiColor::from_u32(0x859900FF), - AnsiColor::from_u32(0xB58900FF), - AnsiColor::from_u32(0x268BD2FF), - AnsiColor::from_u32(0xD33682FF), - AnsiColor::from_u32(0x2AA198FF), - AnsiColor::from_u32(0xEEE8D5FF), -); -const SOLARIZED_LIGHT_BRIGHT_COLORS: AnsiColors = AnsiColors::new( - AnsiColor::from_u32(0x002B36FF), - AnsiColor::from_u32(0xCB4B16FF), - AnsiColor::from_u32(0x586E75FF), - AnsiColor::from_u32(0x657B83FF), - AnsiColor::from_u32(0x839496FF), - AnsiColor::from_u32(0x6C71C4FF), - AnsiColor::from_u32(0x93A1A1FF), - AnsiColor::from_u32(0xFDF6E3FF), -); - const DRACULA_NORMAL_COLORS: AnsiColors = AnsiColors::new( AnsiColor::from_u32(0x000000FF), AnsiColor::from_u32(0xFF5555FF), @@ -134,27 +118,6 @@ const DRACULA_BRIGHT_COLORS: AnsiColors = AnsiColors::new( AnsiColor::from_u32(0xFFFFFFFF), ); -const PHENOMENON_NORMAL_COLORS: AnsiColors = AnsiColors::new( - AnsiColor::from_u32(0x121212FF), - AnsiColor::from_u32(0xD22D1EFF), - AnsiColor::from_u32(0x1CA05AFF), - AnsiColor::from_u32(0xE5A01AFF), - AnsiColor::from_u32(0x3780E9FF), - AnsiColor::from_u32(0xBF409DFF), - AnsiColor::from_u32(0x799C92FF), - AnsiColor::from_u32(0xFAF9F6FF), -); -const PHENOMENON_BRIGHT_COLORS: AnsiColors = AnsiColors::new( - AnsiColor::from_u32(0x292929FF), - AnsiColor::from_u32(0xAE756FFF), - AnsiColor::from_u32(0x789B88FF), - AnsiColor::from_u32(0xBD9F65FF), - AnsiColor::from_u32(0x6F839FFF), - AnsiColor::from_u32(0xA57899FF), - AnsiColor::from_u32(0xBFC5C3FF), - AnsiColor::from_u32(0xFFFFFFFF), -); - const GRUVBOX_DARK_NORMAL_COLORS: AnsiColors = AnsiColors::new( AnsiColor::from_u32(0x282828FF), AnsiColor::from_u32(0xCC241DFF), @@ -176,69 +139,6 @@ const GRUVBOX_DARK_BRIGHT_COLORS: AnsiColors = AnsiColors::new( AnsiColor::from_u32(0xEBDBB2FF), ); -const GRUVBOX_LIGHT_NORMAL_COLORS: AnsiColors = AnsiColors::new( - AnsiColor::from_u32(0xFBF1C7FF), - AnsiColor::from_u32(0xCC241DFF), - AnsiColor::from_u32(0x98971AFF), - AnsiColor::from_u32(0xD79921FF), - AnsiColor::from_u32(0x458588FF), - AnsiColor::from_u32(0xB16286FF), - AnsiColor::from_u32(0x689D6AFF), - AnsiColor::from_u32(0x7C6F64FF), -); -const GRUVBOX_LIGHT_BRIGHT_COLORS: AnsiColors = AnsiColors::new( - AnsiColor::from_u32(0x928374FF), - AnsiColor::from_u32(0x9D0006FF), - AnsiColor::from_u32(0x79740EFF), - AnsiColor::from_u32(0xB57614FF), - AnsiColor::from_u32(0x076678FF), - AnsiColor::from_u32(0x8F3F71FF), - AnsiColor::from_u32(0x427B58FF), - AnsiColor::from_u32(0x3C3836FF), -); - -const SOLARFLARE_NORMAL_COLORS: AnsiColors = AnsiColors::new( - AnsiColor::from_u32(0x2E333DFF), - AnsiColor::from_u32(0xD66060FF), - AnsiColor::from_u32(0x64AF86FF), - AnsiColor::from_u32(0xCAA358FF), - AnsiColor::from_u32(0x5C80B2FF), - AnsiColor::from_u32(0xB766A1FF), - AnsiColor::from_u32(0x8069A1FF), - AnsiColor::from_u32(0xF0F4F7FF), -); -const SOLARFLARE_BRIGHT_COLORS: AnsiColors = AnsiColors::new( - AnsiColor::from_u32(0x37404AFF), - AnsiColor::from_u32(0xEB8282FF), - AnsiColor::from_u32(0x64AF86FF), - AnsiColor::from_u32(0xCAA358FF), - AnsiColor::from_u32(0x5C80B2FF), - AnsiColor::from_u32(0xB766A1FF), - AnsiColor::from_u32(0x8069A1FF), - AnsiColor::from_u32(0xFFFFFFFF), -); - -const ADEBERRY_NORMAL_COLORS: AnsiColors = AnsiColors::new( - AnsiColor::from_u32(0x121212FF), - AnsiColor::from_u32(0xC76156FF), - AnsiColor::from_u32(0x57C78AFF), - AnsiColor::from_u32(0xC8A35AFF), - AnsiColor::from_u32(0x5785C7FF), - AnsiColor::from_u32(0xC756A9FF), - AnsiColor::from_u32(0x57C7C3FF), - AnsiColor::from_u32(0xEEEDEBFF), -); -const ADEBERRY_BRIGHT_COLORS: AnsiColors = AnsiColors::new( - AnsiColor::from_u32(0x292929FF), - AnsiColor::from_u32(0xD22D1EFF), - AnsiColor::from_u32(0x1CA05AFF), - AnsiColor::from_u32(0xE5A01AFF), - AnsiColor::from_u32(0x1458B8FF), - AnsiColor::from_u32(0xA43787FF), - AnsiColor::from_u32(0x4D9989FF), - AnsiColor::from_u32(0xFFFFFFFF), -); - pub(super) fn light_mode_colors() -> TerminalColors { TerminalColors::new(LIGHT_MODE_NORMAL_COLORS, LIGHT_MODE_BRIGHT_COLORS) } @@ -251,10 +151,6 @@ pub(super) fn castcodes_terminal_colors() -> TerminalColors { TerminalColors::new(CASTCODES_NORMAL_COLORS, CASTCODES_BRIGHT_COLORS) } -pub(super) fn solarized_light_colors() -> TerminalColors { - TerminalColors::new(SOLARIZED_LIGHT_NORMAL_COLORS, SOLARIZED_LIGHT_BRIGHT_COLORS) -} - pub(super) fn solarized_dark_colors() -> TerminalColors { TerminalColors::new(SOLARIZED_DARK_NORMAL_COLORS, SOLARIZED_DARK_BRIGHT_COLORS) } @@ -263,26 +159,10 @@ pub(super) fn dracula_colors() -> TerminalColors { TerminalColors::new(DRACULA_NORMAL_COLORS, DRACULA_BRIGHT_COLORS) } -pub(super) fn phenomenon_colors() -> TerminalColors { - TerminalColors::new(PHENOMENON_NORMAL_COLORS, PHENOMENON_BRIGHT_COLORS) -} - pub(super) fn gruvbox_dark_colors() -> TerminalColors { TerminalColors::new(GRUVBOX_DARK_NORMAL_COLORS, GRUVBOX_DARK_BRIGHT_COLORS) } -pub(super) fn gruvbox_light_colors() -> TerminalColors { - TerminalColors::new(GRUVBOX_LIGHT_NORMAL_COLORS, GRUVBOX_LIGHT_BRIGHT_COLORS) -} - -pub(super) fn solarflare_colors() -> TerminalColors { - TerminalColors::new(SOLARFLARE_NORMAL_COLORS, SOLARFLARE_BRIGHT_COLORS) -} - -pub(super) fn adeberry_colors() -> TerminalColors { - TerminalColors::new(ADEBERRY_NORMAL_COLORS, ADEBERRY_BRIGHT_COLORS) -} - /// CastCodes dark theme — OpenCoven brand palette. /// /// Brand slots mapped onto the available `WarpTheme` color slots: @@ -383,19 +263,6 @@ pub(super) fn dracula() -> WarpTheme { ) } -pub(super) fn solarized_light() -> WarpTheme { - WarpTheme::new( - Fill::Solid(ColorU::from_u32(0xFDF6E3FF)), - ColorU::from_u32(0x586E75FF), - Fill::Solid(ColorU::from_u32(0x66B5A9FF)), - None, - Some(Details::Lighter), - solarized_light_colors(), - None, - Some("Solarized Light".to_string()), - ) -} - pub(super) fn solarized_dark() -> WarpTheme { WarpTheme::new( Fill::Solid(ColorU::from_u32(0x002B36FF)), @@ -421,296 +288,3 @@ pub(super) fn gruvbox_dark() -> WarpTheme { Some("Gruvbox Dark".to_string()), ) } - -pub(super) fn gruvbox_light() -> WarpTheme { - WarpTheme::new( - Fill::Solid(ColorU::from_u32(0xFBF1C7FF)), - ColorU::from_u32(0x3C3836FF), - Fill::Solid(ColorU::from_u32(0xAD3B14FF)), - None, - Some(Details::Lighter), - gruvbox_light_colors(), - None, - Some("Gruvbox Light".to_string()), - ) -} - -/// Bundled gradient themes -pub(super) fn cyber_wave() -> WarpTheme { - WarpTheme::new( - Fill::VerticalGradient(VerticalGradient::new( - ColorU::black().blend(&coloru_with_opacity(ColorU::from_u32(0x00C2FFFF), 20)), - ColorU::black(), - )), - ColorU::white(), - Fill::HorizontalGradient(HorizontalGradient::new( - ColorU::from_u32(0x007972FF), - ColorU::from_u32(0x7B008FFF), - )), - None, - Some(Details::Darker), - dark_mode_colors(), - None, - Some("Cyber Wave".to_string()), - ) -} - -pub(super) fn willow_dream() -> WarpTheme { - WarpTheme::new( - Fill::VerticalGradient(VerticalGradient::new( - ColorU::from_u32(0x206169FF), - ColorU::from_u32(0x022F27FF), - )), - ColorU::white(), - Fill::HorizontalGradient(HorizontalGradient::new( - ColorU::from_u32(0xF9AEA8FF), - ColorU::from_u32(0xDD6258FF), - )), - None, - Some(Details::Darker), - dark_mode_colors(), - None, - Some("Willow Dream".to_string()), - ) -} - -pub(super) fn fancy_dracula() -> WarpTheme { - WarpTheme::new( - Fill::VerticalGradient(VerticalGradient::new( - ColorU::from_u32(0x252630FF), - ColorU::from_u32(0x3D3F4FFF), - )), - ColorU::white(), - Fill::HorizontalGradient(HorizontalGradient::new( - ColorU::from_u32(0xBCA1F6FF), - ColorU::from_u32(0xA3E7FCFF), - )), - None, - Some(Details::Darker), - dracula_colors(), - None, - Some("Fancy Dracula".to_string()), - ) -} - -pub(super) fn phenomenon() -> WarpTheme { - WarpTheme::new( - Fill::Solid(ColorU::from_u32(0x121212FF)), - ColorU::from_u32(0xFAF9F6FF), - Fill::Solid(ColorU::from_u32(0x2E5D9EFF)), - None, - Some(Details::Darker), - phenomenon_colors(), - Some(Image { - source: bundled_or_fetched_asset!("jpg/phenomenon_bg.jpg"), - opacity: 100, - }), - Some("Phenomenon".to_string()), - ) -} - -/// Bundled themes with background images -pub(super) fn jellyfish() -> WarpTheme { - WarpTheme::new( - Fill::Solid(ColorU::from_u32(0x1B1718FF)), - ColorU::white(), - Fill::Solid(ColorU::from_u32(0x538682FF)), - None, - Some(Details::Darker), - dark_mode_colors(), - Some(Image { - source: bundled_or_fetched_asset!("jpg/jellyfish_bg.jpg"), - opacity: 30, - }), - Some("Jellyfish".to_string()), - ) -} - -pub(super) fn koi() -> WarpTheme { - WarpTheme::new( - Fill::Solid(ColorU::from_u32(0x211719FF)), - ColorU::white(), - Fill::Solid(ColorU::from_u32(0xFF3131FF)), - None, - Some(Details::Darker), - dark_mode_colors(), - Some(Image { - source: bundled_or_fetched_asset!("jpg/koi_bg.jpg"), - opacity: 30, - }), - Some("Koi".to_string()), - ) -} - -pub(super) fn leafy() -> WarpTheme { - WarpTheme::new( - Fill::Solid(ColorU::black()), - ColorU::white(), - Fill::Solid(ColorU::from_u32(0x55972DFF)), - None, - Some(Details::Darker), - dark_mode_colors(), - Some(Image { - source: bundled_or_fetched_asset!("jpg/leafy_bg.jpg"), - opacity: 30, - }), - Some("Leafy".to_string()), - ) -} - -pub(super) fn marble() -> WarpTheme { - WarpTheme::new( - Fill::Solid(ColorU::from_u32(0xE3E3E3FF)), - ColorU::black(), - Fill::Solid(ColorU::from_u32(0x585858FF)), - None, - Some(Details::Lighter), - light_mode_colors(), - Some(Image { - source: bundled_or_fetched_asset!("jpg/marble_bg.jpg"), - opacity: 50, - }), - Some("Marble".to_string()), - ) -} - -pub(super) fn pink_city() -> WarpTheme { - let details = CustomDetails { - ..CustomDetails::lighter_details() - }; - WarpTheme::new( - Fill::Solid(ColorU::from_u32(0xFBEFF6FF)), - ColorU::black(), - Fill::Solid(ColorU::from_u32(0xE10087FF)), - None, - Some(Details::Custom(details)), - light_mode_colors(), - Some(Image { - source: bundled_or_fetched_asset!("jpg/pink_city_bg.jpg"), - opacity: 40, - }), - Some("Pink City".to_string()), - ) -} - -pub(super) fn snowy() -> WarpTheme { - WarpTheme::new( - Fill::VerticalGradient(VerticalGradient::new( - ColorU::from_u32(0xFFFFFFFF), - ColorU::from_u32(0xDEE6EBFF), - )), - ColorU::black(), - Fill::Solid(ColorU::from_u32(0x647E90FF)), - None, - Some(Details::Lighter), - light_mode_colors(), - Some(Image { - source: bundled_or_fetched_asset!("jpg/snowy_bg.jpg"), - opacity: 20, - }), - Some("Snowy".to_string()), - ) -} - -pub(super) fn red_rock() -> WarpTheme { - WarpTheme::new( - Fill::VerticalGradient(VerticalGradient::new( - ColorU::from_u32(0x211719FF) - .blend(&coloru_with_opacity(ColorU::from_u32(0x4C3435FF), 45)), - ColorU::from_u32(0x211719FF) - .blend(&coloru_with_opacity(ColorU::from_u32(0xD3032FF), 45)), - )), - ColorU::white(), - Fill::Solid(ColorU::from_u32(0x9F4147FF)), - None, - Some(Details::Darker), - dark_mode_colors(), - Some(Image { - source: bundled_or_fetched_asset!("jpg/red_rock_bg.jpg"), - opacity: 30, - }), - Some("Red Rock".to_string()), - ) -} - -pub(super) fn dark_city() -> WarpTheme { - WarpTheme::new( - Fill::VerticalGradient(VerticalGradient::new( - ColorU::from_u32(0x01181FFF) - .blend(&coloru_with_opacity(ColorU::from_u32(0x1A363FFF), 45)), - ColorU::from_u32(0x01181FFF) - .blend(&coloru_with_opacity(ColorU::from_u32(0x1A4551FF), 45)), - )), - ColorU::white(), - Fill::Solid(ColorU::from_u32(0xE9072DFF)), - None, - Some(Details::Darker), - dark_mode_colors(), - Some(Image { - source: bundled_or_fetched_asset!("jpg/dark_city_bg.jpg"), - opacity: 20, - }), - Some("Dark City".to_string()), - ) -} - -pub(super) fn sent_referral_reward() -> WarpTheme { - WarpTheme::new( - Fill::Solid(ColorU::from_u32(0x334567FF)), - ColorU::white(), - Fill::Solid(ColorU::from_u32(0xCD51FFFF)), - None, - Some(Details::Darker), - dark_mode_colors(), - Some(Image { - source: bundled_or_fetched_asset!("jpg/sent_referral_reward_bg.jpg"), - opacity: 100, - }), - Some("CastCodes Referral".to_string()), - ) -} - -pub(super) fn solar_flare() -> WarpTheme { - WarpTheme::new( - Fill::Solid(ColorU::from_u32(0x1B1C18FF)), - ColorU::from_u32(0xDDE6EEFF), - Fill::Solid(ColorU::from_u32(0x34895CFF)), - None, - Some(Details::Darker), - solarflare_colors(), - Some(Image { - source: bundled_or_fetched_asset!("jpg/solarflare_bg.jpg"), - opacity: 20, - }), - Some("Solar Flare".to_string()), - ) -} - -pub(super) fn adeberry() -> WarpTheme { - WarpTheme::new( - Fill::Solid(ColorU::from_u32(0x1D2022FF)), - ColorU::from_u32(0xE4EEF5FF), - Fill::Solid(ColorU::from_u32(0x6C96B4FF)), - None, - Some(Details::Darker), - adeberry_colors(), - None, - Some("Adeberry".to_string()), - ) -} - -pub(super) fn received_referral_reward() -> WarpTheme { - WarpTheme::new( - Fill::Solid(ColorU::from_u32(0xFFFFFFFF)), - ColorU::black(), - Fill::Solid(ColorU::from_u32(0xCD51FFFF)), - None, - Some(Details::Lighter), - light_mode_colors(), - Some(Image { - source: bundled_or_fetched_asset!("jpg/received_referral_reward_bg.jpg"), - opacity: 100, - }), - Some("Received Referral Reward".to_string()), - ) -} diff --git a/app/src/themes/mod.rs b/app/src/themes/mod.rs index 24edfd066..839fbce14 100644 --- a/app/src/themes/mod.rs +++ b/app/src/themes/mod.rs @@ -10,11 +10,12 @@ pub mod tweakcn_import; use warp_core::ui::theme::WarpTheme; +/// Themes offered in the onboarding picker, drawn from the bundled set. pub fn onboarding_theme_picker_themes() -> [WarpTheme; 4] { [ default_themes::castcodes_dark(), - default_themes::phenomenon(), + default_themes::dark_theme(), default_themes::light_theme(), - default_themes::adeberry(), + default_themes::dracula(), ] } diff --git a/app/src/themes/theme.rs b/app/src/themes/theme.rs index 58d409e7e..767d974e9 100644 --- a/app/src/themes/theme.rs +++ b/app/src/themes/theme.rs @@ -24,6 +24,11 @@ const THUMBNAIL_MARGIN: f32 = 10.; // We use the discriminant of enum variants to determine the order of theme types in the // theme chooser view. +// +// Variants marked `#[schemars(skip)]` are retired: they are no longer bundled +// and are hidden from the settings schema, but stay deserializable so existing +// settings files keep loading. `ThemeKind::retired_replacement` maps them to a +// surviving theme. See `super::default_themes`. #[derive( Clone, Debug, @@ -47,9 +52,9 @@ pub enum ThemeKind { SentReferralReward, #[schemars(skip)] ReceivedReferralReward, - #[schemars(description = "Adeberry")] + #[schemars(skip)] Adeberry, - #[schemars(description = "Phenomenon")] + #[schemars(skip)] Phenomenon, #[default] #[schemars(description = "CastCodes Dark")] @@ -58,39 +63,39 @@ pub enum ThemeKind { Dark, #[schemars(description = "Dracula")] Dracula, - #[schemars(description = "Fancy Dracula")] + #[schemars(skip)] FancyDracula, - #[schemars(description = "Cyber Wave")] + #[schemars(skip)] CyberWave, - #[schemars(description = "Solar Flare")] + #[schemars(skip)] SolarFlare, #[schemars(description = "Solarized Dark")] SolarizedDark, - #[schemars(description = "Willow Dream")] + #[schemars(skip)] WillowDream, #[schemars(description = "Light")] Light, - #[schemars(description = "Dark City")] + #[schemars(skip)] DarkCity, #[schemars(description = "Gruvbox Dark")] GruvboxDark, - #[schemars(description = "Red Rock")] + #[schemars(skip)] RedRock, - #[schemars(description = "Jellyfish")] + #[schemars(skip)] JellyFish, - #[schemars(description = "Leafy")] + #[schemars(skip)] Leafy, - #[schemars(description = "Koi")] + #[schemars(skip)] Koi, - #[schemars(description = "Solarized Light")] + #[schemars(skip)] SolarizedLight, - #[schemars(description = "Snowy")] + #[schemars(skip)] Snowy, - #[schemars(description = "Gruvbox Light")] + #[schemars(skip)] GruvboxLight, - #[schemars(description = "Pink City")] + #[schemars(skip)] PinkCity, - #[schemars(description = "Marble")] + #[schemars(skip)] Marble, #[schemars(description = "A user-provided custom theme loaded from a file.")] Custom(CustomTheme), @@ -151,6 +156,49 @@ impl ThemeKind { let theme_name = format!("{self}").to_lowercase(); theme_name.contains(&query.to_lowercase()) } + + /// Whether this kind is still bundled with the app. + pub fn is_bundled(&self) -> bool { + matches!( + self, + ThemeKind::CastCodesDark + | ThemeKind::Dark + | ThemeKind::Light + | ThemeKind::Dracula + | ThemeKind::GruvboxDark + | ThemeKind::SolarizedDark + ) + } + + /// The bundled theme that stands in for a retired one. + /// + /// Retired variants stay deserializable so existing settings files keep + /// loading; they resolve to the nearest surviving theme of the same + /// lightness, so a user on a light theme never snaps to a dark one. + /// Non-retired and user-supplied kinds map to themselves. + pub fn retired_replacement(&self) -> ThemeKind { + match self { + ThemeKind::SolarizedLight + | ThemeKind::GruvboxLight + | ThemeKind::Marble + | ThemeKind::PinkCity + | ThemeKind::Snowy + | ThemeKind::ReceivedReferralReward => ThemeKind::Light, + ThemeKind::FancyDracula => ThemeKind::Dracula, + ThemeKind::CyberWave + | ThemeKind::WillowDream + | ThemeKind::Phenomenon + | ThemeKind::SolarFlare + | ThemeKind::Adeberry + | ThemeKind::JellyFish + | ThemeKind::Koi + | ThemeKind::Leafy + | ThemeKind::DarkCity + | ThemeKind::RedRock + | ThemeKind::SentReferralReward => ThemeKind::CastCodesDark, + other => other.clone(), + } + } } #[derive( @@ -296,35 +344,15 @@ pub struct WarpThemeConfig { impl WarpThemeConfig { pub fn new() -> Self { - // preload with built-in themes + // Preload with the bundled themes. See `default_themes` for why the set + // is limited to six. let theme_map: HashMap = HashMap::from_iter([ - (ThemeKind::SentReferralReward, sent_referral_reward()), - ( - ThemeKind::ReceivedReferralReward, - received_referral_reward(), - ), (ThemeKind::CastCodesDark, castcodes_dark()), (ThemeKind::Dark, dark_theme()), (ThemeKind::Light, light_theme()), - (ThemeKind::SolarizedDark, solarized_dark()), - (ThemeKind::SolarizedLight, solarized_light()), (ThemeKind::Dracula, dracula()), (ThemeKind::GruvboxDark, gruvbox_dark()), - (ThemeKind::GruvboxLight, gruvbox_light()), - (ThemeKind::JellyFish, jellyfish()), - (ThemeKind::Koi, koi()), - (ThemeKind::Leafy, leafy()), - (ThemeKind::Marble, marble()), - (ThemeKind::PinkCity, pink_city()), - (ThemeKind::Snowy, snowy()), - (ThemeKind::DarkCity, dark_city()), - (ThemeKind::RedRock, red_rock()), - (ThemeKind::CyberWave, cyber_wave()), - (ThemeKind::WillowDream, willow_dream()), - (ThemeKind::FancyDracula, fancy_dracula()), - (ThemeKind::Phenomenon, phenomenon()), - (ThemeKind::SolarFlare, solar_flare()), - (ThemeKind::Adeberry, adeberry()), + (ThemeKind::SolarizedDark, solarized_dark()), ]); WarpThemeConfig { theme_map } } @@ -344,6 +372,7 @@ impl WarpThemeConfig { pub fn theme(&self, name: &ThemeKind) -> WarpTheme { self.theme_map .get(name) + .or_else(|| self.theme_map.get(&name.retired_replacement())) .cloned() .unwrap_or_else(castcodes_dark) } diff --git a/app/src/themes/theme_chooser.rs b/app/src/themes/theme_chooser.rs index 77014e46a..aa9a7fe6c 100644 --- a/app/src/themes/theme_chooser.rs +++ b/app/src/themes/theme_chooser.rs @@ -32,7 +32,6 @@ use crate::{ editor::{ Event as EditorEvent, PropagateAndNoOpNavigationKeys, SingleLineEditorOptions, TextOptions, }, - referral_theme_status::ReferralThemeStatus, report_if_error, settings::{respect_system_theme, ThemeSettings}, themes::theme::SelectedSystemThemes, @@ -146,7 +145,6 @@ pub struct ThemeChooser { filtered_themes: Tracked>>, mode: ThemeChooserMode, search_editor: ViewHandle, - referral_theme_status: ModelHandle, tips_completed: ModelHandle, window_id: warpui::WindowId, } @@ -173,22 +171,9 @@ pub fn init(app: &mut AppContext) { ]); } -fn theme_chooser_items( - referral_theme_status: &ReferralThemeStatus, - theme_config: &WarpThemeConfig, -) -> Vec { - let sent_referral_theme_active = referral_theme_status.sent_referral_theme_active(); - let received_referral_theme_active = referral_theme_status.received_referral_theme_active(); - +fn theme_chooser_items(theme_config: &WarpThemeConfig) -> Vec { let mut theme_items: Vec = theme_config .theme_items() - .filter(|(key, _)| match key { - // Only show the referral reward themes if they are active - ThemeKind::SentReferralReward => sent_referral_theme_active, - ThemeKind::ReceivedReferralReward => received_referral_theme_active, - // All other themes should show up always - _ => true, - }) .map(|(key, theme)| ThemeChooserItem::new(key.clone(), theme.clone())) .collect(); theme_items.sort_by(|a, b| a.kind.cmp(&b.kind)); @@ -196,11 +181,7 @@ fn theme_chooser_items( } impl ThemeChooser { - pub fn new( - referral_theme_status: ModelHandle, - ctx: &mut ViewContext, - tips_completed: ModelHandle, - ) -> Self { + pub fn new(ctx: &mut ViewContext, tips_completed: ModelHandle) -> Self { let search_editor = { ctx.add_typed_action_view(|ctx| { let appearance = Appearance::as_ref(ctx); @@ -218,10 +199,6 @@ impl ThemeChooser { me.handle_editor_event(event, ctx); }); - ctx.subscribe_to_model(&referral_theme_status, |me, _, _, ctx| { - me.update_themes(ctx); - }); - let warp_config_handle = WarpConfig::handle(ctx); ctx.subscribe_to_model(&warp_config_handle, |me, _, event, ctx| { if let WarpConfigUpdateEvent::Themes = event { @@ -243,10 +220,7 @@ impl ThemeChooser { } }); - let themes = theme_chooser_items( - referral_theme_status.as_ref(ctx), - WarpConfig::as_ref(ctx).theme_config(), - ); + let themes = theme_chooser_items(WarpConfig::as_ref(ctx).theme_config()); Self { themes: Tracked::new(themes), @@ -258,7 +232,6 @@ impl ThemeChooser { filtered_themes: Tracked::new(None), mode: ThemeChooserMode::for_active_theme(ctx), search_editor, - referral_theme_status, tips_completed, window_id: ctx.window_id(), } @@ -505,10 +478,7 @@ impl ThemeChooser { } fn update_themes(&mut self, ctx: &mut ViewContext) { - *self.themes = theme_chooser_items( - self.referral_theme_status.as_ref(ctx), - WarpConfig::as_ref(ctx).theme_config(), - ); + *self.themes = theme_chooser_items(WarpConfig::as_ref(ctx).theme_config()); } fn up(&mut self, ctx: &mut ViewContext) { diff --git a/app/src/themes/theme_tests.rs b/app/src/themes/theme_tests.rs index c9a6cef65..c2a89e06e 100644 --- a/app/src/themes/theme_tests.rs +++ b/app/src/themes/theme_tests.rs @@ -113,31 +113,13 @@ fn builtin_themes_render_identically_without_ui_block() { dark_theme(), light_theme(), dracula(), - solarized_light(), solarized_dark(), gruvbox_dark(), - gruvbox_light(), - cyber_wave(), - willow_dream(), - fancy_dracula(), - phenomenon(), - jellyfish(), - koi(), - leafy(), - marble(), - pink_city(), - snowy(), - red_rock(), - dark_city(), - sent_referral_reward(), - solar_flare(), - adeberry(), - received_referral_reward(), ]; assert_eq!( builtins.len(), - 23, + 5, "update this test when adding/removing built-in themes" ); @@ -296,3 +278,81 @@ fn new_brand_accessors_fall_back_without_ui_block() { Fill::from(color::internal_colors::text_sub(&theme, bg)), ); } + +/// The bundled set is intentionally capped at six themes; see +/// `super::default_themes`. If this fires, either a theme was added without a +/// deliberate decision, or one was dropped without updating the docs. +#[test] +fn bundles_exactly_the_six_curated_themes() { + let config = WarpThemeConfig::new(); + let mut names: Vec = config + .theme_items() + .map(|(kind, _)| kind.to_string()) + .collect(); + names.sort(); + + assert_eq!( + names, + vec![ + "CastCodes Dark", + "Dark", + "Dracula", + "Gruvbox Dark", + "Light", + "Solarized Dark", + ] + ); + assert!(config.theme_items().all(|(kind, _)| kind.is_bundled())); +} + +/// Retired themes must stay loadable: a settings file naming one resolves to a +/// surviving theme of the same lightness rather than failing or flipping a +/// light-theme user onto a dark background. +#[test] +fn retired_themes_resolve_to_a_same_lightness_replacement() { + let config = WarpThemeConfig::new(); + + for retired in [ + ThemeKind::SolarizedLight, + ThemeKind::GruvboxLight, + ThemeKind::Marble, + ThemeKind::PinkCity, + ThemeKind::Snowy, + ThemeKind::ReceivedReferralReward, + ] { + assert_eq!(retired.retired_replacement(), ThemeKind::Light); + assert_eq!(config.theme(&retired), light_theme(), "{retired}"); + } + + assert_eq!(config.theme(&ThemeKind::FancyDracula), dracula()); + + for retired in [ + ThemeKind::CyberWave, + ThemeKind::WillowDream, + ThemeKind::Phenomenon, + ThemeKind::SolarFlare, + ThemeKind::Adeberry, + ThemeKind::JellyFish, + ThemeKind::Koi, + ThemeKind::Leafy, + ThemeKind::DarkCity, + ThemeKind::RedRock, + ThemeKind::SentReferralReward, + ] { + assert_eq!(config.theme(&retired), castcodes_dark(), "{retired}"); + } + + // Bundled kinds are unaffected by the remapping. + for bundled in [ThemeKind::CastCodesDark, ThemeKind::Dark, ThemeKind::Light] { + assert_eq!(bundled.retired_replacement(), bundled); + } +} + +/// Retired variants must keep deserializing so existing settings files load. +#[test] +fn retired_theme_kinds_still_deserialize() { + let kind: ThemeKind = serde_json::from_str("\"Jellyfish\"") + .or_else(|_| serde_json::from_str::("\"JellyFish\"")) + .expect("retired theme kind should still deserialize"); + assert_eq!(kind, ThemeKind::JellyFish); +} diff --git a/app/src/ui_components/design.rs b/app/src/ui_components/design.rs new file mode 100644 index 000000000..dbb25871f --- /dev/null +++ b/app/src/ui_components/design.rs @@ -0,0 +1,73 @@ +//! Code expression of the CastCodes Phase 1 design contract. +//! +//! The contract in `AGENTS.md` defines colors, radii, spacing and motion, but until now only the +//! color half had a code representation (`warp_core::ui::theme` tokens and accessors). Sizing was +//! written as bare literals at every call site, so there was nothing for new code to reference and +//! nothing to review against. +//! +//! These constants close that gap. Colors are deliberately *not* duplicated here — they belong to +//! the theme accessors so they can vary per theme, whereas the scales below are theme independent. +//! +//! Inherited Warp views still use bare literals. They are migrated opportunistically when a view is +//! touched for other reasons rather than in a single sweeping rewrite, which would churn hundreds of +//! unverifiable call sites across the fork. The scale is therefore declared in full even where the +//! fork has not adopted a given step yet: an incomplete vocabulary just pushes the next author back +//! to writing a bare literal. +#![allow(dead_code)] + +/// Corner radii. The contract allows three steps; anything larger reads as marketing chrome. +/// +/// `Radius::Percentage(50.)` is still the right choice for pills, dots and avatars — those are +/// fully rounded by intent rather than by a fixed pixel step. +pub mod radius { + /// Compact items: chips, badges, list rows, small icon buttons. + pub const COMPACT: f32 = 4.; + /// Controls: buttons, inputs, dropdowns, tabs. + pub const CONTROL: f32 = 6.; + /// Larger surfaces: modals, panels, cards. The contract's maximum. + pub const SURFACE: f32 = 8.; +} + +/// Spacing steps for padding, margins and gaps, in logical pixels. +/// +/// The scale is a 4px grid with a 2px hairline step for dense affordances. +pub mod space { + /// Hairline gap between tightly coupled elements, e.g. an icon and its label. + pub const HAIRLINE: f32 = 2.; + pub const XS: f32 = 4.; + pub const SM: f32 = 8.; + pub const MD: f32 = 12.; + pub const LG: f32 = 16.; + pub const XL: f32 = 24.; +} + +/// Type scale in logical pixels. +/// +/// Prefer `Appearance::ui_font_size()` for primary UI text so the user's font-size preference is +/// respected. Use these only for text that must hold a fixed relationship to a fixed-size element +/// (badge counts, avatar initials, status chips). +pub mod font_size { + /// Badge counts, avatar initials and status chips. + pub const MICRO: f32 = 10.; + /// Secondary and supporting text. + pub const SMALL: f32 = 12.; + /// Default body text. + pub const BODY: f32 = 14.; + /// Section headings. + pub const LARGE: f32 = 16.; + /// Modal and page titles. The contract's maximum for product UI; larger sizes read as + /// marketing copy rather than an editor-grade workspace. + pub const TITLE: f32 = 20.; +} + +/// Animation durations in milliseconds. The contract calls for 100-150ms ease-in-out and no +/// bouncy easing. +/// +/// `warpui` has no shared transition primitive, so these cannot be wired into a generic animation +/// helper yet; they are the reference for hand-rolled animations until one exists. +pub mod motion { + /// Hover and press feedback. + pub const FAST_MS: u64 = 100; + /// Surface transitions: panels opening, rows expanding. + pub const BASE_MS: u64 = 150; +} diff --git a/app/src/ui_components/mod.rs b/app/src/ui_components/mod.rs index 7832bc6c2..43d3a5a90 100644 --- a/app/src/ui_components/mod.rs +++ b/app/src/ui_components/mod.rs @@ -8,6 +8,7 @@ pub(crate) mod blended_colors; pub(crate) mod breadcrumb; pub mod buttons; pub(crate) mod color_dot; +pub mod design; pub(crate) mod dialog; pub(crate) mod icon_with_status; pub(crate) mod item_highlight; @@ -18,4 +19,4 @@ pub(crate) mod window_focus_dimming; pub use warp_core::ui::icons; -const BORDER_RADIUS: f32 = 4.; +const BORDER_RADIUS: f32 = design::radius::COMPACT; diff --git a/app/src/workspace/view.rs b/app/src/workspace/view.rs index 24d754969..d0a187d6b 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -1513,17 +1513,11 @@ impl Workspace { } fn build_settings_views( - global_resource_handles: GlobalResourceHandles, tips_completed: ModelHandle, ctx: &mut ViewContext, ) -> (ViewHandle, ViewHandle) { - let theme_chooser_view = ctx.add_typed_action_view(|ctx| { - ThemeChooser::new( - global_resource_handles.referral_theme_status, - ctx, - tips_completed, - ) - }); + let theme_chooser_view = + ctx.add_typed_action_view(|ctx| ThemeChooser::new(ctx, tips_completed)); ctx.subscribe_to_view(&theme_chooser_view, |me, _, event, ctx| { me.handle_theme_chooser_event(event, ctx); @@ -2629,7 +2623,7 @@ impl Workspace { let (welcome_tips_view, welcome_tips_view_state) = Self::build_welcome_tips(tips_completed.clone(), ctx); let (settings_pane, theme_chooser_view) = - Self::build_settings_views(global_resource_handles, tips_completed.clone(), ctx); + Self::build_settings_views(tips_completed.clone(), ctx); let resource_center_view = Self::build_resource_center_view(ctx, tips_completed.clone(), changelog_model.clone()); @@ -9262,8 +9256,8 @@ impl Workspace { ThemeDeletionModalEvent::DeleteCurrentTheme => { self.theme_chooser_view .update(ctx, |theme_chooser_view, ctx| { - // Reset theme to Dark if we are deleting the current theme - theme_chooser_view.select_and_save_theme(&ThemeKind::Dark, ctx); + // Reset to the default theme if we are deleting the current theme + theme_chooser_view.select_and_save_theme(&ThemeKind::default(), ctx); }); } } @@ -17631,8 +17625,8 @@ impl Workspace { font_family_id: Some(appearance.ui_font_family()), font_weight: Some(Weight::Bold), background: Some(appearance.theme().accent().into()), - font_size: Some(12.), - font_color: Some(ColorU::black()), + font_size: Some(crate::ui_components::design::font_size::SMALL), + font_color: Some(appearance.theme().active_highlighted_text_color().into()), ..Default::default() }, ); diff --git a/app/src/workspace/view/vertical_tabs.rs b/app/src/workspace/view/vertical_tabs.rs index 02e23fea2..93320cb32 100644 --- a/app/src/workspace/view/vertical_tabs.rs +++ b/app/src/workspace/view/vertical_tabs.rs @@ -2,7 +2,7 @@ pub mod telemetry; use crate::ai::agent::conversation::ConversationStatus; use crate::ai::agent_management::AgentNotificationsModel; -use crate::ai::conversation_status_ui::render_status_element; +use crate::ai::conversation_status_ui::{render_status_element, render_status_element_with_label}; use crate::code::editor::{add_color, remove_color}; use crate::code::icon_from_file_path; use crate::safe_triangle::SafeTriangle; @@ -56,7 +56,6 @@ use settings::Setting as _; use std::path::{Path, PathBuf}; use warp_core::context_flag::ContextFlag; use warp_core::ui::color::blend::Blend; -use warp_core::ui::color::coloru_with_opacity; use warp_core::ui::theme::color::internal_colors; use warp_core::ui::theme::{AnsiColorIdentifier, Fill as WarpThemeFill, WarpTheme}; use warp_core::ui::Icon as WarpIcon; @@ -5481,30 +5480,7 @@ fn render_detail_status_pill( status: &ConversationStatus, appearance: &Appearance, ) -> Box { - let theme = appearance.theme(); - let (icon, color) = status.status_icon_and_color(theme); - Container::new( - Flex::row() - .with_main_axis_size(MainAxisSize::Min) - .with_cross_axis_alignment(CrossAxisAlignment::Center) - .with_spacing(4.) - .with_child( - ConstrainedBox::new(icon.to_warpui_icon(WarpThemeFill::Solid(color)).finish()) - .with_width(12.) - .with_height(12.) - .finish(), - ) - .with_child( - Text::new_inline(status.to_string(), appearance.ui_font_family(), 10.) - .with_color(WarpThemeFill::Solid(color).into()) - .finish(), - ) - .finish(), - ) - .with_padding(Padding::uniform(2.).with_left(4.).with_right(4.)) - .with_background(ThemeFill::Solid(coloru_with_opacity(color, 10))) - .with_corner_radius(CornerRadius::with_all(Radius::Pixels(2.))) - .finish() + render_status_element_with_label(status, status.to_string(), appearance) } fn render_detail_wrapping_text( diff --git a/crates/warp_core/src/ui/theme/color.rs b/crates/warp_core/src/ui/theme/color.rs index e84229698..ad89aca6d 100644 --- a/crates/warp_core/src/ui/theme/color.rs +++ b/crates/warp_core/src/ui/theme/color.rs @@ -313,8 +313,15 @@ impl WarpTheme { self.disabled_text_color(self.surface_2()) } + /// Foreground color for content drawn on top of the accent color (accent buttons, avatars, + /// selected rows). Prefers the theme's explicit `primary_foreground` token and otherwise + /// derives a readable color from the accent background. pub fn active_highlighted_text_color(&self) -> Fill { - self.main_text_color(self.accent()) + self.ui + .as_ref() + .and_then(|u| u.primary_foreground) + .map(Fill::Solid) + .unwrap_or_else(|| self.main_text_color(self.accent())) } pub fn settings_import_config_hover_opacity(&self) -> Opacity { diff --git a/crates/warp_core/src/ui/theme/theme_tests.rs b/crates/warp_core/src/ui/theme/theme_tests.rs index 2bb2b3140..d14136d4a 100644 --- a/crates/warp_core/src/ui/theme/theme_tests.rs +++ b/crates/warp_core/src/ui/theme/theme_tests.rs @@ -73,6 +73,28 @@ fn ring_uses_ui_override_when_set() { assert_eq!(theme.ring(), Fill::Solid(ColorU::from_u32(0x4ade80ff))); } +#[test] +fn active_highlighted_text_color_falls_back_to_text_on_accent() { + let theme = test_theme_without_ui(); + assert_eq!( + theme.active_highlighted_text_color(), + theme.main_text_color(theme.accent()) + ); +} + +#[test] +fn active_highlighted_text_color_uses_primary_foreground_when_set() { + let mut theme = test_theme_without_ui(); + theme.ui = Some(UiTokens { + primary_foreground: Some(ColorU::from_u32(0xffffffff)), + ..Default::default() + }); + assert_eq!( + theme.active_highlighted_text_color(), + Fill::Solid(ColorU::from_u32(0xffffffff)) + ); +} + #[test] fn ui_sidebar_override_returns_none_without_ui() { let theme = test_theme_without_ui();