use std::sync::Arc; use std::time::Duration; use chrono::Local; use galaxy_editor::editor::NavigationKey; use galaxyui::clipboard::ClipboardContent; use galaxyui::elements::{ resizable_state_handle, Align, Border, ChildAnchor, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, DispatchEventResult, DragBarSide, Element, Empty, EventHandler, Fill, Flex, HyperlinkUrl, Icon, MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement, PositionedElementAnchor, PositionedElementOffsetBounds, Radius, Resizable, ResizableStateHandle, SavePosition, Shrinkable, Stack, Text, }; use galaxyui::fonts::Properties; use galaxyui::keymap::{EditableBinding, FixedBinding}; use galaxyui::platform::Cursor; use galaxyui::presenter::ChildView; use galaxyui::r#async::Timer; use galaxyui::ui_components::button::ButtonVariant; use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles}; use galaxyui::{ AppContext, Entity, FocusContext, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle, }; use pathfinder_geometry::vector::{vec2f, Vector2F}; use super::execution_context::WarpAiExecutionContext; use super::requests::{Event as RequestsEvent, RequestStatus, Requests}; use super::transcript::{Transcript, TranscriptEvent}; use super::utils::{render_prepared_response_button, render_request_limit_info, TranscriptPart}; use super::{ AskAIType, AI_ASSISTANT_FEATURE_NAME, AI_ASSISTANT_LOGO_COLOR, AI_ASSISTANT_SVG_PATH, ASK_AI_ASSISTANT_TEXT, PROMPT_CHARACTER_LIMIT, }; use crate::appearance::Appearance; use crate::editor::{ EditorOptions, EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys, TextOptions, }; use crate::input_suggestions::{Event as InputSuggestionsEvent, InputSuggestions}; use crate::send_telemetry_from_ctx; use crate::server::server_api::ai::AIClient; use crate::server::server_api::ServerApi; use crate::server::telemetry::{TelemetryEvent, WarpAIActionType}; use crate::terminal::resizable_data::{ModalType, ResizableData, DEFAULT_WARP_AI_WIDTH}; use crate::ui_components::blended_colors; use crate::ui_components::buttons::icon_button; use crate::util::bindings::{cmd_or_ctrl_shift, CustomAction}; use crate::workspace::{ActiveSession, TAB_BAR_HEIGHT}; use crate::workspaces::user_workspaces::UserWorkspaces; const INFO_ICON_SVG_PATH: &str = "bundled/svg/info.svg"; pub const HEXAGON_ALERT_SVG_PATH: &str = "bundled/svg/alert-hexagon.svg"; const EDITOR_SAVE_POSITION_ID: &str = "ai_assistant::editor"; const MIN_PANEL_WIDTH: f32 = 300.; const MIN_REMAINING_WINDOW_SIZE: f32 = 200.; const MAX_EDITOR_HEIGHT: f32 = 300.; const MAX_INPUT_SUGGESTIONS_HEIGHT: f32 = 200.; pub(super) const HEADER_HEIGHT: f32 = TAB_BAR_HEIGHT; pub(super) const HEADER_VERTICAL_PADDING: f32 = 5.; const PANEL_HORIZONTAL_PADDING: f32 = 6.; const EDITOR_MARGIN: f32 = 16.; const LOGO_SIZE: f32 = 20.; const BODY_FONT_SIZE: f32 = 13.; const TITLE_FONT_SIZE: f32 = 16.; const ZERO_STATE_HELP_TEXT_FONT_SIZE: f32 = 12.; const ZERO_STATE_HELP_TEXT: &str = "Shift + ctrl + space a block or text selection to ask Warp AI."; const SCRIPT_ZERO_STATE_PROMPT: &str = "Write a script to connect to an AWS EC2 instance."; const GIT_ZERO_STATE_PROMPT: &str = "How do I undo the most recent commits in git?"; const FILES_ZERO_STATE_PROMPT: &str = "How do I find all files containing specific text?"; // The placeholder texts are prepended with a space to give them cushion from the cursor. const INIT_PLACEHOLDER_TEXT: &str = " Ask a question..."; const FOLLOWUP_PLACEHOLDER_TEXT: &str = " Type a response or click one above..."; const RESTART_BUTTON_TEXT: &str = "Restart"; const ASK_AI_BLOCK_INPUT_LIMIT: usize = 100; #[derive(Default)] struct MouseStateHandles { close_panel_state: MouseStateHandle, reset_context_button: MouseStateHandle, copy_transcript_button: MouseStateHandle, script_zero_state_prompt: MouseStateHandle, git_zero_state_prompt: MouseStateHandle, files_zero_state_prompt: MouseStateHandle, } pub enum AIAssistantPanelEvent { ClosePanel, PasteInTerminalInput(Arc), FocusTerminalInput, OpenWorkflowModalWithCommand(String), } /// Which child view is currently focused. It must be exactly one of these. #[derive(Copy, Clone)] enum PanelFocusState { Editor, Transcript, } enum InputSuggestionsMode { Open { origin_buffer_text: String }, Closed, } /// The panel view is responsible for the various components that make up the panel /// (e.g. header, transcript, editor). /// TODO: we should eventually refactor this and other panels into a more /// general Panel view. pub struct AIAssistantPanelView { editor: ViewHandle, transcript_view: ViewHandle, input_suggestions_view: ViewHandle, input_suggestions_mode: InputSuggestionsMode, requests_model: ModelHandle, focus_state: PanelFocusState, resizable_state_handle: ResizableStateHandle, mouse_state_handles: MouseStateHandles, } #[derive(Debug, Clone)] pub enum AIAssistantAction { ClosePanel, ResetContext, CopyTranscript, PreparedPrompt(&'static str), ClickedUrl(HyperlinkUrl), CopyAnswerToClipboard(Arc), FocusTerminalInput, FocusEditor, } pub fn init(app: &mut AppContext) { use galaxyui::keymap::macros::*; app.register_fixed_bindings([FixedBinding::custom( CustomAction::CloseCurrentSession, AIAssistantAction::ClosePanel, "Close Warp AI", id!("AIAssistantPanel"), )]); app.register_editable_bindings([ EditableBinding::new( "ai_assistant_panel:focus_terminal_input", "Focus Terminal Input From Warp AI", AIAssistantAction::FocusTerminalInput, ) .with_context_predicate(id!("AIAssistantPanel")) .with_key_binding(cmd_or_ctrl_shift("l")), EditableBinding::new( "ai_assistant_panel:reset_context", "Restart Galaxy", AIAssistantAction::ResetContext, ) .with_context_predicate(id!("AIAssistantPanel")) .with_key_binding("ctrl-l"), EditableBinding::new( "ai_assistant_panel:reset_context", "Restart Galaxy", AIAssistantAction::ResetContext, ) .with_context_predicate(id!("AIAssistantPanel")) .with_key_binding(cmd_or_ctrl_shift("k")), ]); } impl AIAssistantPanelView { pub fn new( server_api: Arc, ai_client: Arc, ctx: &mut ViewContext, ) -> Self { let editor = { ctx.add_typed_action_view(|ctx| { let appearance = Appearance::as_ref(ctx); let options = EditorOptions { text: TextOptions::ui_text(Some(BODY_FONT_SIZE), appearance), propagate_and_no_op_vertical_navigation_keys: PropagateAndNoOpNavigationKeys::Always, autogrow: true, soft_wrap: true, supports_vim_mode: true, ..Default::default() }; EditorView::new(options, ctx) }) }; editor.update(ctx, |editor, ctx| { editor.set_placeholder_text(INIT_PLACEHOLDER_TEXT, ctx) }); ctx.subscribe_to_view(&editor, |me, _, event, ctx| { me.handle_editor_event(event, ctx); }); let active_session_model = ActiveSession::handle(ctx); ctx.observe(&active_session_model, Self::on_active_session_change); let requests_model = ctx.add_model(|ctx| Requests::new(server_api.clone(), ai_client.clone(), ctx)); ctx.subscribe_to_model(&requests_model, move |me, _, event, ctx| { me.handle_requests_model_event(event, ctx); }); ctx.observe(&requests_model, |_, _, ctx| ctx.notify()); let transcript_view = ctx.add_typed_action_view(|ctx| Transcript::new(&requests_model, ctx)); ctx.subscribe_to_view(&transcript_view, |me, _, event, ctx| { me.handle_transcript_event(event, ctx); }); let input_suggestions_view = ctx.add_typed_action_view(InputSuggestions::new); ctx.subscribe_to_view(&input_suggestions_view, move |me, _, event, ctx| { me.handle_input_suggestions_event(event, ctx); }); let resizable_data_handle = ResizableData::handle(ctx); let resizable_state_handle = match resizable_data_handle .as_ref(ctx) .get_handle(ctx.window_id(), ModalType::WarpAIWidth) { Some(handle) => handle, None => { log::error!("Couldn't retrieve warp ai resizable state handle."); resizable_state_handle(DEFAULT_WARP_AI_WIDTH) } }; let mut panel = Self { editor, transcript_view, input_suggestions_view, input_suggestions_mode: InputSuggestionsMode::Closed, requests_model, focus_state: PanelFocusState::Editor, resizable_state_handle, mouse_state_handles: Default::default(), }; panel.tick(ctx); panel.on_active_session_change(active_session_model, ctx); panel } fn on_active_session_change( &mut self, active_session_handle: ModelHandle, ctx: &mut ViewContext, ) { let window_id = ctx.window_id(); let ai_execution_context = active_session_handle .as_ref(ctx) .session(window_id) .as_ref() .map(WarpAiExecutionContext::new); self.requests_model.update(ctx, |requests, _| { requests.update_ai_execution_context(ai_execution_context); }); } // Every minute, we re-render to make the next refresh time tick. fn tick(&self, ctx: &mut ViewContext) { ctx.spawn( async move { Timer::after(Duration::from_secs(60)).await }, |view, _, ctx| { view.transcript_view.update(ctx, |_, ctx| { ctx.notify(); }); ctx.notify(); view.tick(ctx); }, ); } fn format_as_code_block(&self, content: &str) -> String { // Intentionally choose a language that won't be interpreted as a shell language // i.e. (*sh) format!("```warp\n{}\n```", content.trim()) } // TODO: reconsider if we should be doing all the formatting in here as opposed // to doing the formatting at source and passing down the prompt to render as is. pub fn ask_ai(&mut self, ask_type: &AskAIType, ctx: &mut ViewContext) { match ask_type { AskAIType::FromTextSelection { text, populate_input_box, } => { if *populate_input_box { let prefix = "Explain the following:\n"; let code_block_formatting_len = self.format_as_code_block("").len(); let truncated = if text.chars().count() + prefix.len() + code_block_formatting_len > PROMPT_CHARACTER_LIMIT { // Take the first k characters of the text selection, where k is the // remaining length after we limit the prompt and add formatting to it. let truncated: String = text .chars() // Take 3 for the ellipsis .take( PROMPT_CHARACTER_LIMIT - prefix.len() - code_block_formatting_len - 3, ) .collect(); format!("{truncated}...") } else { text.to_string() }; self.editor.update(ctx, |editor, ctx| { editor.set_buffer_text( &format!( "{}{}", prefix, self.format_as_code_block(truncated.as_str()) ), ctx, ); }); ctx.notify(); } } AskAIType::FromBlock { input, output, exit_code, .. } => { let block_successful = exit_code.was_successful(); // Formatting strings. let question = if block_successful { "\nWhat should I do next?" } else { "\nHow do I fix this?" }; let prefix = "I ran the command: `"; let suffix = "` and got the following output:\n"; let code_block_formatting_len = self.format_as_code_block("").len(); let non_input_output_len = prefix.len() + suffix.len() + question.len() + code_block_formatting_len; let input_len = input.chars().count(); let output_len = output.chars().count(); // If the input and output are longer than can be and the input is particularly large, try to // shave the input down to a fixed number of chars. let truncated_input = if input_len + output_len + non_input_output_len > PROMPT_CHARACTER_LIMIT && input_len > ASK_AI_BLOCK_INPUT_LIMIT { let truncated: String = input.chars().take(ASK_AI_BLOCK_INPUT_LIMIT).collect(); format!("{truncated}...") } else { input.to_string() }; let truncated_input_len = truncated_input.chars().count(); // If the truncated input and raw output are still longer than // the allowed size, trim down the output. let truncated_output = if truncated_input_len + output_len + non_input_output_len > PROMPT_CHARACTER_LIMIT { // Take the last k characters of the block's output, where k is the // remaining length after we limit the prompt and add formatting to it. // + 3 for the ellipsis. let output_starting_index = output_len + truncated_input_len + non_input_output_len + 3 - PROMPT_CHARACTER_LIMIT; let truncated: String = output.chars().skip(output_starting_index).collect(); format!("...{truncated}") } else { output.to_string() }; // Insert the truncated strings (with the formatting around them) into the editor. self.editor.update(ctx, |editor, ctx| { editor.set_buffer_text( &format!( "{prefix}{}{suffix}{}{question}", truncated_input, self.format_as_code_block(truncated_output.as_str()) ), ctx, ); }); ctx.notify(); } AskAIType::FromAICommandSearch { query } => { let truncated = if query.chars().count() > PROMPT_CHARACTER_LIMIT { // Reserve 3 for the ellpisis let truncated: String = query.chars().take(PROMPT_CHARACTER_LIMIT - 3).collect(); format!("{truncated}...") } else { query.to_string() }; self.editor.update(ctx, |editor, ctx| { editor.set_buffer_text(truncated.as_str(), ctx); }); } // Not supported by the AI Assistant. Only supported by blocklist AI. AskAIType::FromBlocks { .. } => (), } send_telemetry_from_ctx!( TelemetryEvent::OpenedWarpAI { source: ask_type.into() }, ctx ); } fn is_prompt_too_long(&self, prompt: &str) -> bool { prompt.chars().count() > PROMPT_CHARACTER_LIMIT } fn is_prompt_empty(&self, prompt: &str) -> bool { prompt.chars().count() == 0 } fn handle_editor_event(&mut self, event: &EditorEvent, ctx: &mut ViewContext) { match event { EditorEvent::Enter => { self.input_suggestions_mode = InputSuggestionsMode::Closed; let buffer_text = self.editor.as_ref(ctx).buffer_text(ctx); if !self.is_prompt_too_long(buffer_text.as_str()) && !self.is_prompt_empty(buffer_text.as_str()) { self.issue_request(buffer_text, ctx); } else { // Only send this event if the user tried to execute with a longer than permitted prompt. send_telemetry_from_ctx!(TelemetryEvent::WarpAICharacterLimitExceeded, ctx); } ctx.notify(); } EditorEvent::Edited(_) => { // Force a re-render so we can show the character limit warning. ctx.notify(); if let Some(selected_text) = self .input_suggestions_view .as_ref(ctx) .get_selected_item_text() { if selected_text == self.editor.as_ref(ctx).buffer_text(ctx) { return; } } self.input_suggestions_mode = InputSuggestionsMode::Closed; } EditorEvent::CmdUpOnFirstRow => { self.transcript_view.update(ctx, |transcript_view, ctx| { transcript_view.select_last_code_block(ctx); }); ctx.notify(); } EditorEvent::Activate => { self.focus_state = PanelFocusState::Editor; ctx.focus_self(); } EditorEvent::Escape => { self.input_suggestions_view .update(ctx, |input_suggestions, ctx| { input_suggestions.exit(true, ctx); }); ctx.notify(); } EditorEvent::Navigate(NavigationKey::Up) => { if matches!(self.input_suggestions_mode, InputSuggestionsMode::Closed) { let editor = self.editor.as_ref(ctx); if editor.single_cursor_on_first_row(ctx) { let buffer_text = editor.buffer_text(ctx); let all_past_prompts = self .requests_model .as_ref(ctx) .all_past_transcript_prompts(); self.input_suggestions_view .update(ctx, |input_suggestions, ctx| { input_suggestions.fuzzy_substring_search( buffer_text.clone(), all_past_prompts, ctx, ); }); self.input_suggestions_mode = InputSuggestionsMode::Open { origin_buffer_text: buffer_text, }; } else { self.editor.update(ctx, |editor, ctx| editor.move_up(ctx)); } } else { self.input_suggestions_view .update(ctx, |input_suggestions, ctx| { input_suggestions.select_prev(ctx); }); } ctx.notify(); } EditorEvent::Navigate(NavigationKey::Down) => { if matches!( self.input_suggestions_mode, InputSuggestionsMode::Open { .. } ) { self.input_suggestions_view .update(ctx, |input_suggestions, ctx| { if input_suggestions.is_empty() { input_suggestions.exit(true, ctx); } else { input_suggestions.select_next(ctx); } }); } else { self.editor.update(ctx, |editor, ctx| editor.move_down(ctx)); } ctx.notify(); } _ => {} } } fn handle_transcript_event(&mut self, event: &TranscriptEvent, ctx: &mut ViewContext) { match event { TranscriptEvent::PasteInTerminalInput { code_block_index } => { let code = self.transcript_view.read(ctx, |transcript_view, ctx| { transcript_view.code_for_index(*code_block_index, ctx) }); if let Some(code) = code { ctx.emit(AIAssistantPanelEvent::PasteInTerminalInput(Arc::new(code))); } } TranscriptEvent::FocusEditor => { self.focus_state = PanelFocusState::Editor; ctx.focus_self(); } TranscriptEvent::ClickedCodeBlock | TranscriptEvent::FocusTranscript => { self.focus_state = PanelFocusState::Transcript; ctx.focus_self(); } TranscriptEvent::OpenWorkflowModalWithCommand(command) => { ctx.emit(AIAssistantPanelEvent::OpenWorkflowModalWithCommand( command.clone(), )); } } } fn handle_requests_model_event(&mut self, event: &RequestsEvent, ctx: &mut ViewContext) { match event { RequestsEvent::RequestFinished { .. } => { self.editor.update(ctx, |editor, ctx| { editor.clear_buffer_and_reset_undo_stack(ctx); editor.set_placeholder_text(FOLLOWUP_PLACEHOLDER_TEXT, ctx); }); self.transcript_view.update(ctx, |transcript_view, ctx| { transcript_view.scroll_to_bottom_of_transcript(ctx); }); ctx.notify(); } } } fn handle_input_suggestions_event( &mut self, event: &InputSuggestionsEvent, ctx: &mut ViewContext, ) { match event { InputSuggestionsEvent::CloseSuggestion { .. } => { if let InputSuggestionsMode::Open { origin_buffer_text } = &self.input_suggestions_mode { self.editor.update(ctx, |editor, ctx| { editor.set_buffer_text(origin_buffer_text, ctx); }); } self.input_suggestions_mode = InputSuggestionsMode::Closed; } InputSuggestionsEvent::Select(item) => { self.editor.update(ctx, |editor, ctx| { editor.set_buffer_text(item.text(), ctx); }); } InputSuggestionsEvent::ConfirmSuggestion { suggestion, .. } => { self.editor.update(ctx, |editor, ctx| { editor.set_buffer_text(suggestion, ctx); }); self.input_suggestions_mode = InputSuggestionsMode::Closed; } InputSuggestionsEvent::ConfirmAndExecuteSuggestion { suggestion, .. } => { self.issue_request(suggestion.to_string(), ctx); self.input_suggestions_mode = InputSuggestionsMode::Closed; } InputSuggestionsEvent::IgnoreItem { .. } => { // not worth implementing; feature is hardly used } } ctx.notify(); } fn issue_request(&mut self, request: String, ctx: &mut ViewContext) { self.requests_model.update(ctx, |requests_model, ctx| { requests_model.issue_request(request, ctx); }); self.transcript_view.update(ctx, |transcript_view, ctx| { transcript_view.clear_selected_block(ctx); transcript_view.scroll_to_bottom_of_transcript(ctx); }); ctx.notify(); } fn reset_context(&mut self, ctx: &mut ViewContext) { let request_status = self.requests_model.as_ref(ctx).request_status(); if matches!(request_status, &RequestStatus::InFlight { .. }) { self.editor.update(ctx, |editor, ctx| { editor.clear_buffer_and_reset_undo_stack(ctx); }); } self.editor.update(ctx, |editor, ctx| { editor.set_placeholder_text(INIT_PLACEHOLDER_TEXT, ctx); }); self.requests_model.update(ctx, |requests_model, ctx| { requests_model.reset(ctx); }); self.transcript_view.update(ctx, |transcript_view, ctx| { transcript_view.reset(ctx); }); self.focus_state = PanelFocusState::Editor; ctx.focus_self(); ctx.notify(); } fn copy_transcript(&mut self, ctx: &mut ViewContext) { let transcript = self.transcript(ctx); let mut result = String::new(); let time_now = Local::now(); result.push_str(&format!( "## Warp AI Transcript ({})\n\n", time_now.format("%x %l:%M %p") )); for part in transcript { result.push_str(&format!("Prompt: {}\n\n", part.raw_user_prompt().trim())); result.push_str(&format!( "Warp AI: {}\n\n", part.raw_assistant_answer().trim() )); } ctx.clipboard() .write(ClipboardContent::plain_text(result.trim().to_string())); } fn should_render_zero_state(&self, app: &AppContext) -> bool { self.transcript(app).is_empty() && matches!(self.request_status(app), RequestStatus::NotInFlight) } fn transcript<'a>(&self, app: &'a AppContext) -> &'a [TranscriptPart] { self.requests_model.as_ref(app).transcript() } fn request_status<'a>(&self, app: &'a AppContext) -> &'a RequestStatus { self.requests_model.as_ref(app).request_status() } fn num_remaining_reqs(&self, app: &AppContext) -> usize { self.requests_model.as_ref(app).num_remaining_reqs() } #[cfg(feature = "integration_tests")] pub fn editor(&self) -> &ViewHandle { &self.editor } } /// All rendering related capabilities. impl AIAssistantPanelView { fn render_title_bar(&self, appearance: &Appearance, app: &AppContext) -> Box { let mut header = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child( Container::new( ConstrainedBox::new( Icon::new(AI_ASSISTANT_SVG_PATH, *AI_ASSISTANT_LOGO_COLOR).finish(), ) .with_height(LOGO_SIZE) .with_width(LOGO_SIZE) .finish(), ) .with_padding_right(4.) .finish(), ) .with_child( Container::new( appearance .ui_builder() .wrappable_text(AI_ASSISTANT_FEATURE_NAME.to_string(), false) .with_style(UiComponentStyles { font_family_id: Some(appearance.ui_font_family()), font_size: Some(TITLE_FONT_SIZE), font_weight: Some(galaxyui::fonts::Weight::Semibold), font_color: Some(appearance.theme().active_ui_text_color().into()), ..Default::default() }) .build() .finish(), ) .finish(), ) .with_child(Shrinkable::new(1., Empty::new().finish()).finish()); // Add the copy and restart buttons iff the transcript is non-empty or there's a request in flight; if !self.transcript(app).is_empty() || matches!(self.request_status(app), RequestStatus::InFlight { .. }) { header.add_child( Container::new(Align::new(self.render_restart_button(appearance)).finish()) .with_margin_right(4.) .finish(), ); header.add_child( Container::new(self.render_copy_transcript_button(appearance)) .with_margin_right(4.) .finish(), ); } // Add the close button header.add_child( Container::new( icon_button( appearance, crate::ui_components::icons::Icon::X, false, self.mouse_state_handles.close_panel_state.clone(), ) .build() .on_click(|ctx, _, _| ctx.dispatch_typed_action(AIAssistantAction::ClosePanel)) .with_cursor(Cursor::PointingHand) .finish(), ) .finish(), ); header.finish() } fn render_copy_transcript_button(&self, appearance: &Appearance) -> Box { let tooltip_background = appearance.theme().surface_1().into_solid(); let ui_builder = appearance.ui_builder().clone(); icon_button( appearance, crate::ui_components::icons::Icon::Copy, false, self.mouse_state_handles.copy_transcript_button.clone(), ) .with_tooltip(move || { let tool_tip_style = UiComponentStyles { background: Some(Fill::Solid(tooltip_background)), ..Default::default() }; ui_builder .tool_tip("Copy transcript to clipboard".to_owned()) .with_style(tool_tip_style) .build() .finish() }) .build() .on_click(move |ctx, _, _| ctx.dispatch_typed_action(AIAssistantAction::CopyTranscript)) .with_cursor(Cursor::PointingHand) .finish() } fn render_restart_button(&self, appearance: &Appearance) -> Box { let default_styles = UiComponentStyles { border_width: None, font_color: Some(appearance.theme().active_ui_text_color().into()), font_size: Some(12.), font_family_id: Some(appearance.ui_font_family()), padding: Some(Coords { top: 4., bottom: 4., left: 8., right: 8., }), border_radius: Some(CornerRadius::with_all(Radius::Pixels(4.))), ..Default::default() }; let hover_style = UiComponentStyles { background: Some(appearance.theme().surface_3().into()), ..default_styles }; appearance .ui_builder() .button_with_custom_styles( ButtonVariant::Text, self.mouse_state_handles.reset_context_button.clone(), default_styles, Some(hover_style), Some(hover_style), Some(hover_style), ) .with_text_label(RESTART_BUTTON_TEXT.to_owned()) .build() .on_click(move |ctx, _, _| ctx.dispatch_typed_action(AIAssistantAction::ResetContext)) .with_cursor(Cursor::PointingHand) .finish() } fn render_editor_size_warning( &self, appearance: &Appearance, buffer_len: usize, ) -> Box { Flex::row() .with_children([ Container::new( Text::new_inline( "Character limit exceeded.", appearance.ui_font_family(), BODY_FONT_SIZE, ) .with_style(Properties { weight: galaxyui::fonts::Weight::Bold, ..Default::default() }) .with_color(appearance.theme().ui_error_color()) .finish(), ) .with_margin_right(10.) .finish(), Text::new_inline( format!("{buffer_len} / {PROMPT_CHARACTER_LIMIT}"), appearance.ui_font_family(), BODY_FONT_SIZE, ) .with_color(appearance.theme().ui_error_color()) .finish(), ]) .finish() } fn render_editor(&self) -> Box { SavePosition::new( ConstrainedBox::new(ChildView::new(&self.editor).finish()) .with_max_height(MAX_EDITOR_HEIGHT) .finish(), EDITOR_SAVE_POSITION_ID, ) .finish() } fn render_input_suggestions_menu(&self, appearance: &Appearance) -> Box { ConstrainedBox::new( Container::new(ChildView::new(&self.input_suggestions_view).finish()) .with_uniform_margin(10.) .with_background(appearance.theme().surface_2()) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.))) .with_border(Border::all(1.0).with_border_fill(appearance.theme().outline())) .finish(), ) .with_max_height(MAX_INPUT_SUGGESTIONS_HEIGHT) .finish() } fn render_zero_state(&self, appearance: &Appearance, app: &AppContext) -> Box { let theme = appearance.theme(); // TODO: fill should be Copy. it's cheap (a few ColorU's at most). let sub_text_color = blended_colors::text_sub(theme, theme.surface_2()); let thick_overlay_color = theme.surface_3(); let mut column = Flex::column() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child( ConstrainedBox::new(Icon::new(AI_ASSISTANT_SVG_PATH, thick_overlay_color).finish()) .with_height(44.) .with_width(44.) .finish(), ) .with_child( Container::new( Text::new_inline(ASK_AI_ASSISTANT_TEXT, appearance.ui_font_family(), 14.) .with_color(sub_text_color) .finish(), ) .with_margin_top(8.) .finish(), ); if self.num_remaining_reqs(app) > 0 { column.add_children([ Container::new(render_prepared_response_button( appearance, self.mouse_state_handles.git_zero_state_prompt.clone(), Some(300.), None, GIT_ZERO_STATE_PROMPT, )) .with_margin_top(20.) .with_margin_bottom(10.) .finish(), Container::new(render_prepared_response_button( appearance, self.mouse_state_handles.files_zero_state_prompt.clone(), Some(300.), None, FILES_ZERO_STATE_PROMPT, )) .with_margin_bottom(10.) .finish(), Container::new(render_prepared_response_button( appearance, self.mouse_state_handles.script_zero_state_prompt.clone(), Some(300.), None, SCRIPT_ZERO_STATE_PROMPT, )) .finish(), ]); } column.add_child( Container::new( Flex::row() .with_main_axis_size(MainAxisSize::Max) .with_main_axis_alignment(MainAxisAlignment::Center) .with_child( Container::new( ConstrainedBox::new( Icon::new(INFO_ICON_SVG_PATH, theme.active_ui_text_color()) .finish(), ) .with_height(15.) .with_width(15.) .finish(), ) .with_margin_right(8.) .finish(), ) .with_child( Shrinkable::new( 1., appearance .ui_builder() .wrappable_text(ZERO_STATE_HELP_TEXT.to_string(), true) .with_style(UiComponentStyles { font_family_id: Some(appearance.ui_font_family()), font_size: Some(ZERO_STATE_HELP_TEXT_FONT_SIZE), font_color: Some(theme.active_ui_text_color().into()), ..Default::default() }) .build() .finish(), ) .finish(), ) .finish(), ) .with_margin_top(25.) .finish(), ); let is_custom_llm_enabled: bool = UserWorkspaces::as_ref(app) .current_team() .is_some_and(|team| team.is_custom_llm_enabled()); if !is_custom_llm_enabled { column.add_child( Container::new(render_request_limit_info( &self.requests_model, app, appearance, )) .with_margin_top(18.) .finish(), ); } Container::new(column.finish()) .with_margin_left(12.) .with_margin_right(12.) .with_padding_top(50.) .finish() } } impl Entity for AIAssistantPanelView { type Event = AIAssistantPanelEvent; } impl TypedActionView for AIAssistantPanelView { type Action = AIAssistantAction; fn handle_action(&mut self, action: &AIAssistantAction, ctx: &mut ViewContext) { use AIAssistantAction::*; match action { ResetContext => { self.reset_context(ctx); send_telemetry_from_ctx!( TelemetryEvent::WarpAIAction { action_type: WarpAIActionType::Restart }, ctx ); } CopyTranscript => { self.copy_transcript(ctx); send_telemetry_from_ctx!( TelemetryEvent::WarpAIAction { action_type: WarpAIActionType::CopyTranscript }, ctx ); } ClosePanel => { ctx.emit(AIAssistantPanelEvent::ClosePanel); } PreparedPrompt(prompt) => { self.issue_request(prompt.to_string(), ctx); send_telemetry_from_ctx!(TelemetryEvent::UsedWarpAIPreparedPrompt { prompt }, ctx); } ClickedUrl(url) => { ctx.open_url(&url.url); } CopyAnswerToClipboard(content) => { ctx.clipboard() .write(ClipboardContent::plain_text(content.to_string())); send_telemetry_from_ctx!( TelemetryEvent::WarpAIAction { action_type: WarpAIActionType::CopyAnswer }, ctx ); } FocusTerminalInput => ctx.emit(AIAssistantPanelEvent::FocusTerminalInput), FocusEditor => { self.focus_state = PanelFocusState::Editor; ctx.focus_self(); } } } } impl View for AIAssistantPanelView { fn ui_name() -> &'static str { "AIAssistantPanel" } fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext) { if focus_ctx.is_self_focused() { match &self.focus_state { PanelFocusState::Editor => { self.transcript_view.update(ctx, |transcript_view, ctx| { transcript_view.clear_selected_block(ctx) }); ctx.focus(&self.editor); } PanelFocusState::Transcript => ctx.focus(&self.transcript_view), } ctx.notify(); } } fn render(&self, app: &AppContext) -> Box { let appearance = Appearance::as_ref(app); let mut panel = Flex::column().with_main_axis_size(MainAxisSize::Max); let should_render_zero_state = self.should_render_zero_state(app); let body = if should_render_zero_state { Align::new(self.render_zero_state(appearance, app)).finish() } else { Align::new(ChildView::new(&self.transcript_view).finish()) .top_center() .finish() }; panel.add_child(Shrinkable::new(1., body).finish()); if matches!(self.request_status(app), RequestStatus::NotInFlight) { let buffer_text = self.editor.as_ref(app).buffer_text(app); if self.is_prompt_too_long(buffer_text.as_str()) { panel.add_child( Container::new( self.render_editor_size_warning(appearance, buffer_text.chars().count()), ) .with_padding_left(PANEL_HORIZONTAL_PADDING) .with_padding_bottom(5.) .with_padding_top(10.) .finish(), ); } panel.add_child( Container::new(self.render_editor()) .with_uniform_padding(EDITOR_MARGIN) .finish(), ); } let mut stack = Stack::new().with_child(panel.finish()); stack.add_positioned_overlay_child( ConstrainedBox::new( Container::new(self.render_title_bar(appearance, app)) .with_padding_top(HEADER_VERTICAL_PADDING) .with_padding_bottom(HEADER_VERTICAL_PADDING) .with_padding_left(PANEL_HORIZONTAL_PADDING) .with_padding_right(PANEL_HORIZONTAL_PADDING) .finish(), ) .with_height(HEADER_HEIGHT) .finish(), OffsetPositioning::offset_from_parent( vec2f(0., HEADER_HEIGHT), galaxyui::elements::ParentOffsetBounds::Unbounded, ParentAnchor::TopLeft, ChildAnchor::BottomLeft, ), ); if matches!( self.input_suggestions_mode, InputSuggestionsMode::Open { .. } ) { stack.add_positioned_overlay_child( self.render_input_suggestions_menu(appearance), OffsetPositioning::offset_from_save_position_element( EDITOR_SAVE_POSITION_ID, Vector2F::new(0., -10.), PositionedElementOffsetBounds::ParentByPosition, PositionedElementAnchor::TopLeft, ChildAnchor::BottomLeft, ), ); } let styled_panel = Container::new(stack.finish()) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.))); let clickable_panel = EventHandler::new(styled_panel.finish()).on_left_mouse_down(|ctx, _, _| { ctx.dispatch_typed_action(AIAssistantAction::FocusEditor); DispatchEventResult::StopPropagation }); Resizable::new( self.resizable_state_handle.clone(), clickable_panel.finish(), ) .on_resize(move |ctx, _| ctx.notify()) .with_dragbar_side(DragBarSide::Left) .with_bounds_callback(Box::new(|window_bounds| { ( MIN_PANEL_WIDTH, (window_bounds.x() - MIN_REMAINING_WINDOW_SIZE).max(MIN_PANEL_WIDTH), ) })) .finish() } }