first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -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;
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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.;
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user