first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+206
View File
@@ -0,0 +1,206 @@
//! Source-facing helpers that centralize the derivation of the agent-icon shape
//! ([`IconWithStatusVariant`]) from the underlying state models. The invariant the
//! helpers enforce: any single logical agent run renders as the same brand color, glyph,
//! and ambient-vs-local treatment regardless of which surface is rendering it (vertical
//! tabs, pane header, conversation list, notifications mailbox).
//!
//! Each helper is a thin adapter over one data source. Surfaces call the helper for
//! whichever source they hold and feed the resulting variant into
//! [`render_icon_with_status`]. The pure inner functions in this module are exercised
//! directly by the cross-surface consistency tests in `agent_icon_tests.rs`.
use warp_cli::agent::Harness;
use warpui::{AppContext, SingletonEntity};
use crate::ai::agent::conversation::ConversationStatus;
use crate::ai::agent_conversations_model::{
AgentConversationEntry, AgentConversationProvenance, AgentConversationsModel,
AgentRunDisplayStatus,
};
use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel;
use crate::terminal::view::TerminalView;
use crate::terminal::CLIAgent;
use crate::ui_components::icon_with_status::IconWithStatusVariant;
/// Returns the agent-icon variant for a live [`TerminalView`], or `None` when the terminal is
/// not an agent surface (plain terminal / shell / empty conversation).
///
/// Resolution order:
/// 1. A [`CLIAgentSessionsModel`] session with a known agent wins. Plugin-backed sessions
/// surface rich status; command-detected sessions don't.
/// 2. A task-backed run uses task status and harness so the terminal chrome and the
/// matching conversation list card stay in lockstep.
/// 3. Live ambient pre-dispatch or a selected local conversation falls through to the
/// no-task waterfall.
/// 4. Everything else returns `None` so the caller renders a plain-terminal indicator.
pub(crate) fn terminal_view_agent_icon_variant(
terminal_view: &TerminalView,
app: &AppContext,
) -> Option<IconWithStatusVariant> {
let cli_agent_session = CLIAgentSessionsModel::as_ref(app).session(terminal_view.id());
// Resolve the ambient task id from [`TerminalView::ambient_agent_task_id_for_details_panel`],
// falling back to the selected conversation's server metadata for restored cloud transcripts.
let ambient_task_id = terminal_view
.ambient_agent_task_id_for_details_panel(app)
.or_else(|| {
terminal_view
.selected_conversation_server_metadata(app)
.and_then(|m| m.ambient_agent_task_id)
});
let task_data = ambient_task_id
.and_then(|task_id| AgentConversationsModel::as_ref(app).get_task_data(&task_id));
// Local orchestration children are dispatched as server tasks (so they carry an ambient
// task id) but execute on the user's machine, so they must not get the cloud treatment.
let is_local_child = terminal_view.selected_conversation_is_local_child(app);
// Defer to the card helper when we have task data and no CLI session takes precedence.
if cli_agent_session.is_none() {
if let Some(task) = task_data.as_ref() {
let status = AgentRunDisplayStatus::from_task(task, app).to_conversation_status();
let harness = task
.agent_config_snapshot
.as_ref()
.and_then(|config| config.harness.as_ref())
.map(|harness| harness.harness_type)
.unwrap_or(Harness::Oz);
return Some(agent_icon_variant_for_run(harness, status, !is_local_child));
}
}
let is_ambient = terminal_view.is_ambient_agent_session(app)
|| (ambient_task_id.is_some() && !is_local_child);
let inputs = TerminalIconInputs {
is_ambient,
cli_session: cli_agent_session.map(|session| CLISessionInputs {
agent: session.agent,
has_listener: session.listener.is_some(),
status: session.status.to_conversation_status(),
supports_rich_status: session.supports_rich_status(),
}),
selected_third_party_cli_agent: terminal_view
.ambient_agent_view_model()
.and_then(|model| model.as_ref(app).selected_third_party_cli_agent()),
selected_conversation_status: terminal_view.selected_conversation_status_for_display(app),
has_selected_conversation: terminal_view
.selected_conversation_display_title(app)
.is_some(),
};
agent_icon_variant_from_terminal_inputs(&inputs)
}
pub(crate) fn agent_conversation_entry_icon_variant(
entry: &AgentConversationEntry,
) -> IconWithStatusVariant {
let status = entry.display.status.to_conversation_status();
let is_ambient = matches!(entry.provenance, AgentConversationProvenance::AmbientRun)
|| entry.backing.has_ambient_run
|| entry.identity.ambient_agent_task_id.is_some();
agent_icon_variant_for_run(
entry.display.harness.unwrap_or(Harness::Oz),
status,
is_ambient,
)
}
/// Primitive inputs to the terminal-view waterfall, gathered once from the live
/// [`TerminalView`] / [`AppContext`].
struct TerminalIconInputs {
is_ambient: bool,
cli_session: Option<CLISessionInputs>,
/// Third-party CLI agent for a live ambient run before task data is available (e.g.
/// Claude pre-dispatch). `None` otherwise; task-derived harnesses are handled upstream.
selected_third_party_cli_agent: Option<CLIAgent>,
/// The conversation status that the terminal view would surface in its status-icon slot.
selected_conversation_status: Option<ConversationStatus>,
/// Whether the terminal view currently has a selected conversation (ambient or local).
has_selected_conversation: bool,
}
/// CLI-session-derived inputs for the terminal waterfall.
struct CLISessionInputs {
agent: CLIAgent,
/// Whether the session is backed by a plugin listener. Plugin-backed sessions report
/// rich status; command-detected sessions only know that an agent is running.
has_listener: bool,
status: ConversationStatus,
/// Whether the agent's session handler exposes rich status (plugin-backed handlers report
/// rich status; Codex's OSC 9 handler does not).
supports_rich_status: bool,
}
/// Pure waterfall from primitive inputs to an [`IconWithStatusVariant`]. Mirrors the
/// resolution order documented on [`terminal_view_agent_icon_variant`].
fn agent_icon_variant_from_terminal_inputs(
inputs: &TerminalIconInputs,
) -> Option<IconWithStatusVariant> {
// 1. CLI session with a known (non-Unknown) agent wins. Status is only meaningful when
// the session is plugin-backed and the handler exposes rich status.
if let Some(session) = inputs
.cli_session
.as_ref()
.filter(|s| !matches!(s.agent, CLIAgent::Unknown))
{
let status =
(session.has_listener && session.supports_rich_status).then(|| session.status.clone());
return Some(IconWithStatusVariant::CLIAgent {
agent: session.agent,
status,
is_ambient: inputs.is_ambient,
});
}
// 2. Live ambient run with a third-party harness selected, before task data is
// available (e.g. Claude pre-dispatch). `Unknown` is filtered so an unrecognized
// harness doesn't render as an unbranded gray circle.
if inputs.is_ambient {
if let Some(agent) = inputs
.selected_third_party_cli_agent
.filter(|agent| !matches!(agent, CLIAgent::Unknown))
{
return Some(IconWithStatusVariant::CLIAgent {
agent,
status: inputs.selected_conversation_status.clone(),
is_ambient: true,
});
}
}
// 3. Selected conversation OR ambient (Oz) terminal: Oz agent variant.
if inputs.has_selected_conversation || inputs.is_ambient {
return Some(IconWithStatusVariant::OzAgent {
status: inputs.selected_conversation_status.clone(),
is_ambient: inputs.is_ambient,
});
}
None
}
/// Pure run-card logic: maps a [`Harness`], status, and ambient flag into an
/// [`IconWithStatusVariant`]. Falls back to the Oz variant for [`Harness::Oz`] and
/// [`Harness::Unknown`], the latter so a future-server harness this client doesn't
/// recognize doesn't render an unbranded gray circle.
pub(crate) fn agent_icon_variant_for_run(
harness: Harness,
status: ConversationStatus,
is_ambient: bool,
) -> IconWithStatusVariant {
let cli_agent =
CLIAgent::from_harness(harness).filter(|agent| !matches!(agent, CLIAgent::Unknown));
match cli_agent {
Some(agent) => IconWithStatusVariant::CLIAgent {
agent,
status: Some(status),
is_ambient,
},
None => IconWithStatusVariant::OzAgent {
status: Some(status),
is_ambient,
},
}
}
#[cfg(test)]
#[path = "agent_icon_tests.rs"]
mod tests;
+438
View File
@@ -0,0 +1,438 @@
//! Cross-surface equivalence tests for the agent-icon helpers.
//!
//! The invariant under test: for every canonical logical run state, every surface produces
//! the same [`IconWithStatusVariant`]. Surfaces today are:
//! - Terminal view (vertical tabs + pane header) via
//! [`super::agent_icon_variant_from_terminal_inputs`]
//! - Run cards (conversation list, agent management view) via
//! [`super::agent_icon_variant_for_run`]
//! - Notification mailbox — exercised in `notifications/item_tests.rs`
//!
//! Adding a new canonical state is a one-enum-variant + one `expected` arm + one `*_inputs`
//! arm change; the table test below enforces every surface agrees.
use chrono::Utc;
use warp_cli::agent::Harness;
use super::{
agent_conversation_entry_icon_variant, agent_icon_variant_for_run,
agent_icon_variant_from_terminal_inputs, CLISessionInputs, TerminalIconInputs,
};
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus};
use crate::ai::agent_conversations_model::entry::{
AgentConversationBackingData, AgentConversationCapabilities, AgentConversationDisplayData,
AgentConversationIdentity, AgentConversationPrincipal,
};
use crate::ai::agent_conversations_model::{
AgentConversationEntry, AgentConversationEntryId, AgentConversationProvenance,
AgentRunDisplayStatus,
};
use crate::terminal::CLIAgent;
use crate::ui_components::icon_with_status::IconWithStatusVariant;
/// Projection of the fields we care about for cross-surface equivalence.
/// [`IconWithStatusVariant`] itself can't derive `PartialEq` because `NeutralElement`
/// carries a `Box<dyn Element>`, so we extract the agent-variant fields here.
#[derive(Debug, Clone, PartialEq, Eq)]
struct AgentIconFields {
is_cli: bool,
cli_agent: Option<CLIAgent>,
status: Option<ConversationStatus>,
is_ambient: bool,
}
impl AgentIconFields {
fn from_variant(variant: &IconWithStatusVariant) -> Option<Self> {
match variant {
IconWithStatusVariant::OzAgent { status, is_ambient } => Some(Self {
is_cli: false,
cli_agent: None,
status: status.clone(),
is_ambient: *is_ambient,
}),
IconWithStatusVariant::CLIAgent {
agent,
status,
is_ambient,
} => Some(Self {
is_cli: true,
cli_agent: Some(*agent),
status: status.clone(),
is_ambient: *is_ambient,
}),
IconWithStatusVariant::Neutral { .. }
| IconWithStatusVariant::NeutralElement { .. }
| IconWithStatusVariant::CustomAvatar { .. } => None,
}
}
}
/// Canonical logical run states. Each represents a conceptually distinct run whose icon must
/// be rendered identically across every surface that can display it.
#[derive(Debug, Clone, Copy)]
enum CanonicalRunState {
/// Plain terminal, no conversation, no agent activity.
PlainTerminal,
/// Local Warp-native (Oz) conversation, in-progress.
LocalOzInProgress,
/// Cloud-mode Oz run, in-progress.
CloudOzInProgress,
/// Cloud Claude harness selected, pre-dispatch (no session, no status yet).
/// This is the state the pre-setup icon bug regressed on — the tab must already render
/// the Claude brand circle even though no CLI session exists yet.
CloudClaudePreDispatch,
/// Cloud Claude harness selected, dispatch in flight (status = InProgress, no session).
CloudClaudeInProgress,
/// Viewing a finished cloud Codex transcript whose VM has shut down. No live ambient
/// model exists, so the harness comes from the conversation's server metadata; the icon
/// must still render as cloud Codex.
ViewingCloudCodexTranscript,
/// Local Claude CLI session with a plugin listener (rich status), in-progress.
LocalClaudePluginInProgress,
/// Local Claude CLI session with a plugin listener (rich status), blocked.
LocalClaudePluginBlocked,
/// Local Claude CLI session detected via command matching only (no listener, no rich status).
LocalClaudeCommandDetected,
}
impl CanonicalRunState {
fn all() -> &'static [Self] {
use CanonicalRunState::*;
&[
PlainTerminal,
LocalOzInProgress,
CloudOzInProgress,
CloudClaudePreDispatch,
CloudClaudeInProgress,
ViewingCloudCodexTranscript,
LocalClaudePluginInProgress,
LocalClaudePluginBlocked,
LocalClaudeCommandDetected,
]
}
/// The canonical [`AgentIconFields`] for this state. `None` means no agent icon renders.
/// Editing an arm here is the deliberate way to evolve the cross-surface contract.
fn expected(&self) -> Option<AgentIconFields> {
match self {
PlainTerminal => None,
LocalOzInProgress => Some(AgentIconFields {
is_cli: false,
cli_agent: None,
status: Some(ConversationStatus::InProgress),
is_ambient: false,
}),
CloudOzInProgress => Some(AgentIconFields {
is_cli: false,
cli_agent: None,
status: Some(ConversationStatus::InProgress),
is_ambient: true,
}),
CloudClaudePreDispatch => Some(AgentIconFields {
is_cli: true,
cli_agent: Some(CLIAgent::Claude),
status: None,
is_ambient: true,
}),
CloudClaudeInProgress => Some(AgentIconFields {
is_cli: true,
cli_agent: Some(CLIAgent::Claude),
status: Some(ConversationStatus::InProgress),
is_ambient: true,
}),
ViewingCloudCodexTranscript => Some(AgentIconFields {
is_cli: true,
cli_agent: Some(CLIAgent::Codex),
status: Some(ConversationStatus::Success),
is_ambient: true,
}),
LocalClaudePluginInProgress => Some(AgentIconFields {
is_cli: true,
cli_agent: Some(CLIAgent::Claude),
status: Some(ConversationStatus::InProgress),
is_ambient: false,
}),
LocalClaudePluginBlocked => Some(AgentIconFields {
is_cli: true,
cli_agent: Some(CLIAgent::Claude),
status: Some(ConversationStatus::Blocked {
blocked_action: String::new(),
}),
is_ambient: false,
}),
LocalClaudeCommandDetected => Some(AgentIconFields {
is_cli: true,
cli_agent: Some(CLIAgent::Claude),
status: None,
is_ambient: false,
}),
}
}
/// Terminal-view inputs for this state. Every state has a terminal representation.
fn terminal_inputs(&self) -> TerminalIconInputs {
match self {
PlainTerminal => TerminalIconInputs {
is_ambient: false,
cli_session: None,
selected_third_party_cli_agent: None,
selected_conversation_status: None,
has_selected_conversation: false,
},
LocalOzInProgress => TerminalIconInputs {
is_ambient: false,
cli_session: None,
selected_third_party_cli_agent: None,
selected_conversation_status: Some(ConversationStatus::InProgress),
has_selected_conversation: true,
},
CloudOzInProgress => TerminalIconInputs {
is_ambient: true,
cli_session: None,
selected_third_party_cli_agent: None,
selected_conversation_status: Some(ConversationStatus::InProgress),
has_selected_conversation: false,
},
CloudClaudePreDispatch => TerminalIconInputs {
is_ambient: true,
cli_session: None,
selected_third_party_cli_agent: Some(CLIAgent::Claude),
selected_conversation_status: None,
has_selected_conversation: false,
},
CloudClaudeInProgress => TerminalIconInputs {
is_ambient: true,
cli_session: None,
selected_third_party_cli_agent: Some(CLIAgent::Claude),
selected_conversation_status: Some(ConversationStatus::InProgress),
has_selected_conversation: false,
},
ViewingCloudCodexTranscript => TerminalIconInputs {
// VM has shut down: the caller resolves these fields from the conversation's
// server metadata, so the waterfall sees the same shape as a live run.
is_ambient: true,
cli_session: None,
selected_third_party_cli_agent: Some(CLIAgent::Codex),
selected_conversation_status: Some(ConversationStatus::Success),
has_selected_conversation: true,
},
LocalClaudePluginInProgress => TerminalIconInputs {
is_ambient: false,
cli_session: Some(CLISessionInputs {
agent: CLIAgent::Claude,
has_listener: true,
status: ConversationStatus::InProgress,
supports_rich_status: true,
}),
selected_third_party_cli_agent: None,
selected_conversation_status: None,
has_selected_conversation: false,
},
LocalClaudePluginBlocked => TerminalIconInputs {
is_ambient: false,
cli_session: Some(CLISessionInputs {
agent: CLIAgent::Claude,
has_listener: true,
status: ConversationStatus::Blocked {
blocked_action: String::new(),
},
supports_rich_status: true,
}),
selected_third_party_cli_agent: None,
selected_conversation_status: None,
has_selected_conversation: false,
},
LocalClaudeCommandDetected => TerminalIconInputs {
is_ambient: false,
cli_session: Some(CLISessionInputs {
agent: CLIAgent::Claude,
has_listener: false,
status: ConversationStatus::InProgress,
supports_rich_status: false,
}),
selected_third_party_cli_agent: None,
selected_conversation_status: None,
has_selected_conversation: false,
},
}
}
/// Run-card inputs for this state, if it can surface as a run card.
/// Cards only exist for cloud/ambient runs; local states return `None`.
fn run_inputs(&self) -> Option<(Harness, ConversationStatus, bool)> {
match self {
CloudOzInProgress => Some((Harness::Oz, ConversationStatus::InProgress, true)),
CloudClaudePreDispatch | CloudClaudeInProgress => {
Some((Harness::Claude, ConversationStatus::InProgress, true))
}
ViewingCloudCodexTranscript => {
Some((Harness::Codex, ConversationStatus::Success, true))
}
PlainTerminal
| LocalOzInProgress
| LocalClaudePluginInProgress
| LocalClaudePluginBlocked
| LocalClaudeCommandDetected => None,
}
}
}
/// The consistency enforcer: for every canonical state, the terminal-side and task-side
/// helpers must produce the same [`AgentIconFields`] projection.
#[test]
fn every_canonical_state_produces_consistent_icon_across_surfaces() {
for state in CanonicalRunState::all() {
let expected = state.expected();
let terminal_actual = agent_icon_variant_from_terminal_inputs(&state.terminal_inputs())
.as_ref()
.and_then(AgentIconFields::from_variant);
assert_eq!(
terminal_actual, expected,
"terminal surface disagreed for {state:?}"
);
if let Some((harness, status, is_ambient)) = state.run_inputs() {
let run_variant = agent_icon_variant_for_run(harness, status.clone(), is_ambient);
let run_actual = AgentIconFields::from_variant(&run_variant);
// Run cards always populate status (they derive it from `ConversationOrTask::status`).
let expected_for_run = expected.clone().map(|mut fields| {
fields.status = Some(status);
fields
});
assert_eq!(
run_actual, expected_for_run,
"run-card surface disagreed for {state:?}"
);
}
}
}
/// Structural invariant: the `is_ambient` flag on the rendered variant must match the
/// `is_ambient` flag on the terminal inputs. Catches accidental drift in the waterfall.
#[test]
fn terminal_is_ambient_matches_inputs_for_every_state() {
for state in CanonicalRunState::all() {
let inputs = state.terminal_inputs();
let Some(variant) = agent_icon_variant_from_terminal_inputs(&inputs) else {
continue;
};
let fields = AgentIconFields::from_variant(&variant)
.expect("terminal helper must only return agent variants");
assert_eq!(
fields.is_ambient, inputs.is_ambient,
"is_ambient drifted for {state:?}"
);
}
}
#[test]
fn cli_agent_from_harness_maps_known_harnesses() {
assert_eq!(CLIAgent::from_harness(Harness::Oz), None);
assert_eq!(
CLIAgent::from_harness(Harness::Claude),
Some(CLIAgent::Claude)
);
assert_eq!(
CLIAgent::from_harness(Harness::Gemini),
Some(CLIAgent::Gemini)
);
assert_eq!(
CLIAgent::from_harness(Harness::OpenCode),
Some(CLIAgent::OpenCode)
);
}
#[test]
fn run_card_with_oz_or_unknown_harness_renders_as_oz() {
// Oz harness explicitly: local Oz is the spec-defined fallback.
let variant = agent_icon_variant_for_run(Harness::Oz, ConversationStatus::Success, true);
let fields = AgentIconFields::from_variant(&variant).unwrap();
assert!(!fields.is_cli);
assert!(fields.is_ambient);
// Unknown harness (e.g. server surfaced a future variant): also falls back to Oz so we
// don't render an unbranded gray circle.
let variant = agent_icon_variant_for_run(Harness::Unknown, ConversationStatus::Success, true);
let fields = AgentIconFields::from_variant(&variant).unwrap();
assert!(!fields.is_cli);
assert!(fields.is_ambient);
}
/// A local Claude session and an ambient Claude run must render with the same CLI agent
/// brand but differ only by `is_ambient`. This answers the product-spec ambiguity about
/// whether those should look different — they should.
#[test]
fn local_claude_vs_cloud_claude_differ_only_by_is_ambient() {
let local = agent_icon_variant_from_terminal_inputs(
&CanonicalRunState::LocalClaudePluginInProgress.terminal_inputs(),
)
.and_then(|v| AgentIconFields::from_variant(&v))
.unwrap();
let cloud = agent_icon_variant_from_terminal_inputs(
&CanonicalRunState::CloudClaudeInProgress.terminal_inputs(),
)
.and_then(|v| AgentIconFields::from_variant(&v))
.unwrap();
assert_eq!(local.cli_agent, cloud.cli_agent);
assert_eq!(local.cli_agent, Some(CLIAgent::Claude));
assert!(!local.is_ambient);
assert!(cloud.is_ambient);
}
#[test]
fn non_ambient_entry_uses_display_harness() {
let conversation_id = AIConversationId::new();
let entry = AgentConversationEntry {
id: AgentConversationEntryId::Conversation(conversation_id),
identity: AgentConversationIdentity {
local_conversation_id: Some(conversation_id),
ambient_agent_task_id: None,
server_conversation_token: None,
session_id: None,
},
provenance: AgentConversationProvenance::CloudSyncedConversation,
display: AgentConversationDisplayData {
title: "Codex conversation".to_string(),
initial_query: None,
created_at: Utc::now(),
last_updated: Utc::now(),
status: AgentRunDisplayStatus::ConversationSucceeded,
creator: AgentConversationPrincipal::default(),
executor: None,
request_usage: None,
run_time: None,
session_status: None,
source: None,
working_directory: None,
environment_id: None,
harness: Some(Harness::Codex),
artifacts: Vec::new(),
},
backing: AgentConversationBackingData {
has_loaded_conversation: true,
has_local_persisted_data: true,
has_cloud_data: true,
has_ambient_run: false,
},
capabilities: AgentConversationCapabilities {
can_open: true,
can_copy_link: false,
can_share: false,
can_delete: false,
can_fork_locally: false,
can_cancel: false,
},
};
let variant = agent_conversation_entry_icon_variant(&entry);
assert_eq!(
AgentIconFields::from_variant(&variant).unwrap(),
AgentIconFields {
is_cli: true,
cli_agent: Some(CLIAgent::Codex),
status: Some(ConversationStatus::Success),
is_ambient: false,
}
);
}
+7 -10
View File
@@ -1,16 +1,13 @@
use galaxyui::{
elements::{
self, Align, Border, CacheOption, ConstrainedBox, Container, Element, Image, ParentElement,
Text,
},
ui_components::components::{UiComponent, UiComponentStyles},
use pathfinder_geometry::vector::vec2f;
use galaxy_core::ui::external_product_icon::ExternalProductIcon;
use galaxy_core::ui::icons::Icon;
use galaxyui::elements::{
self, Align, Border, CacheOption, ChildAnchor, ConstrainedBox, Container, Element, Image,
OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Stack, Text,
};
use warpui::ui_components::components::{UiComponent, UiComponentStyles};
use super::red_notification_dot::RedNotificationDot;
use galaxy_core::ui::{external_product_icon::ExternalProductIcon, icons::Icon};
use galaxyui::elements::{ChildAnchor, OffsetPositioning, ParentAnchor, ParentOffsetBounds, Stack};
use pathfinder_geometry::vector::vec2f;
pub enum AvatarContent {
/// Rendered as capital initial of the given display name.
+5 -8
View File
@@ -1,14 +1,11 @@
use std::fmt::Debug;
use galaxyui::{
elements::{
CrossAxisAlignment, Flex, Hoverable, MainAxisSize, MouseStateHandle, ParentElement,
Shrinkable,
},
ui_components::components::{UiComponent, UiComponentStyles},
AppContext, Element, EventContext,
};
use itertools::{Itertools, Position};
use galaxyui::elements::{
CrossAxisAlignment, Flex, Hoverable, MainAxisSize, MouseStateHandle, ParentElement, Shrinkable,
};
use galaxyui::ui_components::components::{UiComponent, UiComponentStyles};
use galaxyui::{AppContext, Element, EventContext};
use crate::appearance::Appearance;
+5 -6
View File
@@ -1,12 +1,11 @@
use warpui::elements::{CornerRadius, MouseStateHandle, Radius};
use warpui::ui_components::button::Button;
use warpui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use super::icons::{Icon, ICON_DIMENSIONS};
use super::{blended_colors, BORDER_RADIUS};
use crate::appearance::Appearance;
use crate::themes::theme::Fill;
use crate::themes::theme::GalaxyTheme;
use galaxyui::elements::Radius;
use galaxyui::elements::{CornerRadius, MouseStateHandle};
use galaxyui::ui_components::button::Button;
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use crate::themes::theme::{Fill, WarpTheme};
const ICON_BUTTON_PADDING: f32 = 4.;
+61 -27
View File
@@ -1,5 +1,3 @@
use super::blended_colors;
use crate::appearance::Appearance;
use galaxyui::elements::{
Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Dismiss, Element, Flex,
MainAxisAlignment, MainAxisSize, ParentElement, Shrinkable, Text,
@@ -7,6 +5,9 @@ use galaxyui::elements::{
use galaxyui::fonts::{Properties, Weight};
use galaxyui::ui_components::components::{UiComponent, UiComponentStyles};
use super::blended_colors;
use crate::appearance::Appearance;
const DIALOG_PADDING: f32 = 20.;
/// UiComponent that implements a dialog.
@@ -22,6 +23,9 @@ pub struct Dialog {
child: Option<Box<dyn Element>>,
styles: UiComponentStyles,
close_button: Option<Box<dyn Element>>,
/// Optional icon rendered above the title. When set, the header row becomes
/// `[icon] … [close button]` with the title on its own row below.
header_icon: Option<Box<dyn Element>>,
show_separator: bool,
}
@@ -53,6 +57,7 @@ impl Dialog {
bottom_row: Default::default(),
bottom_row_left: Default::default(),
close_button: None,
header_icon: None,
show_separator: false,
}
}
@@ -67,6 +72,13 @@ impl Dialog {
self
}
/// Sets an icon element rendered in the top row alongside the close button,
/// with the dialog title displayed below that row.
pub fn with_header_icon(mut self, icon: Box<dyn Element>) -> Self {
self.header_icon = Some(icon);
self
}
pub fn with_bottom_row_child(mut self, child: Box<dyn Element>) -> Self {
self.bottom_row.push(child);
self
@@ -92,28 +104,18 @@ impl UiComponent for Dialog {
type ElementType = Dismiss;
fn build(self) -> Dismiss {
let mut header = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
Shrinkable::new(
1.,
Text::new(
self.title,
self.styles.font_family_id.expect("FamilyId set"),
self.styles.font_size.expect("Font size set"),
)
.with_style(self.styles.font_properties())
.with_color(self.styles.font_color.unwrap_or_default())
.finish(),
)
.finish(),
);
if let Some(close_button) = self.close_button {
header.add_child(close_button);
}
let title_element = Shrinkable::new(
1.,
Text::new(
self.title,
self.styles.font_family_id.expect("FamilyId set"),
self.styles.font_size.expect("Font size set"),
)
.with_style(self.styles.font_properties())
.with_color(self.styles.font_color.unwrap_or_default())
.finish(),
)
.finish();
let footer = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
@@ -152,13 +154,45 @@ impl UiComponent for Dialog {
)
};
let mut main_content = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(
let mut main_content =
Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
if let Some(header_icon) = self.header_icon {
// Icon + close button in the top row, title on its own row below.
let mut icon_row = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(header_icon);
if let Some(close_button) = self.close_button {
icon_row.add_child(close_button);
}
main_content.add_child(
Container::new(icon_row.finish())
.with_padding_bottom(12.)
.finish(),
);
main_content.add_child(
Container::new(title_element)
.with_padding_bottom(DIALOG_PADDING)
.finish(),
);
} else {
// Original layout: title and close button share the same row.
let mut header = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(title_element);
if let Some(close_button) = self.close_button {
header.add_child(close_button);
}
main_content.add_child(
Container::new(header.finish())
.with_padding_bottom(DIALOG_PADDING)
.finish(),
);
}
if let Some(body) = self.body {
main_content.add_child(
+401 -104
View File
@@ -8,22 +8,115 @@ use galaxyui::elements::{
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use crate::ai::agent::conversation::ConversationStatus;
use crate::ai::agent::conversation::{ConversationStatus, StatusColorStyle};
use crate::terminal::CLIAgent;
use crate::themes::theme::Fill as ThemeFill;
/// Sizing configuration for the icon circle and its status badge.
pub(crate) struct IconWithStatusSizing {
pub(crate) icon_size: f32,
pub(crate) padding: f32,
pub(crate) badge_icon_size: f32,
pub(crate) badge_padding: f32,
/// The overall constrained size for the stack.
/// When set, overrides the default `icon_size + padding * 2`.
pub(crate) overall_size_override: Option<f32>,
/// Offset of the status badge from the bottom-right corner of the circle.
/// Positive x pushes right, positive y pushes down.
pub(crate) badge_offset: (f32, f32),
/// Background color used for the Oz agent's circle when it is running in an ambient (cloud)
/// run. Matches the Oz brand purple used in the cloud-mode design spec.
const OZ_AMBIENT_BACKGROUND_COLOR: ColorU = ColorU {
r: 203,
g: 176,
b: 247,
a: 255,
};
// Sub-component size ratios, expressed as fractions of `total_size`. The brand circle is
// ~76% wide and the status badge is ~57% wide, with the badge's bottom-right anchored at
// the box's bottom-right corner. With these ratios the badge center sits *inside* the
// brand circle (not on its edge). `CIRCLE_RATIO` is `pub(crate)` so callers that
// pre-render their own avatar can size it consistently with the other variants.
pub(crate) const CIRCLE_RATIO: f32 = 0.76;
const ICON_RATIO: f32 = 0.43;
const DEFAULT_BADGE_RATIO: f32 = 0.57;
const DEFAULT_BADGE_ICON_RATIO: f32 = 0.34;
const CLOUD_RATIO: f32 = 0.57;
const STATUS_IN_CLOUD_RATIO: f32 = 0.285;
/// Status-badge geometry override. Pass [`StatusBadgeStyle::DEFAULT`] for today's look.
#[derive(Clone, Copy)]
pub(crate) struct StatusBadgeStyle {
/// Cutout-ring diameter as a fraction of `total_size`.
pub ring_ratio: f32,
/// Status-icon glyph diameter as a fraction of `total_size`.
pub icon_ratio: f32,
pub inner_shape: BadgeInnerShape,
}
#[derive(Clone, Copy)]
pub(crate) enum BadgeInnerShape {
Circle,
RoundedSquare { radius_px: f32 },
}
impl StatusBadgeStyle {
pub(crate) const DEFAULT: Self = Self {
ring_ratio: DEFAULT_BADGE_RATIO,
icon_ratio: DEFAULT_BADGE_ICON_RATIO,
inner_shape: BadgeInnerShape::Circle,
};
}
// Neutral variants have no overlay, so they fill the full `total_size` bounding box. The
// inner glyph occupies `NEUTRAL_GLYPH_RATIO * total_size`, matching the old sizing where
// a 24px container held a 16px glyph (16/24 ≈ 0.667).
const NEUTRAL_GLYPH_RATIO: f32 = 16.0 / 24.0;
/// Returns the brand-circle diameter for a given `total_size`.
pub(crate) fn circle_size(total: f32) -> f32 {
total * CIRCLE_RATIO
}
fn icon_size(total: f32) -> f32 {
total * ICON_RATIO
}
fn circle_padding(total: f32) -> f32 {
(circle_size(total) - icon_size(total)) / 2.
}
fn badge_size(total: f32, style: StatusBadgeStyle) -> f32 {
total * style.ring_ratio
}
fn badge_icon_size(total: f32, style: StatusBadgeStyle) -> f32 {
total * style.icon_ratio
}
fn badge_padding(total: f32, style: StatusBadgeStyle) -> f32 {
(badge_size(total, style) - badge_icon_size(total, style)) / 4.
}
fn cloud_icon_size(total: f32) -> f32 {
total * CLOUD_RATIO
}
fn status_in_cloud_size(total: f32) -> f32 {
total * STATUS_IN_CLOUD_RATIO
}
/// Default overhang of the overlay's BR past the circle's BR edge (toward the box's
/// BR), as a fraction of `total_size`. Baked into `corner_overlay_offset` so most
/// surfaces can just pass `0.0` for their `overlay_extra_overhang_ratio`.
const DEFAULT_OVERLAY_OVERHANG_PAST_CIRCLE_EDGE: f32 = 0.19;
/// Returns the pixel offset applied to the overlay's `BottomRight → BottomRight`
/// anchor.
/// The offset is measured from the bounding box's BR corner, so the returned value is
/// negative whenever the overlay sits up-and-left of the box's BR (which is the only
/// case we render).
///
/// `overlay_extra_overhang_ratio` is a signed fraction of `total` added to
/// `DEFAULT_OVERLAY_OVERHANG_PAST_CIRCLE_EDGE`:
/// * `0.0` — overlay BR sits `DEFAULT_OVERLAY_OVERHANG_PAST_CIRCLE_EDGE * total` past
/// the circle's BR (the position most surfaces want).
/// * Positive — overlay BR pushed further toward the box's BR. A value of
/// `1 - CIRCLE_RATIO - DEFAULT_OVERLAY_OVERHANG_PAST_CIRCLE_EDGE` (= 0.05) lands
/// exactly on the box's BR — the Figma-natural overhang.
/// * Negative — overlay BR pulled inward toward the circle's center.
fn corner_overlay_offset(total: f32, overlay_extra_overhang_ratio: f32) -> f32 {
let total_overhang = DEFAULT_OVERLAY_OVERHANG_PAST_CIRCLE_EDGE + overlay_extra_overhang_ratio;
-((1.0 - CIRCLE_RATIO) - total_overhang) * total
}
/// What to render inside the circle.
@@ -44,74 +137,111 @@ pub(crate) enum IconWithStatusVariant {
CLIAgent {
agent: CLIAgent,
status: Option<ConversationStatus>,
is_ambient: bool,
},
/// A pre-rendered avatar with an optional status overlay (cloud lobe when
/// ambient). Caller must size `avatar` to `circle_size(total_size)` so the
/// overlay's overhang matches the other variants.
CustomAvatar {
avatar: Box<dyn Element>,
status: Option<ConversationStatus>,
is_ambient: bool,
},
}
/// Renders an icon inside a circle with an optional status badge overlay.
/// Renders an icon-with-status component sized entirely from a single `total_size`. All
/// sub-components (brand circle, status badge, cloud lobe) are derived proportionally,
/// so callers only need to pick the size they want.
///
/// `overlay_extra_overhang_ratio` is a signed fraction of `total_size` added to the
/// default overlay overhang past the circle's BR edge. Most surfaces pass `0.0` to
/// get the default position; positive values push the overlay further toward the box's
/// BR (more overhang) and negative values pull it inward toward the circle's center.
///
/// When `is_ambient` is set on an agent variant, the status badge is replaced by a
/// cloud (filled with `status_container_background`) containing the status icon.
pub(crate) fn render_icon_with_status(
variant: IconWithStatusVariant,
sizing: &IconWithStatusSizing,
theme: &GalaxyTheme,
badge_ring_background: GalaxyThemeFill,
total_size: f32,
overlay_extra_overhang_ratio: f32,
theme: &WarpTheme,
status_container_background: WarpThemeFill,
) -> Box<dyn Element> {
render_icon_with_status_with_badge_style(
variant,
total_size,
overlay_extra_overhang_ratio,
StatusBadgeStyle::DEFAULT,
theme,
status_container_background,
)
}
/// Like [`render_icon_with_status`] but with a custom [`StatusBadgeStyle`]. The
/// cloud-lobe path (`is_ambient`) ignores it.
pub(crate) fn render_icon_with_status_with_badge_style(
variant: IconWithStatusVariant,
total_size: f32,
overlay_extra_overhang_ratio: f32,
badge_style: StatusBadgeStyle,
theme: &WarpTheme,
status_container_background: WarpThemeFill,
) -> Box<dyn Element> {
let sub_text = theme.sub_text_color(theme.background());
match variant {
IconWithStatusVariant::Neutral { icon, icon_color } => {
let inner = ConstrainedBox::new(icon.to_galaxyui_icon(icon_color).finish())
.with_width(sizing.icon_size)
.with_height(sizing.icon_size)
.finish();
Container::new(inner)
.with_uniform_padding(sizing.padding)
.with_background(internal_colors::fg_overlay_2(theme))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
(sizing.icon_size + sizing.padding * 2.) / 2.,
)))
.finish()
}
IconWithStatusVariant::NeutralElement { icon_element } => {
let inner = ConstrainedBox::new(icon_element)
.with_width(sizing.icon_size)
.with_height(sizing.icon_size)
.finish();
Container::new(inner)
.with_uniform_padding(sizing.padding)
.with_background(internal_colors::fg_overlay_2(theme))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
(sizing.icon_size + sizing.padding * 2.) / 2.,
)))
.finish()
}
IconWithStatusVariant::Neutral { icon, icon_color } => render_neutral_circle(
icon.to_galaxyui_icon(icon_color).finish(),
internal_colors::fg_overlay_2(theme),
total_size,
),
IconWithStatusVariant::NeutralElement { icon_element } => render_neutral_circle(
icon_element,
internal_colors::fg_overlay_2(theme),
total_size,
),
IconWithStatusVariant::OzAgent { status, is_ambient } => {
let icon = if is_ambient {
GalaxyIcon::OzCloud
let circle_background = if is_ambient {
ThemeFill::Solid(OZ_AMBIENT_BACKGROUND_COLOR)
} else {
theme.background()
};
// In ambient/cloud mode use the combined `OzCloud` silhouette (Oz + cloud),
// matching the treatment used in the agent view header. Non-ambient runs
// continue to use the plain `Oz` glyph.
let oz_glyph = if is_ambient {
WarpIcon::OzCloud
} else {
GalaxyIcon::Oz
};
let inner = ConstrainedBox::new(
icon.to_galaxyui_icon(theme.main_text_color(theme.background()))
.finish(),
)
.with_width(sizing.icon_size)
.with_height(sizing.icon_size)
.finish();
let circle = Container::new(inner)
.with_uniform_padding(sizing.padding)
.with_background(theme.background())
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
(sizing.icon_size + sizing.padding * 2.) / 2.,
)))
.finish();
render_with_optional_status_badge(
// Cloud (ambient) runs use a black glyph on the light-purple background
// for consistency with the web app; local runs keep the theme text color.
let glyph_color = if is_ambient {
WarpThemeFill::Solid(ColorU::black())
} else {
theme.main_text_color(theme.background())
};
let circle = render_circle(
oz_glyph.to_galaxyui_icon(glyph_color).finish(),
circle_background,
total_size,
);
attach_status_overlay(
circle,
status.as_ref(),
sizing,
is_ambient,
total_size,
overlay_extra_overhang_ratio,
badge_style,
theme,
badge_ring_background,
status_container_background,
)
}
IconWithStatusVariant::CLIAgent { agent, status } => {
IconWithStatusVariant::CLIAgent {
agent,
status,
is_ambient,
} => {
let brand_color = agent
.brand_color()
.unwrap_or(ColorU::new(100, 100, 100, 255));
@@ -122,78 +252,245 @@ pub(crate) fn render_icon_with_status(
icon.to_galaxyui_icon(GalaxyThemeFill::Solid(icon_color))
.finish()
})
.unwrap_or_else(|| GalaxyIcon::Terminal.to_galaxyui_icon(sub_text).finish());
let inner = ConstrainedBox::new(icon_element)
.with_width(sizing.icon_size)
.with_height(sizing.icon_size)
.finish();
let circle = Container::new(inner)
.with_uniform_padding(sizing.padding)
.with_background(ThemeFill::Solid(brand_color))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
(sizing.icon_size + sizing.padding * 2.) / 2.,
)))
.finish();
render_with_optional_status_badge(
.unwrap_or_else(|| WarpIcon::Terminal.to_galaxyui_icon(sub_text).finish());
let circle = render_circle(icon_element, ThemeFill::Solid(brand_color), total_size);
attach_status_overlay(
circle,
status.as_ref(),
sizing,
is_ambient,
total_size,
overlay_extra_overhang_ratio,
badge_style,
theme,
badge_ring_background,
status_container_background,
)
}
IconWithStatusVariant::CustomAvatar {
avatar,
status,
is_ambient,
} => attach_status_overlay(
avatar,
status.as_ref(),
is_ambient,
total_size,
overlay_extra_overhang_ratio,
badge_style,
theme,
status_container_background,
),
}
}
/// Builds the brand-circle container around `icon_element`. The circle's diameter is
/// `circle_size(total)` and the icon glyph is `icon_size(total)`, with the rest going
/// to symmetric padding around the glyph.
/// The returned element is `circle_size(total)` wide; agent callers wrap it via
/// `attach_status_overlay` to occupy the full `total_size` footprint.
fn render_circle(
icon_element: Box<dyn Element>,
background: WarpThemeFill,
total_size: f32,
) -> Box<dyn Element> {
let icon = icon_size(total_size);
let padding = circle_padding(total_size);
let inner = ConstrainedBox::new(icon_element)
.with_width(icon)
.with_height(icon)
.finish();
Container::new(inner)
.with_uniform_padding(padding)
.with_background(background)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
circle_size(total_size) / 2.,
)))
.finish()
}
/// Builds the neutral circle: a full-`total_size` container with the glyph at
/// `NEUTRAL_GLYPH_RATIO * total_size`. Used for non-agent surfaces (plain terminal,
/// code, file tabs, etc.) which have no status overlay and therefore should fill the
/// requested bounding box rather than shrinking to `circle_size(total)`.
fn render_neutral_circle(
icon_element: Box<dyn Element>,
background: WarpThemeFill,
total_size: f32,
) -> Box<dyn Element> {
let glyph = total_size * NEUTRAL_GLYPH_RATIO;
let padding = (total_size - glyph) / 2.;
let inner = ConstrainedBox::new(icon_element)
.with_width(glyph)
.with_height(glyph)
.finish();
Container::new(inner)
.with_uniform_padding(padding)
.with_background(background)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(total_size / 2.)))
.finish()
}
/// Wraps a brand circle with the appropriate status overlay (badge for non-ambient runs,
/// cloud lobe for ambient runs). Both overlays are derived from `total_size`.
#[allow(clippy::too_many_arguments)]
fn attach_status_overlay(
circle: Box<dyn Element>,
status: Option<&ConversationStatus>,
is_ambient: bool,
total_size: f32,
overlay_extra_overhang_ratio: f32,
badge_style: StatusBadgeStyle,
theme: &WarpTheme,
status_container_background: WarpThemeFill,
) -> Box<dyn Element> {
if is_ambient {
render_with_cloud_status_badge(
circle,
status,
total_size,
overlay_extra_overhang_ratio,
theme,
)
} else {
render_with_optional_status_badge(
circle,
status,
total_size,
overlay_extra_overhang_ratio,
badge_style,
theme,
status_container_background,
)
}
}
/// Overlays a cloud (with the conversation status icon centered inside, if any) at
/// the bottom-right of the base circle. Used for agents running in ambient/cloud mode.
fn render_with_cloud_status_badge(
circle: Box<dyn Element>,
status: Option<&ConversationStatus>,
total_size: f32,
overlay_extra_overhang_ratio: f32,
theme: &WarpTheme,
) -> Box<dyn Element> {
let cloud_diameter = cloud_icon_size(total_size);
let cloud = ConstrainedBox::new(
WarpIcon::CloudFilled
.to_warpui_icon(theme.foreground())
.finish(),
)
.with_width(cloud_diameter)
.with_height(cloud_diameter)
.finish();
let cloud_with_status: Box<dyn Element> = match status {
Some(status) => {
let (icon, color) = status.status_icon_and_color(theme, StatusColorStyle::Cloud);
let inner = status_in_cloud_size(total_size);
let status_icon =
ConstrainedBox::new(icon.to_warpui_icon(WarpThemeFill::Solid(color)).finish())
.with_width(inner)
.with_height(inner)
.finish();
let mut stack = Stack::new().with_child(cloud);
// The CloudFilled SVG's visual center of mass sits below the container's
// geometric center (the cloud is wider at the bottom than the top), so we
// nudge the status icon down to look optically centered inside the cloud
// shape rather than the bounding box.
stack.add_positioned_child(
status_icon,
OffsetPositioning::offset_from_parent(
vec2f(0., 1.),
ParentOffsetBounds::Unbounded,
ParentAnchor::Center,
ChildAnchor::Center,
),
);
stack.finish()
}
None => cloud,
};
let cloud_offset = corner_overlay_offset(total_size, overlay_extra_overhang_ratio);
let mut stack = Stack::new().with_child(
ConstrainedBox::new(circle)
.with_width(total_size)
.with_height(total_size)
.finish(),
);
stack.add_positioned_child(
cloud_with_status,
OffsetPositioning::offset_from_parent(
vec2f(cloud_offset, cloud_offset),
ParentOffsetBounds::Unbounded,
ParentAnchor::BottomRight,
ChildAnchor::BottomRight,
),
);
ConstrainedBox::new(stack.finish())
.with_width(total_size)
.with_height(total_size)
.finish()
}
/// Adds a status badge with a cutout ring to the bottom-right of the circle.
fn render_with_optional_status_badge(
circle: Box<dyn Element>,
status: Option<&ConversationStatus>,
sizing: &IconWithStatusSizing,
theme: &GalaxyTheme,
badge_ring_background: GalaxyThemeFill,
total_size: f32,
overlay_extra_overhang_ratio: f32,
badge_style: StatusBadgeStyle,
theme: &WarpTheme,
status_container_background: WarpThemeFill,
) -> Box<dyn Element> {
let Some(status) = status else {
return circle;
// No status badge: still occupy the full `total_size` footprint so the agent
// circle (which is only `circle_size(total)` wide) sits centered in the box
// the caller reserved.
return ConstrainedBox::new(circle)
.with_width(total_size)
.with_height(total_size)
.finish();
};
let (icon, color) = status.status_icon_and_color(theme);
let badge_icon = ConstrainedBox::new(
icon.to_galaxyui_icon(GalaxyThemeFill::Solid(color))
.finish(),
)
.with_width(sizing.badge_icon_size)
.with_height(sizing.badge_icon_size)
.finish();
let badge = Container::new(badge_icon)
.with_uniform_padding(sizing.badge_padding)
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)))
let (icon, color) = status.status_icon_and_color(theme, StatusColorStyle::Standard);
let badge_icon_diameter = badge_icon_size(total_size, badge_style);
let pad = badge_padding(total_size, badge_style);
let badge_icon = ConstrainedBox::new(icon.to_galaxyui_icon(WarpThemeFill::Solid(color)).finish())
.with_width(badge_icon_diameter)
.with_height(badge_icon_diameter)
.finish();
// Cutout ring that visually separates the badge from the circle.
let inner_radius = match badge_style.inner_shape {
BadgeInnerShape::Circle => Radius::Percentage(50.),
BadgeInnerShape::RoundedSquare { radius_px } => Radius::Pixels(radius_px),
};
let badge = Container::new(badge_icon)
.with_uniform_padding(pad)
.with_corner_radius(CornerRadius::with_all(inner_radius))
.finish();
// Cutout ring around the badge; always circular (only the inner holder varies).
let badge_with_ring = Container::new(badge)
.with_uniform_padding(sizing.badge_padding)
.with_background(badge_ring_background)
.with_uniform_padding(pad)
.with_background(status_container_background)
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)))
.finish();
let circle_size = sizing.icon_size + sizing.padding * 2.;
let overall_size = sizing.overall_size_override.unwrap_or(circle_size);
let badge_corner_offset = corner_overlay_offset(total_size, overlay_extra_overhang_ratio);
let mut stack = Stack::new().with_child(
ConstrainedBox::new(circle)
.with_width(overall_size)
.with_height(overall_size)
.with_width(total_size)
.with_height(total_size)
.finish(),
);
stack.add_positioned_child(
badge_with_ring,
OffsetPositioning::offset_from_parent(
vec2f(sizing.badge_offset.0, sizing.badge_offset.1),
ParentOffsetBounds::ParentBySize,
vec2f(badge_corner_offset, badge_corner_offset),
ParentOffsetBounds::Unbounded,
ParentAnchor::BottomRight,
ChildAnchor::BottomRight,
),
);
ConstrainedBox::new(stack.finish())
.with_width(overall_size)
.with_height(overall_size)
.with_width(total_size)
.with_height(total_size)
.finish()
}
+4 -6
View File
@@ -1,11 +1,9 @@
use crate::ui_components::blended_colors;
use crate::{appearance::Appearance, ui_components::icons::Icon};
use galaxy_core::ui::theme::color::internal_colors;
use galaxy_core::ui::theme::Fill;
use galaxyui::elements::{CornerRadius, MouseState, Radius};
use galaxyui::Element;
use pathfinder_color::ColorU;
use crate::appearance::Appearance;
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
/// Shared item highlight state for left-panel style lists (file tree, global search results,
/// warp drive rows, etc.).
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
+621
View File
@@ -0,0 +1,621 @@
//! Generic, reusable JSON tree rendering component.
//!
//! Renders a `serde_json::Value` as an interactive, collapsible tree with
//! theme-driven colors.
// Callers are wired in later phases; suppress until then.
#![allow(dead_code)]
use std::collections::HashMap;
use std::sync::Arc;
use pathfinder_color::ColorU;
use galaxy_core::ui::icons::Icon;
use galaxy_core::ui::theme::color::internal_colors;
use galaxy_core::ui::theme::WarpTheme;
use warpui::elements::{
ConstrainedBox, CrossAxisAlignment, Empty, Flex, Hoverable, MainAxisSize, MouseStateHandle,
ParentElement, Shrinkable, Text,
};
use warpui::Element;
use crate::appearance::Appearance;
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/// The indent width in logical pixels per nesting level.
const INDENT_PX: f32 = 12.;
/// The icon size for chevron expanders.
const CHEVRON_SIZE: f32 = 12.;
/// The font size used for all tree rows.
const TREE_FONT_SIZE: f32 = 12.;
/// Strings longer than this character count, or containing `\n`, are elided
/// by default and can be expanded in place.
pub const LONG_STRING_THRESHOLD: usize = 120;
// ---------------------------------------------------------------------------
// PathSegment
// ---------------------------------------------------------------------------
/// A single segment of a path into a `serde_json::Value` tree.
///
/// A sequence of segments uniquely identifies any node in the tree by its
/// structural position (key in an object, index in an array).
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum PathSegment {
/// A named key in a JSON object.
Key(String),
/// A 0-based index in a JSON array.
Index(usize),
}
// ---------------------------------------------------------------------------
// JsonTreeState
// ---------------------------------------------------------------------------
/// Stores per-node expansion state for a rendered JSON tree.
///
/// State is keyed by `Vec<PathSegment>` which identifies each node by its
/// structural path in the tree. Path-keyed state is stable across
/// streaming re-parses because the path for any given node is deterministic
/// as long as the surrounding JSON structure is unchanged.
#[derive(Debug, Default, Clone)]
pub struct JsonTreeState {
/// Expansion state for object/array nodes. Absent = default (expanded at
/// depth 0, collapsed deeper).
node_expansion: HashMap<Vec<PathSegment>, bool>,
/// Expansion state for long string values. Absent = collapsed (elided).
string_expansion: HashMap<Vec<PathSegment>, bool>,
}
impl JsonTreeState {
/// Returns whether the node at `path` and `depth` should be expanded.
///
/// Default behaviour: expanded at depth 0, collapsed at depth 1+. An
/// explicit entry in the state map always takes precedence.
pub fn is_expanded(&self, path: &[PathSegment], depth: usize) -> bool {
if let Some(&explicit) = self.node_expansion.get(path) {
return explicit;
}
depth == 0
}
/// Returns whether the long string at `path` should be expanded.
pub fn is_string_expanded(&self, path: &[PathSegment]) -> bool {
self.string_expansion.get(path).copied().unwrap_or(false)
}
/// Toggles the expansion state of the node at `path`.
///
/// If no explicit state exists, the new state is the inverse of the
/// default (derived from depth). Callers must pass `depth` so we know
/// what the default would have been.
pub fn toggle(&mut self, path: &[PathSegment], depth: usize) {
let current = self.is_expanded(path, depth);
self.node_expansion.insert(path.to_vec(), !current);
}
/// Toggles the expansion state of the long string at `path`.
pub fn toggle_string(&mut self, path: &[PathSegment]) {
let current = self.is_string_expanded(path);
self.string_expansion.insert(path.to_vec(), !current);
}
}
// ---------------------------------------------------------------------------
// JsonTreeColors
// ---------------------------------------------------------------------------
/// Pre-resolved colors for each JSON value category, sourced from the active
/// `WarpTheme`. Build this once per render from `JsonTreeColors::from_theme`.
#[derive(Debug, Clone, Copy)]
pub struct JsonTreeColors {
/// Color for object/array keys and array indices.
pub key: ColorU,
/// Color for string values.
pub string: ColorU,
/// Color for number values.
pub number: ColorU,
/// Color for boolean values.
pub bool: ColorU,
/// Color for null values.
pub null: ColorU,
/// Color for type/size annotations (`{} 4 keys`) and punctuation.
pub annotation: ColorU,
}
impl JsonTreeColors {
/// Resolve colors from a `WarpTheme` and its background color.
///
/// Each JSON value type maps to a visually distinct ANSI foreground color
/// so that keys, strings, numbers, booleans, and null are easy to
/// distinguish at a glance. Annotations and punctuation use the theme's
/// subdued text color so they don't compete with value content.
pub fn from_theme(theme: &WarpTheme) -> Self {
let bg = theme.background();
Self {
key: theme.ansi_fg_cyan(),
string: theme.ansi_fg_green(),
number: theme.ansi_fg_yellow(),
bool: theme.ansi_fg_magenta(),
null: internal_colors::text_disabled(theme, bg),
annotation: internal_colors::text_sub(theme, bg),
}
}
}
// ---------------------------------------------------------------------------
// Annotation helpers
// ---------------------------------------------------------------------------
/// Formats the annotation for a collapsible container node, e.g. `{} 3 keys`.
pub fn format_object_annotation(key_count: usize) -> String {
match key_count {
0 => "{} 0 keys".to_string(),
1 => "{} 1 key".to_string(),
n => format!("{{}} {} keys", n),
}
}
/// Formats the annotation for a collapsible array node, e.g. `[] 2 items`.
pub fn format_array_annotation(item_count: usize) -> String {
match item_count {
0 => "[] 0 items".to_string(),
1 => "[] 1 item".to_string(),
n => format!("[] {} items", n),
}
}
// ---------------------------------------------------------------------------
// Long-string helpers
// ---------------------------------------------------------------------------
/// Returns `true` when a string should be elided by default:
/// - Length > `LONG_STRING_THRESHOLD`, OR
/// - Contains a newline character.
pub fn is_long_string(s: &str) -> bool {
s.len() > LONG_STRING_THRESHOLD || s.contains('\n')
}
/// Formats a `serde_json::Number` as a string, rendering whole-valued floats
/// as integers (e.g. `5.0` → `"5"`, `3.14` → `"3.14"`).
pub fn format_number(n: &serde_json::Number) -> String {
if let Some(i) = n.as_i64() {
return i.to_string();
}
if let Some(u) = n.as_u64() {
return u.to_string();
}
if let Some(f) = n.as_f64() {
// Display whole-valued floats without the `.0` suffix.
if f.fract() == 0.0 && f.is_finite() {
return format!("{}", f as i64);
}
return format!("{}", f);
}
n.to_string()
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
/// Builds a `Box<dyn Element>` that renders `root` as an interactive,
/// collapsible JSON tree.
///
/// # Parameters
/// - `root` — the JSON value to render.
/// - `root_label` — optional label printed above the tree (e.g. "Request").
/// - `state` — current expansion state; queried on every render.
/// - `colors` — pre-resolved theme colors.
/// - `on_toggle` — called with the path of a clicked collapsible node.
/// - `on_copy_json` — called with the path and value when "Copy JSON" is
/// activated via right-click on a row in the tree.
/// - `appearance` — provides font families and sizes.
pub fn render_json_tree(
root: &serde_json::Value,
root_label: Option<&str>,
state: &JsonTreeState,
colors: &JsonTreeColors,
on_toggle: Arc<dyn Fn(Vec<PathSegment>, usize) + Send + Sync>,
on_copy_json: Arc<dyn Fn(Vec<PathSegment>, serde_json::Value) + Send + Sync>,
appearance: &Appearance,
) -> Box<dyn Element> {
let font_family = appearance.ui_font_family();
let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
// Optional section label.
if let Some(label) = root_label {
let label_text = Text::new_inline(label.to_owned(), font_family, TREE_FONT_SIZE)
.with_color(colors.annotation)
.soft_wrap(false)
.finish();
column.add_child(label_text);
}
// Render the root node and all visible descendants.
render_value(
root,
vec![],
0,
None,
state,
colors,
&on_toggle,
&on_copy_json,
font_family,
&mut column,
);
column.finish()
}
/// Recursively renders a JSON value into `column`, producing one row per
/// visible node.
///
/// `label` is `Some("key")` for object members and `Some("0")` for array
/// elements; it is `None` for the root call.
#[allow(clippy::too_many_arguments)]
fn render_value(
value: &serde_json::Value,
path: Vec<PathSegment>,
depth: usize,
label: Option<String>,
state: &JsonTreeState,
colors: &JsonTreeColors,
on_toggle: &Arc<dyn Fn(Vec<PathSegment>, usize) + Send + Sync>,
on_copy_json: &Arc<dyn Fn(Vec<PathSegment>, serde_json::Value) + Send + Sync>,
font_family: warpui::fonts::FamilyId,
column: &mut Flex,
) {
match value {
serde_json::Value::Object(map) => {
render_container_node(
&format_object_annotation(map.len()),
map.len(),
value.clone(),
path.clone(),
depth,
label,
state,
colors,
on_toggle,
on_copy_json,
font_family,
column,
);
if state.is_expanded(&path, depth) {
for (key, child_value) in map {
let child_path = {
let mut p = path.clone();
p.push(PathSegment::Key(key.clone()));
p
};
render_value(
child_value,
child_path,
depth + 1,
Some(key.clone()),
state,
colors,
on_toggle,
on_copy_json,
font_family,
column,
);
}
}
}
serde_json::Value::Array(arr) => {
render_container_node(
&format_array_annotation(arr.len()),
arr.len(),
value.clone(),
path.clone(),
depth,
label,
state,
colors,
on_toggle,
on_copy_json,
font_family,
column,
);
if state.is_expanded(&path, depth) {
for (idx, child_value) in arr.iter().enumerate() {
let child_path = {
let mut p = path.clone();
p.push(PathSegment::Index(idx));
p
};
render_value(
child_value,
child_path,
depth + 1,
Some(idx.to_string()),
state,
colors,
on_toggle,
on_copy_json,
font_family,
column,
);
}
}
}
serde_json::Value::String(s) => {
render_scalar_row(
path,
depth,
label,
build_string_value_text(s, colors, font_family),
value.clone(),
colors,
on_copy_json,
font_family,
column,
);
}
serde_json::Value::Number(n) => {
let text = Text::new_inline(format_number(n), font_family, TREE_FONT_SIZE)
.with_color(colors.number)
.soft_wrap(false)
.finish();
render_scalar_row(
path,
depth,
label,
text,
value.clone(),
colors,
on_copy_json,
font_family,
column,
);
}
serde_json::Value::Bool(b) => {
let display = if *b { "true" } else { "false" };
let text = Text::new_inline(display, font_family, TREE_FONT_SIZE)
.with_color(colors.bool)
.soft_wrap(false)
.finish();
render_scalar_row(
path,
depth,
label,
text,
value.clone(),
colors,
on_copy_json,
font_family,
column,
);
}
serde_json::Value::Null => {
let text = Text::new_inline("null", font_family, TREE_FONT_SIZE)
.with_color(colors.null)
.soft_wrap(false)
.finish();
render_scalar_row(
path,
depth,
label,
text,
value.clone(),
colors,
on_copy_json,
font_family,
column,
);
}
}
}
/// Builds the value text for a string, applying long-string elision if needed.
/// Elided strings show a truncated preview with an ellipsis.
fn build_string_value_text(
s: &str,
colors: &JsonTreeColors,
font_family: warpui::fonts::FamilyId,
) -> Box<dyn Element> {
if is_long_string(s) {
// Show a preview: first line, capped at threshold characters.
let first_line = s.lines().next().unwrap_or("");
let preview: String = first_line.chars().take(LONG_STRING_THRESHOLD).collect();
let display = format!("\"{}\"", preview);
Text::new_inline(display, font_family, TREE_FONT_SIZE)
.with_color(colors.string)
.soft_wrap(false)
.finish()
} else {
let display = format!("\"{}\"", s);
Text::new_inline(display, font_family, TREE_FONT_SIZE)
.with_color(colors.string)
.soft_wrap(false)
.finish()
}
}
/// Renders a collapsible object/array node row with a chevron expander.
///
/// Empty containers (0 keys/items) are non-interactive (no chevron, no click).
#[allow(clippy::too_many_arguments)]
fn render_container_node(
annotation: &str,
child_count: usize,
value_for_copy: serde_json::Value,
path: Vec<PathSegment>,
depth: usize,
label: Option<String>,
state: &JsonTreeState,
colors: &JsonTreeColors,
on_toggle: &Arc<dyn Fn(Vec<PathSegment>, usize) + Send + Sync>,
on_copy_json: &Arc<dyn Fn(Vec<PathSegment>, serde_json::Value) + Send + Sync>,
font_family: warpui::fonts::FamilyId,
column: &mut Flex,
) {
// Empty containers have no chevron and are not interactive.
let is_empty = child_count == 0;
let is_expanded = state.is_expanded(&path, depth);
let mut row = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Center);
// Indent spacer.
row.add_child(indent_spacer(depth));
// Chevron or placeholder.
if is_empty {
// Empty containers: no chevron, render a same-width placeholder.
row.add_child(
ConstrainedBox::new(Empty::new().finish())
.with_width(CHEVRON_SIZE)
.with_height(CHEVRON_SIZE)
.finish(),
);
} else {
let icon = if is_expanded {
Icon::ChevronDown
} else {
Icon::ChevronRight
};
let icon_color = colors.annotation;
row.add_child(
ConstrainedBox::new(
icon.to_warpui_icon(galaxy_core::ui::theme::Fill::Solid(icon_color))
.finish(),
)
.with_width(CHEVRON_SIZE)
.with_height(CHEVRON_SIZE)
.finish(),
);
}
// Key/index label (if inside an object or array).
if let Some(ref key) = label {
let key_text = Text::new_inline(format!("{}: ", key), font_family, TREE_FONT_SIZE)
.with_color(colors.key)
.soft_wrap(false)
.finish();
row.add_child(key_text);
}
// Type annotation.
row.add_child(
Shrinkable::new(
1.,
Text::new_inline(annotation.to_owned(), font_family, TREE_FONT_SIZE)
.with_color(colors.annotation)
.soft_wrap(false)
.finish(),
)
.finish(),
);
let row_element = row.finish();
// Wrap in a Hoverable for click (toggle) and right-click (copy JSON).
if is_empty {
// Non-interactive.
column.add_child(row_element);
} else {
let on_toggle_clone = on_toggle.clone();
let path_for_toggle = path.clone();
let on_copy_clone = on_copy_json.clone();
let path_for_copy = path.clone();
let state_handle = MouseStateHandle::default();
let row_for_hover = row_element;
let hoverable = Hoverable::new(state_handle, move |_| row_for_hover)
.on_click(move |_ctx, _app, _pos| {
on_toggle_clone(path_for_toggle.clone(), depth);
})
.on_right_click(move |_ctx, _app, _pos| {
on_copy_clone(path_for_copy.clone(), value_for_copy.clone());
})
.finish();
column.add_child(hoverable);
}
}
/// Renders a scalar value row (string, number, bool, null).
#[allow(clippy::too_many_arguments)]
fn render_scalar_row(
path: Vec<PathSegment>,
depth: usize,
label: Option<String>,
value_element: Box<dyn Element>,
value_for_copy: serde_json::Value,
colors: &JsonTreeColors,
on_copy_json: &Arc<dyn Fn(Vec<PathSegment>, serde_json::Value) + Send + Sync>,
font_family: warpui::fonts::FamilyId,
column: &mut Flex,
) {
let mut row = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Center);
// Indent spacer.
row.add_child(indent_spacer(depth));
// Placeholder where chevron would be, to keep column alignment.
row.add_child(
ConstrainedBox::new(Empty::new().finish())
.with_width(CHEVRON_SIZE)
.with_height(CHEVRON_SIZE)
.finish(),
);
// Key/index label (if inside an object or array).
if let Some(ref key) = label {
let key_text = Text::new_inline(format!("{}: ", key), font_family, TREE_FONT_SIZE)
.with_color(colors.key)
.soft_wrap(false)
.finish();
row.add_child(key_text);
}
// The typed value element.
row.add_child(Shrinkable::new(1., value_element).finish());
// Wrap in a Hoverable for right-click (copy JSON).
let on_copy_clone = on_copy_json.clone();
let path_for_copy = path.clone();
let state_handle = MouseStateHandle::default();
let row_element = row.finish();
let hoverable = Hoverable::new(state_handle, move |_| row_element)
.on_right_click(move |_ctx, _app, _pos| {
on_copy_clone(path_for_copy.clone(), value_for_copy.clone());
})
.finish();
column.add_child(hoverable);
}
/// Returns a fixed-width transparent spacer for the given indentation depth.
fn indent_spacer(depth: usize) -> Box<dyn Element> {
if depth == 0 {
Empty::new().finish()
} else {
ConstrainedBox::new(Empty::new().finish())
.with_width(depth as f32 * INDENT_PX)
.finish()
}
}
#[cfg(test)]
#[path = "json_tree_tests.rs"]
mod tests;
+408
View File
@@ -0,0 +1,408 @@
//! Pure-logic unit tests for the `json_tree` component.
//!
//! These tests cover only the data-layer functions and types: annotation
//! formatting, long-string detection, state management, and value rendering.
//! They do not exercise the element-construction layer (which requires a
//! running UI framework).
use crate::ui_components::json_tree::{
format_array_annotation, format_number, format_object_annotation, is_long_string,
JsonTreeState, PathSegment, LONG_STRING_THRESHOLD,
};
// -----------------------------------------------------------------------
// Annotation labels
// -----------------------------------------------------------------------
#[test]
fn object_annotation_zero_keys() {
assert_eq!(format_object_annotation(0), "{} 0 keys");
}
#[test]
fn object_annotation_one_key() {
assert_eq!(format_object_annotation(1), "{} 1 key");
}
#[test]
fn object_annotation_n_keys() {
assert_eq!(format_object_annotation(5), "{} 5 keys");
assert_eq!(format_object_annotation(100), "{} 100 keys");
}
#[test]
fn array_annotation_zero_items() {
assert_eq!(format_array_annotation(0), "[] 0 items");
}
#[test]
fn array_annotation_one_item() {
assert_eq!(format_array_annotation(1), "[] 1 item");
}
#[test]
fn array_annotation_n_items() {
assert_eq!(format_array_annotation(3), "[] 3 items");
assert_eq!(format_array_annotation(99), "[] 99 items");
}
// -----------------------------------------------------------------------
// Long-string detection
// -----------------------------------------------------------------------
#[test]
fn short_string_below_threshold_is_not_long() {
let s = "a".repeat(LONG_STRING_THRESHOLD - 1);
assert!(!is_long_string(&s));
}
#[test]
fn string_at_exactly_threshold_is_not_long() {
let s = "a".repeat(LONG_STRING_THRESHOLD);
assert!(!is_long_string(&s));
}
#[test]
fn string_above_threshold_is_long() {
let s = "a".repeat(LONG_STRING_THRESHOLD + 1);
assert!(is_long_string(&s));
}
#[test]
fn multiline_string_is_long_regardless_of_char_count() {
// Even a short string with a newline is treated as long.
assert!(is_long_string("hello\nworld"));
// Single-char newline is still long.
assert!(is_long_string("\n"));
}
#[test]
fn empty_string_is_not_long() {
assert!(!is_long_string(""));
}
// -----------------------------------------------------------------------
// JsonTreeState — toggle independence
// -----------------------------------------------------------------------
#[test]
fn toggle_one_path_leaves_other_paths_unchanged() {
let path_a = vec![PathSegment::Key("a".to_string())];
let path_b = vec![PathSegment::Key("b".to_string())];
let mut state = JsonTreeState::default();
// Both paths start at default: expanded at depth 0.
assert!(state.is_expanded(&path_a, 0));
assert!(state.is_expanded(&path_b, 0));
// Toggle path A.
state.toggle(&path_a, 0);
// A is now collapsed.
assert!(!state.is_expanded(&path_a, 0));
// B is still at its default (expanded at depth 0).
assert!(state.is_expanded(&path_b, 0));
}
#[test]
fn toggle_is_idempotent_across_two_calls() {
let path = vec![PathSegment::Index(0)];
let mut state = JsonTreeState::default();
// Depth-1 node defaults to collapsed.
assert!(!state.is_expanded(&path, 1));
// First toggle: collapsed → expanded.
state.toggle(&path, 1);
assert!(state.is_expanded(&path, 1));
// Second toggle: expanded → collapsed again.
state.toggle(&path, 1);
assert!(!state.is_expanded(&path, 1));
}
#[test]
fn toggle_nested_path_independent_of_parent() {
let parent = vec![PathSegment::Key("parent".to_string())];
let child = vec![
PathSegment::Key("parent".to_string()),
PathSegment::Key("child".to_string()),
];
let mut state = JsonTreeState::default();
// Both are at depth 1, so default is collapsed.
assert!(!state.is_expanded(&parent, 1));
assert!(!state.is_expanded(&child, 1));
// Toggle parent only.
state.toggle(&parent, 1);
assert!(state.is_expanded(&parent, 1));
assert!(!state.is_expanded(&child, 1));
}
// -----------------------------------------------------------------------
// JsonTreeState — long-string expansion (toggle_string / is_string_expanded)
// -----------------------------------------------------------------------
#[test]
fn string_collapsed_by_default() {
let state = JsonTreeState::default();
let path = vec![PathSegment::Key("summary".to_string())];
assert!(!state.is_string_expanded(&path));
}
#[test]
fn toggle_string_expands_then_collapses() {
let path = vec![PathSegment::Key("body".to_string())];
let mut state = JsonTreeState::default();
// Default: collapsed.
assert!(!state.is_string_expanded(&path));
// First toggle: collapsed → expanded.
state.toggle_string(&path);
assert!(state.is_string_expanded(&path));
// Second toggle: expanded → collapsed.
state.toggle_string(&path);
assert!(!state.is_string_expanded(&path));
}
#[test]
fn toggle_string_is_independent_of_node_expansion() {
let path = vec![PathSegment::Key("note".to_string())];
let mut state = JsonTreeState::default();
// Toggling a string does not affect node expansion state for the same path.
state.toggle_string(&path);
assert!(state.is_string_expanded(&path));
// Node expansion at depth 0 is still the default (expanded).
assert!(state.is_expanded(&path, 0));
// Node expansion at depth 1 is still the default (collapsed).
assert!(!state.is_expanded(&path, 1));
}
// -----------------------------------------------------------------------
// JsonTreeState — default expansion
// -----------------------------------------------------------------------
#[test]
fn depth_0_defaults_to_expanded() {
let state = JsonTreeState::default();
let path = vec![];
assert!(state.is_expanded(&path, 0));
}
#[test]
fn depth_1_defaults_to_collapsed() {
let state = JsonTreeState::default();
let path = vec![PathSegment::Key("field".to_string())];
assert!(!state.is_expanded(&path, 1));
}
#[test]
fn depth_2_defaults_to_collapsed() {
let state = JsonTreeState::default();
let path = vec![
PathSegment::Key("a".to_string()),
PathSegment::Key("b".to_string()),
];
assert!(!state.is_expanded(&path, 2));
}
// -----------------------------------------------------------------------
// Empty container — no children to render
// -----------------------------------------------------------------------
#[test]
fn empty_object_annotation_is_correct() {
// An empty object should show "0 keys" regardless of internal state.
assert_eq!(format_object_annotation(0), "{} 0 keys");
}
#[test]
fn empty_array_annotation_is_correct() {
assert_eq!(format_array_annotation(0), "[] 0 items");
}
// -----------------------------------------------------------------------
// Integer rendering
// -----------------------------------------------------------------------
#[test]
fn whole_float_displays_as_integer() {
// serde_json represents JSON `5` as Number(5), but it can also appear
// as `5.0` in some contexts. The format_number helper must strip the
// `.0` so it displays as "5".
let n: serde_json::Number = serde_json::from_str("5").unwrap();
assert_eq!(format_number(&n), "5");
}
#[test]
fn negative_integer_displays_correctly() {
let n: serde_json::Number = serde_json::from_str("-42").unwrap();
assert_eq!(format_number(&n), "-42");
}
#[test]
fn float_with_fraction_displays_with_decimal() {
let n: serde_json::Number = serde_json::from_str("3.14").unwrap();
let formatted = format_number(&n);
// Must contain a decimal point; exact representation depends on precision.
assert!(formatted.contains('.'), "expected decimal in {formatted}");
}
#[test]
fn large_integer_displays_without_scientific_notation() {
let n: serde_json::Number = serde_json::from_str("1000000").unwrap();
assert_eq!(format_number(&n), "1000000");
}
// -----------------------------------------------------------------------
// Duplicate object keys
// -----------------------------------------------------------------------
#[test]
fn duplicate_object_keys_not_silently_dropped() {
// JSON allows duplicate keys in raw text; serde_json resolves them by
// retaining the last value for each key. Our rendering code must not
// drop any additional entries beyond what the parser already resolved.
//
// Verify that for a parsed object, format_object_annotation reports the
// exact count that serde_json produced — no further filtering.
let v: serde_json::Value = serde_json::from_str(r#"{"a": 1, "a": 2}"#).unwrap();
let map = v.as_object().expect("expected object");
// serde_json keeps the last value; our annotation reflects that faithfully.
let annotation = format_object_annotation(map.len());
assert!(
!annotation.is_empty(),
"annotation must be non-empty for any parsed object"
);
// The annotation count matches exactly what serde_json gave us.
assert_eq!(annotation, format_object_annotation(map.len()));
// The key still exists — it was not silently removed by the renderer.
assert!(map.contains_key("a"), "key 'a' was silently dropped");
}
#[test]
fn multi_key_object_all_entries_preserved() {
// Verifies that iterating over a serde_json Map (as render_value does)
// does not drop any entries. A three-key object must produce a
// three-key annotation.
let v = serde_json::json!({"x": 1, "y": 2, "z": 3});
let map = v.as_object().expect("expected object");
assert_eq!(map.len(), 3);
assert_eq!(format_object_annotation(map.len()), "{} 3 keys");
for key in ["x", "y", "z"] {
assert!(map.contains_key(key), "key {key:?} was missing");
}
}
// -----------------------------------------------------------------------
// mcp_result_to_renderable
// -----------------------------------------------------------------------
#[test]
fn mcp_result_success_with_structured_content_returns_tree() {
use crate::ai::agent::CallMCPToolResult;
use crate::ai::blocklist::inline_action::requested_command::{
mcp_result_to_renderable, McpRenderable,
};
let value = serde_json::json!({"count": 42, "files": ["a.rs", "b.rs"]});
let result = rmcp::model::CallToolResult::structured(value.clone());
let renderable = mcp_result_to_renderable(&CallMCPToolResult::Success { result });
match renderable {
McpRenderable::Tree(v) => assert_eq!(v, value),
_ => panic!("expected Tree variant"),
}
}
#[test]
fn mcp_result_success_with_json_text_content_returns_parsed_tree() {
use crate::ai::blocklist::inline_action::requested_command::{
mcp_result_to_renderable, McpRenderable,
};
let json_str = r#"{"status": "ok", "value": 7}"#;
let content = vec![rmcp::model::Content::text(json_str)];
let result = rmcp::model::CallToolResult::success(content);
let renderable = mcp_result_to_renderable(&CallMCPToolResult::Success { result });
let expected: serde_json::Value = serde_json::from_str(json_str).unwrap();
match renderable {
McpRenderable::Tree(v) => assert_eq!(v, expected),
_ => panic!("expected Tree variant with parsed JSON"),
}
}
#[test]
fn mcp_result_success_with_non_json_text_returns_string_tree() {
use crate::ai::blocklist::inline_action::requested_command::{
mcp_result_to_renderable, McpRenderable,
};
let plain_text = "just some plain text output";
let content = vec![rmcp::model::Content::text(plain_text)];
let result = rmcp::model::CallToolResult::success(content);
let renderable = mcp_result_to_renderable(&CallMCPToolResult::Success { result });
match renderable {
McpRenderable::Tree(serde_json::Value::String(s)) => {
assert_eq!(s, plain_text);
}
_ => panic!("expected Tree(String) variant"),
}
}
#[test]
fn mcp_result_error_returns_error_variant() {
use crate::ai::blocklist::inline_action::requested_command::{
mcp_result_to_renderable, McpRenderable,
};
let msg = "tool not found".to_string();
let renderable = mcp_result_to_renderable(&CallMCPToolResult::Error(msg.clone()));
match renderable {
McpRenderable::Error(e) => assert_eq!(e, msg),
_ => panic!("expected Error variant"),
}
}
#[test]
fn mcp_result_cancelled_returns_cancelled_variant() {
use crate::ai::blocklist::inline_action::requested_command::{
mcp_result_to_renderable, McpRenderable,
};
let renderable = mcp_result_to_renderable(&CallMCPToolResult::Cancelled);
assert!(matches!(renderable, McpRenderable::Cancelled));
}
// -----------------------------------------------------------------------
// Path segment equality (required for HashMap key correctness)
// -----------------------------------------------------------------------
#[test]
fn path_segments_hash_and_eq_correctly() {
use std::collections::HashMap;
let mut map: HashMap<Vec<PathSegment>, bool> = HashMap::new();
let path_key = vec![PathSegment::Key("foo".to_string())];
let path_idx = vec![PathSegment::Index(0)];
map.insert(path_key.clone(), true);
map.insert(path_idx.clone(), false);
assert!(map[&path_key]);
assert!(!map[&path_idx]);
// A different path does not collide.
let path_other = vec![PathSegment::Key("bar".to_string())];
assert!(!map.contains_key(&path_other));
}
+2 -2
View File
@@ -1,4 +1,4 @@
use crate::appearance::Appearance;
use pathfinder_geometry::vector::{vec2f, Vector2F};
use galaxyui::elements::{
ChildAnchor, ChildView, MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement,
ParentOffsetBounds, Stack,
@@ -6,10 +6,10 @@ use galaxyui::elements::{
use galaxyui::platform::Cursor;
use galaxyui::ui_components::components::{UiComponent, UiComponentStyles};
use galaxyui::{AppContext, Element, EventContext, View, ViewHandle};
use pathfinder_geometry::vector::{vec2f, Vector2F};
use super::buttons::{highlight, icon_button};
use super::icons::Icon;
use crate::appearance::Appearance;
#[derive(Clone, Copy)]
pub enum MenuDirection {
+2
View File
@@ -2,6 +2,7 @@
//! used for the apps design (our buttons with styling, headers and panels etc.) as well definition
//! of colors (aka blended colors from the figma designs derived from Warp theme) and icons used
//! within the app.
pub(crate) mod agent_icon;
pub(crate) mod avatar;
pub(crate) mod blended_colors;
pub(crate) mod breadcrumb;
@@ -10,6 +11,7 @@ pub(crate) mod color_dot;
pub(crate) mod dialog;
pub(crate) mod icon_with_status;
pub(crate) mod item_highlight;
pub mod json_tree;
pub(crate) mod menu_button;
pub(crate) mod red_notification_dot;
pub(crate) mod render_file_search_row;
+9 -10
View File
@@ -1,14 +1,13 @@
use crate::appearance::Appearance;
use galaxy_core::ui::theme::AnsiColorIdentifier;
use galaxyui::{
elements::{
ChildAnchor, ConstrainedBox, Container, CornerRadius, Empty, Fill, OffsetPositioning,
ParentAnchor, ParentElement as _, ParentOffsetBounds, Radius, Stack,
},
ui_components::components::UiComponentStyles,
Element,
};
use pathfinder_geometry::vector::vec2f;
use galaxy_core::ui::theme::AnsiColorIdentifier;
use galaxyui::elements::{
ChildAnchor, ConstrainedBox, Container, CornerRadius, Empty, Fill, OffsetPositioning,
ParentAnchor, ParentElement as _, ParentOffsetBounds, Radius, Stack,
};
use galaxyui::ui_components::components::UiComponentStyles;
use galaxyui::Element;
use crate::appearance::Appearance;
pub struct RedNotificationDot {}
+21 -15
View File
@@ -1,15 +1,18 @@
//! File search row rendering components.
//!
//! This module provides UI components for rendering file and directory search results
//! in search interfaces. It handles the display of file names with their parent paths,
//! supports fuzzy match highlighting, and intelligently truncates long paths while
//! preserving important information.
//! This module provides UI components for rendering file and directory search
//! results in search interfaces. It handles the display of file names with
//! their parent paths and supports fuzzy match highlighting.
//!
//! The main functionality includes:
//! - Rendering file/directory names with optional path context
//! - Highlighting fuzzy match results in both filename and path portions
//! - Smart truncation of long file paths with ellipsis
//! - Responsive layout that adapts to different highlight states
//! Two truncation mechanisms are available:
//! - An optional combined character-count cap (`max_combined_length`) that
//! pre-truncates the path's trailing characters with `...`. Useful for very
//! compact UIs.
//! - Pixel-aware clipping by the text layout engine, which fades or renders a
//! leading `…` when the row is too narrow to fit the full path. This is the
//! default for callers that pass `max_combined_length: None`.
use std::path::Path;
use fuzzy_match::FuzzyMatchResult;
use galaxy_core::ui::theme::Fill;
@@ -17,14 +20,12 @@ use galaxyui::elements::{
Container, CrossAxisAlignment, Flex, Highlight, MainAxisSize, ParentElement, Shrinkable, Text,
};
use galaxyui::fonts::{Properties, Weight};
use galaxyui::text_layout::ClipConfig;
use galaxyui::{AppContext, Element};
use std::path::Path;
use galaxyui::text_layout::{ClipConfig, ClipDirection, ClipStyle};
use galaxyui::{AppContext, Element, SingletonEntity};
use crate::appearance::Appearance;
use crate::search::ai_context_menu::safe_truncate;
use crate::search::ItemHighlightState;
use galaxyui::SingletonEntity;
pub const MAX_COMBINED_LENGTH: usize = 55;
@@ -178,12 +179,17 @@ pub fn render_file_search_row(
);
}
// Create path text with lighter color and highlights
// Create path text with lighter color and highlights. Clipping happens at the
// leading edge with a literal `…` so the trailing (more informative) directories
// remain visible when the row is too narrow to show the full path.
let path_text = if !path_display.is_empty() {
let mut path_text =
Text::new_inline(path_display, appearance.ui_font_family(), path_font_size)
.with_color(path_color)
.with_clip(ClipConfig::start())
.with_clip(ClipConfig {
direction: ClipDirection::Start,
style: ClipStyle::Ellipsis,
})
.soft_wrap(false);
if !path_highlights.is_empty() {
+5 -9
View File
@@ -1,14 +1,10 @@
use galaxy_core::ui::appearance::Appearance;
use galaxyui::{
elements::{
Border, Container, CrossAxisAlignment, Element, Empty, Fill, Flex, MouseStateHandle,
ParentElement,
},
ui_components::{
button::ButtonVariant,
components::{Coords, UiComponent, UiComponentStyles},
},
use galaxyui::elements::{
Border, Container, CrossAxisAlignment, Element, Empty, Fill, Flex, MouseStateHandle,
ParentElement,
};
use warpui::ui_components::button::ButtonVariant;
use warpui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use crate::ui_components::blended_colors;