Files
galaxy/app/src/ai/blocklist/view_util.rs
T

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()
}