use crate::ai::agent::conversation::{AIConversationId, ConversationStatus}; use crate::ai::conversation_status_ui::{render_status_element, STATUS_ELEMENT_PADDING}; use crate::appearance::Appearance; use crate::search::{ItemHighlightState, SearchItem}; use crate::terminal::history::LinkedWorkflowData; use crate::terminal::input::inline_history::data_source::AcceptHistoryItem; use crate::terminal::input::inline_menu::styles as inline_styles; use crate::util::time_format::format_approx_duration_from_now_utc; use chrono::{DateTime, Local}; use fuzzy_match::FuzzyMatchResult; use ordered_float::OrderedFloat; use galaxy_core::ui::color::coloru_with_opacity; use galaxy_core::ui::theme::Fill; use galaxy_core::ui::Icon; use galaxyui::elements::{ConstrainedBox, Container, Highlight, ParentElement, Shrinkable, Text}; use galaxyui::fonts::{Properties, Weight}; use galaxyui::prelude::{Align, CrossAxisAlignment, Flex, MainAxisAlignment, MainAxisSize}; use galaxyui::scene::{CornerRadius, Radius}; use galaxyui::text_layout::ClipConfig; use galaxyui::{AppContext, Element, SingletonEntity}; #[derive(Debug, Clone)] pub struct InlineHistoryItem { item_type: HistoryItemType, name_match_result: Option, prefix_match_len: usize, score: OrderedFloat, timestamp: DateTime, } #[derive(Debug, Clone)] enum HistoryItemType { Conversation { conversation_id: AIConversationId, title: String, status: ConversationStatus, }, Command { command: String, linked_workflow_data: Option, }, AIPrompt { query_text: String, }, } impl InlineHistoryItem { pub fn conversation( conversation_id: AIConversationId, title: String, status: ConversationStatus, timestamp: DateTime, ) -> Self { Self { item_type: HistoryItemType::Conversation { conversation_id, title, status, }, name_match_result: None, prefix_match_len: 0, score: OrderedFloat(f64::MIN), timestamp, } } pub fn command( command: String, linked_workflow_data: Option, timestamp: DateTime, ) -> Self { Self { item_type: HistoryItemType::Command { command, linked_workflow_data, }, name_match_result: None, prefix_match_len: 0, score: OrderedFloat(f64::MIN), timestamp, } } pub fn ai_prompt(query_text: String, timestamp: DateTime) -> Self { Self { item_type: HistoryItemType::AIPrompt { query_text }, name_match_result: None, prefix_match_len: 0, score: OrderedFloat(f64::MIN), timestamp, } } pub fn with_name_match_result(mut self, result: Option) -> Self { self.name_match_result = result; self } pub fn with_prefix_match_len(mut self, len: usize) -> Self { self.prefix_match_len = len; self } pub fn with_score(mut self, score: OrderedFloat) -> Self { self.score = score; self } } impl SearchItem for InlineHistoryItem { type Action = AcceptHistoryItem; fn render_icon( &self, _highlight_state: ItemHighlightState, appearance: &Appearance, ) -> Box { let icon_size = inline_styles::font_size(appearance); let icon = match &self.item_type { HistoryItemType::Conversation { status, .. } => { render_status_element(status, icon_size, appearance) } HistoryItemType::Command { .. } => { let icon_color = inline_styles::icon_color(appearance); Container::new( ConstrainedBox::new(Icon::Terminal.to_galaxyui_icon(icon_color).finish()) .with_width(icon_size) .with_height(icon_size) .finish(), ) .with_uniform_padding(STATUS_ELEMENT_PADDING) .with_background(coloru_with_opacity(icon_color.into(), 10)) .with_corner_radius(CornerRadius::with_all(Radius::Pixels( inline_styles::ITEM_CORNER_RADIUS, ))) .finish() } HistoryItemType::AIPrompt { .. } => { let icon_color = inline_styles::icon_color(appearance); Container::new( ConstrainedBox::new(Icon::Prompt.to_galaxyui_icon(icon_color).finish()) .with_width(icon_size) .with_height(icon_size) .finish(), ) .with_uniform_padding(STATUS_ELEMENT_PADDING) .with_background(coloru_with_opacity(icon_color.into(), 10)) .with_corner_radius(CornerRadius::with_all(Radius::Pixels( inline_styles::ITEM_CORNER_RADIUS, ))) .finish() } }; Container::new(icon) .with_margin_right(inline_styles::ICON_MARGIN) .finish() } fn render_item( &self, _highlight_state: ItemHighlightState, app: &AppContext, ) -> Box { let appearance = Appearance::handle(app).as_ref(app); let theme = appearance.theme(); let font_size = inline_styles::font_size(appearance); let background_color = inline_styles::menu_background_color(app); let primary_text_color = inline_styles::primary_text_color(theme, background_color.into()); let secondary_text_color = inline_styles::secondary_text_color(theme, background_color.into()); let (display_text, match_indices, font_family) = match &self.item_type { HistoryItemType::Conversation { title, .. } => { let indices = self .name_match_result .as_ref() .map(|m| m.matched_indices.clone()) .unwrap_or_default(); (title.clone(), indices, appearance.ui_font_family()) } HistoryItemType::Command { command, .. } => { let indices = if self.prefix_match_len > 0 { (0..self.prefix_match_len).collect() } else { vec![] }; (command.clone(), indices, appearance.monospace_font_family()) } HistoryItemType::AIPrompt { query_text } => { let indices = if self.prefix_match_len > 0 { (0..self.prefix_match_len).collect() } else { vec![] }; (query_text.clone(), indices, appearance.ui_font_family()) } }; let mut text = Text::new_inline(display_text, font_family, font_size) .with_color(primary_text_color.into()) .with_clip(ClipConfig::ellipsis()); if !match_indices.is_empty() { text = text.with_single_highlight( Highlight::new().with_properties(Properties::default().weight(Weight::Bold)), match_indices, ); } let mut primary_row = 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.finish()).finish()); let timestamp = Text::new_inline( format_approx_duration_from_now_utc(self.timestamp.to_utc()), appearance.ui_font_family(), font_size, ) .with_color(secondary_text_color.into()) .finish(); let max_timestamp_width = app .font_cache() .em_width(appearance.ui_font_family(), font_size) * 10.; primary_row.add_child( ConstrainedBox::new(Align::new(timestamp).right().finish()) .with_width(max_timestamp_width) .finish(), ); primary_row.finish() } fn item_background( &self, highlight_state: ItemHighlightState, appearance: &Appearance, ) -> Option { inline_styles::item_background(highlight_state, appearance) } fn score(&self) -> OrderedFloat { self.score } fn accept_result(&self) -> Self::Action { match &self.item_type { HistoryItemType::Conversation { conversation_id, title, .. } => AcceptHistoryItem::Conversation { conversation_id: *conversation_id, title: title.clone(), }, HistoryItemType::Command { command, linked_workflow_data, } => AcceptHistoryItem::Command { command: command.clone(), linked_workflow_data: linked_workflow_data.clone(), }, HistoryItemType::AIPrompt { query_text } => AcceptHistoryItem::AIPrompt { query_text: query_text.clone(), }, } } fn execute_result(&self) -> Self::Action { self.accept_result() } fn accessibility_label(&self) -> String { match &self.item_type { HistoryItemType::Conversation { title, .. } => format!("Conversation: {title}"), HistoryItemType::Command { command, .. } => format!("Command: {command}"), HistoryItemType::AIPrompt { query_text } => format!("AI prompt: {query_text}"), } } }