240 lines
8.7 KiB
Rust
240 lines
8.7 KiB
Rust
#![allow(dead_code)]
|
|
|
|
//! This module contains common utilities for rendering Blocklist AI UI.
|
|
use std::sync::LazyLock;
|
|
|
|
use galaxy_core::ui::appearance::Appearance;
|
|
use galaxyui::elements::{
|
|
ChildAnchor, ConstrainedBox, Container, CrossAxisAlignment, Flex, Hoverable, MainAxisAlignment,
|
|
MainAxisSize, MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement,
|
|
ParentOffsetBounds, Stack,
|
|
};
|
|
use galaxyui::fonts::Weight;
|
|
use galaxyui::ui_components::button::ButtonVariant;
|
|
use galaxyui::ui_components::components::{UiComponent, UiComponentStyles};
|
|
use galaxyui::ui_components::text::Span;
|
|
use galaxyui::{AppContext, Element, EntityId, EventContext, SingletonEntity};
|
|
use pathfinder_color::ColorU;
|
|
use pathfinder_geometry::vector::vec2f;
|
|
|
|
use crate::themes::theme::{AnsiColorIdentifier, Fill, GalaxyTheme, WarpTheme};
|
|
use crate::ui_components::icons::Icon;
|
|
|
|
const PROVIDER_BUTTON_ICON_SIZE: f32 = 14.;
|
|
const PROVIDER_BUTTON_ICON_TEXT_GAP: f32 = 8.;
|
|
|
|
/// Text to use as a label throughout the app for user interactions that will attach selected
|
|
/// block(s) or text selections to a new AI query.
|
|
pub static ATTACH_AS_AGENT_MODE_CONTEXT_TEXT: LazyLock<&'static str> =
|
|
LazyLock::new(|| "Attach as agent context");
|
|
|
|
/// Label we use for the the command palette action to create a new local Oz agent pane.
|
|
pub static NEW_AGENT_PANE_LABEL: LazyLock<&'static str> = LazyLock::new(|| "New Agent Pane");
|
|
|
|
/// Claude/Anthropic brand color (official brand orange #D97757).
|
|
/// Reference: https://github.com/anthropics/skills/blob/main/skills/brand-guidelines/SKILL.md
|
|
pub const CLAUDE_ORANGE: ColorU = ColorU {
|
|
r: 0xD9,
|
|
g: 0x77,
|
|
b: 0x57,
|
|
a: 0xFF,
|
|
};
|
|
|
|
/// Returns the color to be used for various AI signifiers
|
|
/// input with AI mode).
|
|
pub fn ai_brand_color(theme: &GalaxyTheme) -> ColorU {
|
|
AnsiColorIdentifier::Magenta
|
|
.to_ansi_color(&theme.terminal_colors().normal)
|
|
.into()
|
|
}
|
|
|
|
/// Returns the color to be used for error UI throughout Agent Mode (like the "request limit
|
|
/// exceeded" chip).
|
|
pub fn error_color(theme: &GalaxyTheme) -> ColorU {
|
|
AnsiColorIdentifier::Red
|
|
.to_ansi_color(&theme.terminal_colors().normal)
|
|
.into()
|
|
}
|
|
|
|
/// Returns the AI icon element to be rendered in AI output blocks and the terminal input when in
|
|
/// AI mode. Takes a color parameter as the solid fill for the icon. We use [ai_brand_color] in most
|
|
/// cases.
|
|
pub fn render_ai_agent_mode_icon(app: &AppContext, color: impl Into<Fill>) -> Box<dyn Element> {
|
|
render_input_icon(Icon::AgentMode, color.into(), app)
|
|
}
|
|
|
|
/// Returns the icon element to be rendered in the terminal input when
|
|
/// the user is making a follow up AI query in an existing conversation. Takes a color parameter as the solid fill for the icon.
|
|
pub fn render_ai_follow_up_icon(
|
|
mouse_state: MouseStateHandle,
|
|
app: &AppContext,
|
|
) -> Box<dyn Element> {
|
|
let appearance = Appearance::as_ref(app);
|
|
Hoverable::new(mouse_state, |state| {
|
|
let mut stack = Stack::new().with_child(render_input_icon(
|
|
Icon::CornerRight,
|
|
appearance.theme().foreground(),
|
|
app,
|
|
));
|
|
if state.is_hovered() {
|
|
let tooltip_background = appearance.theme().tooltip_background();
|
|
let tool_tip = appearance
|
|
.ui_builder()
|
|
.tool_tip("Follow up with existing conversation".to_owned())
|
|
.with_style(UiComponentStyles {
|
|
font_size: Some(12.),
|
|
background: Some(galaxyui::elements::Fill::Solid(tooltip_background)),
|
|
font_color: Some(appearance.theme().background().into_solid()),
|
|
..Default::default()
|
|
});
|
|
stack.add_positioned_overlay_child(
|
|
tool_tip.build().finish(),
|
|
OffsetPositioning::offset_from_parent(
|
|
vec2f(0., -4.),
|
|
ParentOffsetBounds::WindowByPosition,
|
|
ParentAnchor::TopLeft,
|
|
ChildAnchor::BottomLeft,
|
|
),
|
|
);
|
|
}
|
|
stack.finish()
|
|
})
|
|
.finish()
|
|
}
|
|
|
|
fn render_input_icon(icon: Icon, color: Fill, app: &AppContext) -> Box<dyn Element> {
|
|
// Since the icon is rendered next to monospace text content, its size should scale to
|
|
// based on the current font size -- specifically, its height must match the editor text line
|
|
// height.
|
|
let icon_size = ai_indicator_height(app);
|
|
ConstrainedBox::new(
|
|
Container::new(icon.to_galaxyui_icon(color).finish())
|
|
.with_uniform_padding(icon_size / 8.)
|
|
.finish(),
|
|
)
|
|
.with_width(icon_size)
|
|
.with_height(icon_size)
|
|
.finish()
|
|
}
|
|
|
|
/// Returns the size to be used for the AI icon in AI output blocks and the terminal input when in
|
|
/// AI mode.
|
|
///
|
|
/// This size is computed based on the user's current font size and line height ratio, such that the
|
|
/// size of the icon matches the user's text line height. This is necessary because the AI icon in
|
|
/// the input is rendered next to text in the editor.
|
|
pub fn ai_indicator_height(app: &AppContext) -> f32 {
|
|
let appearance = Appearance::as_ref(app);
|
|
app.font_cache().line_height(
|
|
appearance.monospace_font_size(),
|
|
appearance.line_height_ratio(),
|
|
)
|
|
}
|
|
|
|
/// Returns the saved position ID of the attached blocks chip inside the [`AIBlock`] header.
|
|
pub fn get_attached_blocks_chip_element_position_id(view_id: EntityId) -> String {
|
|
format!("aiblock:{view_id}.attached_block_chip_position")
|
|
}
|
|
|
|
/// Returns the saved position ID of the overflow menu inside the [`AIBlock`] header.
|
|
pub fn get_ai_block_overflow_menu_element_position_id(view_id: EntityId) -> String {
|
|
format!("aiblock:{view_id}.overflow_menu_position")
|
|
}
|
|
|
|
/// Formats credit count to display as whole numbers when the value is effectively a whole number,
|
|
pub fn format_token_count(tokens: u32) -> String {
|
|
if tokens >= 1_000_000 {
|
|
format!("{:.1}M tokens", tokens as f64 / 1_000_000.0)
|
|
} else if tokens >= 1_000 {
|
|
format!("{:.1}k tokens", tokens as f64 / 1_000.0)
|
|
} else {
|
|
format!("{tokens} tokens")
|
|
}
|
|
}
|
|
|
|
pub fn format_cost_cents(cents: f32) -> String {
|
|
if cents >= 100.0 {
|
|
format!("${:.2}", cents / 100.0)
|
|
} else if cents >= 1.0 {
|
|
format!("{:.1}\u{00A2}", cents)
|
|
} else if cents > 0.0 {
|
|
format!("{:.2}\u{00A2}", cents)
|
|
} else {
|
|
"$0.00".to_string()
|
|
}
|
|
}
|
|
|
|
/// otherwise displays with one decimal place.
|
|
/// Returns a formatted string with proper pluralization ("credit" vs "credits").
|
|
pub fn format_credits(credits: f32) -> String {
|
|
// If the first part of the decimal is 0, we just display the whole number.
|
|
if credits.fract() < 0.1 {
|
|
let whole = credits.trunc() as i32;
|
|
if whole == 1 {
|
|
format!("{whole} credit")
|
|
} else {
|
|
format!("{whole} credits")
|
|
}
|
|
} else {
|
|
format!("{credits:.1} credits")
|
|
}
|
|
}
|
|
|
|
/// Renders a secondary button with an MCP/skill provider icon and a text label.
|
|
pub(crate) fn render_provider_icon_button<F>(
|
|
button_label: &str,
|
|
button_handle: MouseStateHandle,
|
|
appearance: &Appearance,
|
|
icon: Icon,
|
|
color: Fill,
|
|
on_click: F,
|
|
) -> Box<dyn Element>
|
|
where
|
|
F: FnMut(&mut EventContext) + 'static,
|
|
{
|
|
let theme = appearance.theme();
|
|
let font_color = theme.foreground().into_solid();
|
|
let mut label_children = vec![ConstrainedBox::new(icon.to_galaxyui_icon(color).finish())
|
|
.with_width(PROVIDER_BUTTON_ICON_SIZE)
|
|
.with_height(PROVIDER_BUTTON_ICON_SIZE)
|
|
.finish()];
|
|
label_children.push(
|
|
Container::new(
|
|
Span::new(
|
|
button_label.to_string(),
|
|
UiComponentStyles {
|
|
font_family_id: Some(appearance.ui_font_family()),
|
|
font_size: Some(appearance.ui_font_size()),
|
|
font_weight: Some(Weight::Semibold),
|
|
font_color: Some(font_color),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.build()
|
|
.finish(),
|
|
)
|
|
.with_padding_left(PROVIDER_BUTTON_ICON_TEXT_GAP)
|
|
.finish(),
|
|
);
|
|
let label = Flex::row()
|
|
.with_children(label_children)
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
|
.with_main_axis_alignment(MainAxisAlignment::Center)
|
|
.with_main_axis_size(MainAxisSize::Min)
|
|
.finish();
|
|
let mut on_click = on_click;
|
|
appearance
|
|
.ui_builder()
|
|
.button(ButtonVariant::Secondary, button_handle)
|
|
.with_custom_label(label)
|
|
.with_style(UiComponentStyles {
|
|
font_weight: Some(Weight::Semibold),
|
|
..Default::default()
|
|
})
|
|
.build()
|
|
.on_click(move |ctx, _, _| {
|
|
on_click(ctx);
|
|
})
|
|
.finish()
|
|
}
|