From cb9d358807126d036a2382b53de33cc7c0865dc7 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sun, 26 Jul 2026 05:58:55 -0500 Subject: [PATCH 1/4] feat(ui): flag agents awaiting a response, curate themes, fix theme-blind colors Three passes over the app's UI surfaces. Agent needs-response indicators A tab gave no signal when an agent in one of its panes was blocked waiting on the user, so a background tab could sit unanswered indefinitely. Tabs now show a needs-response indicator when any pane is blocked, and pane headers carry a matching chip. The state has two independent sources that both have to be checked: a plugin-backed CLI agent session and the Oz conversation. The conversation path alone is not sufficient, because selected_conversation_status_for_display returns None for a blocked-but-empty conversation. Bundled themes reduced to six The catalog had grown to 24, including ten background-image themes, three gradients, and two referral rewards that are unreachable in an OSS build. Kept CastCodes Dark, Dark, Light, Dracula, Solarized Dark and Gruvbox Dark. Retired ThemeKind variants stay in the enum so existing settings still deserialize and the chooser's sort order is preserved; they are hidden from the schema and resolve to a replacement of the same lightness. Theme-blind color literals Several render sites hard-coded a color that only works against one background lightness. The settings working-directory input drew black text on surface_2(), invisible in every dark theme; three tab bar icons fell back to white, invisible in Light; the account avatar used black initials on the accent fill. These now use the semantic accessors. active_highlighted_text_color() reads the primary_foreground token, which was defined in castcodes_ui_tokens but never consumed. Two dead translucent-white statics in the command palette were removed rather than re-themed. Adds app/src/ui_components/design.rs with the radius, spacing, type and motion steps from the Phase 1 contract. Colors were already tokenized; sizing had no code representation, so every call site wrote a bare literal. Inherited Warp views are migrated as they are touched rather than in one sweep, which would churn hundreds of visually unverifiable call sites. --- DESIGN-CHANGES.md | 116 +++++ app/src/ai/conversation_status_ui.rs | 57 ++- .../ai/document/orchestration_config_block.rs | 2 +- app/src/auth/login_slide.rs | 3 - app/src/env_vars/env_var_collection_block.rs | 7 +- app/src/pane_group/mod.rs | 10 + app/src/search/command_search/view.rs | 7 - app/src/settings/initializer.rs | 12 +- .../features/working_directory.rs | 7 +- app/src/settings_view/show_blocks_view.rs | 11 +- app/src/tab.rs | 50 +- app/src/terminal/view/pane_impl.rs | 38 ++ app/src/terminal/view_tests.rs | 98 ++++ app/src/themes/default_themes.rs | 448 +----------------- app/src/themes/mod.rs | 5 +- app/src/themes/theme.rs | 107 +++-- app/src/themes/theme_chooser.rs | 12 +- app/src/themes/theme_tests.rs | 98 +++- app/src/ui_components/design.rs | 73 +++ app/src/ui_components/mod.rs | 3 +- app/src/workspace/view.rs | 8 +- app/src/workspace/view/vertical_tabs.rs | 28 +- crates/warp_core/src/ui/theme/color.rs | 9 +- crates/warp_core/src/ui/theme/theme_tests.rs | 22 + 24 files changed, 654 insertions(+), 577 deletions(-) create mode 100644 app/src/ui_components/design.rs 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/src/ai/conversation_status_ui.rs b/app/src/ai/conversation_status_ui.rs index 2146ffd9f..1aeced04b 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(4.) + .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/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..d17dbf845 100644 --- a/app/src/settings_view/show_blocks_view.rs +++ b/app/src/settings_view/show_blocks_view.rs @@ -18,6 +18,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 +31,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 +109,18 @@ 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_corner_radius(CornerRadius::with_all(Radius::Pixels(4.))); 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..8b3a7003d 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 { + "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..41d13d114 100644 --- a/app/src/themes/theme_chooser.rs +++ b/app/src/themes/theme_chooser.rs @@ -174,21 +174,11 @@ pub fn init(app: &mut AppContext) { } fn theme_chooser_items( - referral_theme_status: &ReferralThemeStatus, + _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(); - 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)); 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..704450214 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -9262,8 +9262,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 +17631,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(); From d1419f17dc2019ce84ab008fecf8810fb094422b Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sun, 26 Jul 2026 08:02:14 -0500 Subject: [PATCH 2/4] fix(ui): remove dead referral theme dependency --- app/src/themes/theme_chooser.rs | 28 ++++------------------------ app/src/workspace/view.rs | 12 +++--------- 2 files changed, 7 insertions(+), 33 deletions(-) diff --git a/app/src/themes/theme_chooser.rs b/app/src/themes/theme_chooser.rs index 41d13d114..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,10 +171,7 @@ pub fn init(app: &mut AppContext) { ]); } -fn theme_chooser_items( - _referral_theme_status: &ReferralThemeStatus, - theme_config: &WarpThemeConfig, -) -> Vec { +fn theme_chooser_items(theme_config: &WarpThemeConfig) -> Vec { let mut theme_items: Vec = theme_config .theme_items() .map(|(key, theme)| ThemeChooserItem::new(key.clone(), theme.clone())) @@ -186,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); @@ -208,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 { @@ -233,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), @@ -248,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(), } @@ -495,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/workspace/view.rs b/app/src/workspace/view.rs index 704450214..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()); From f4c13ce387368b13700efe160851e1001ec7820a Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sun, 26 Jul 2026 08:48:59 -0500 Subject: [PATCH 3/4] fix(ui): polish status copy and design tokens --- app/src/ai/conversation_status_ui.rs | 2 +- app/src/settings_view/show_blocks_view.rs | 7 +++++-- app/src/tab.rs | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/app/src/ai/conversation_status_ui.rs b/app/src/ai/conversation_status_ui.rs index 1aeced04b..e4f6e929c 100644 --- a/app/src/ai/conversation_status_ui.rs +++ b/app/src/ai/conversation_status_ui.rs @@ -70,7 +70,7 @@ pub fn render_status_element_with_label( Flex::row() .with_main_axis_size(MainAxisSize::Min) .with_cross_axis_alignment(CrossAxisAlignment::Center) - .with_spacing(4.) + .with_spacing(design::space::XS) .with_child( ConstrainedBox::new(icon.to_warpui_icon(Fill::from(color)).finish()) .with_width(12.) diff --git a/app/src/settings_view/show_blocks_view.rs b/app/src/settings_view/show_blocks_view.rs index d17dbf845..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; @@ -119,8 +120,10 @@ impl UserOwnedBlock { .with_width(20.) .finish(), ) - .with_uniform_padding(4.) - .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.))); + .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 8b3a7003d..046666ce6 100644 --- a/app/src/tab.rs +++ b/app/src/tab.rs @@ -900,7 +900,7 @@ impl<'a> TabComponent<'a> { return Some(if *pane_count > 1 { format!("{pane_count} agents need your response") } else { - "Agent needs your response".to_string() + "An agent needs your response".to_string() }); } From 37e8e369d5a534313ea9f84093ed522e97accef0 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:24:31 -0500 Subject: [PATCH 4/4] fix(oss): enable CLI agent status notifications Keep OSC 9/777 notifications enabled in OSS builds so local agent blocked states can reach the needs-response indicators. Add a channel-level regression test for the feature gate. Signed-off-by: Val Alexander <68980965+BunsDev@users.noreply.github.com> --- app/Cargo.toml | 1 + app/src/lib.rs | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) 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/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" + ); + } +}