use markdown_parser::markdown_parser::RUNNABLE_BLOCK_MARKDOWN_LANG; use markdown_parser::CodeBlockText; use pathfinder_color::ColorU; use pathfinder_geometry::vector::vec2f; use galaxy_core::ui::builder::AnimatedButtonOptions; use galaxyui::clipboard::ClipboardContent; use galaxyui::elements::{ Align, Border, ChildAnchor, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, DispatchEventResult, EventHandler, Fill, Flex, FormattedTextElement, HyperlinkUrl, Icon, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentAnchor, ParentElement, Radius, SavePosition, ScrollbarWidth, Shrinkable, Stack, Text, Wrap, }; use galaxyui::keymap::Keystroke; use galaxyui::platform::Cursor; use galaxyui::ui_components::components::{UiComponent, UiComponentStyles}; use galaxyui::units::{IntoPixels, Pixels}; use galaxyui::{ AppContext, BlurContext, Element, Entity, FocusContext, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext, WeakViewHandle, }; use super::panel::{HEADER_HEIGHT, HEXAGON_ALERT_SVG_PATH}; use super::requests::{RequestStatus, Requests}; use super::utils::{ code_block_position_id, markdown_segments_from_text, render_prepared_response_button, render_request_limit_info, save_as_workflow_position_id, AssistantTranscriptPart, CodeBlockIndex, FormattedTranscriptMessage, MarkdownSegment, TranscriptPartSubType, }; use super::AI_ASSISTANT_SVG_PATH; use crate::appearance::Appearance; use crate::send_telemetry_from_ctx; use crate::server::telemetry::{SaveAsWorkflowModalSource, TelemetryEvent, WarpAIActionType}; use crate::ui_components::blended_colors; use crate::workspaces::user_workspaces::UserWorkspaces; const TRANSCRIPT_POSITION_ID: &str = "ai_assistant::transcript"; const TERMINAL_INPUT_SVG_PATH: &str = "bundled/svg/terminal-input.svg"; const USER_ICON_SVG_PATH: &str = "bundled/svg/user.svg"; const SAVE_WORKFLOW_ICON_PATH: &str = "bundled/svg/workflow.svg"; const BODY_FONT_SIZE: f32 = 13.; const CODE_FONT_SIZE: f32 = 12.; const WARNING_MESSAGE_FONT_SIZE: f32 = 10.; const PANEL_LEFT_MARGIN: f32 = 15.; const DETAILS_BOTTOM_MARGIN: f32 = 12.; const COPY_BUTTON_SIZE: f32 = 14.; const TERMINAL_INPUT_BUTTON_SIZE: f32 = 20.; const SAVE_AS_WORKFLOW_BUTTON_SIZE: f32 = 20.; const HOW_DO_I_FIX_PROMPT: &str = "How do I fix this?"; const SHOW_EXAMPLES_PROMPT: &str = "Show examples."; const WHAT_TO_DO_NEXT_PROMPT: &str = "What should I do next?"; const IN_FLIGHT_REQUEST_TEXT: &str = "Generating answer..."; const ACCURACY_NOTICE_TEXT: &str = "AI responses can be inaccurate."; const MISSING_CONTEXT_NOTICE_TEXT: &str = "Warp AI might forget earlier answers as conversations get long."; lazy_static::lazy_static! { static ref SCROLL_BUFFER_OFFSET_PX: Pixels = (10.).into_pixels(); } #[derive(Debug, Clone, Default)] pub struct CodeBlockMouseStateHandles { pub play_button: MouseStateHandle, pub play_button_tooltip: MouseStateHandle, pub copy_button: MouseStateHandle, pub copy_button_tooltip: MouseStateHandle, pub save_as_workflow_button: MouseStateHandle, pub save_as_workflow_button_tooltip: MouseStateHandle, } #[derive(Default)] struct MouseStateHandles { show_examples_button: MouseStateHandle, what_to_do_next_button: MouseStateHandle, how_do_i_fix_button: MouseStateHandle, } /// A view to render a Q/A style transcript. pub struct Transcript { view_handle: WeakViewHandle, requests_model: ModelHandle, selected_code_block: Option, clipped_scroll_state: ClippedScrollStateHandle, mouse_state_handles: MouseStateHandles, } #[derive(Debug, Clone)] pub enum TranscriptAction { CopyAnswerToClipboard { transcript_part_index: usize, }, CopyCodeToClipboard { code_block_index: CodeBlockIndex, }, PasteInTerminalInput { code_block_index: CodeBlockIndex, }, OpenWorkflowModal(CodeBlockIndex), ClickedCodeBlock { code_block_index: CodeBlockIndex, }, ClickedUrl(HyperlinkUrl), Keydown(Keystroke), /// A mouse down event outside of the other clickable elements (e.g. buttons, code blocks, etc.) MouseDown, } pub enum TranscriptEvent { PasteInTerminalInput { code_block_index: CodeBlockIndex }, FocusEditor, FocusTranscript, ClickedCodeBlock, OpenWorkflowModalWithCommand(String), } impl Entity for Transcript { type Event = TranscriptEvent; } impl TypedActionView for Transcript { type Action = TranscriptAction; fn handle_action(&mut self, action: &TranscriptAction, ctx: &mut ViewContext) { use TranscriptAction::*; match action { CopyAnswerToClipboard { transcript_part_index, } => { let answer = self .requests_model .as_ref(ctx) .transcript() .get(*transcript_part_index) .map(|p| p.assistant.formatted_message.raw.clone()); if let Some(answer) = answer { ctx.clipboard().write(ClipboardContent::plain_text(answer)); } send_telemetry_from_ctx!( TelemetryEvent::WarpAIAction { action_type: WarpAIActionType::CopyAnswer }, ctx ); } CopyCodeToClipboard { code_block_index } => { self.copy_code_to_clipboard(*code_block_index, ctx); } PasteInTerminalInput { code_block_index } => { self.paste_in_terminal_input(*code_block_index, ctx); } OpenWorkflowModal(code_block_index) => self.open_workflow_modal(*code_block_index, ctx), ClickedUrl(url) => { ctx.open_url(&url.url); } ClickedCodeBlock { code_block_index } => { self.selected_code_block = Some(*code_block_index); ctx.emit(TranscriptEvent::ClickedCodeBlock); ctx.notify(); } Keydown(keystroke) => self.handle_keydown(keystroke, ctx), MouseDown => { if self.selected_code_block.is_none() { ctx.emit(TranscriptEvent::FocusEditor); } else { ctx.emit(TranscriptEvent::FocusTranscript); } ctx.notify(); } } } } impl Transcript { pub fn new(requests_model: &ModelHandle, ctx: &mut ViewContext) -> Self { ctx.observe(requests_model, |_, _, ctx| ctx.notify()); Self { view_handle: ctx.handle(), requests_model: requests_model.to_owned(), selected_code_block: None, clipped_scroll_state: Default::default(), mouse_state_handles: Default::default(), } } fn copy_code_to_clipboard( &mut self, code_block_index: CodeBlockIndex, ctx: &mut ViewContext, ) { if let Some(code) = self.code_for_index(code_block_index, ctx) { ctx.clipboard().write(ClipboardContent::plain_text(code)); } send_telemetry_from_ctx!( TelemetryEvent::WarpAIAction { action_type: WarpAIActionType::CopyCode }, ctx ); } fn paste_in_terminal_input( &mut self, code_block_index: CodeBlockIndex, ctx: &mut ViewContext, ) { ctx.emit(TranscriptEvent::PasteInTerminalInput { code_block_index }); send_telemetry_from_ctx!( TelemetryEvent::WarpAIAction { action_type: WarpAIActionType::InsertIntoInput }, ctx ); } fn open_workflow_modal( &mut self, code_block_index: CodeBlockIndex, ctx: &mut ViewContext, ) { if let Some(code) = self.code_for_index(code_block_index, ctx) { ctx.emit(TranscriptEvent::OpenWorkflowModalWithCommand(code)); } send_telemetry_from_ctx!( TelemetryEvent::SaveAsWorkflowModal { source: SaveAsWorkflowModalSource::WarpAIPanel }, ctx ); } fn handle_keydown(&mut self, keystroke: &Keystroke, ctx: &mut ViewContext) { let Some(selected_block_index) = self.selected_code_block else { return; }; if keystroke.key == "down" { let new_index = self.next_code_block_index(ctx); if new_index.is_some() { self.selected_code_block = new_index; } else if keystroke.cmd { self.selected_code_block = None; self.scroll_to_bottom_of_transcript(ctx); ctx.emit(TranscriptEvent::FocusEditor); } ctx.notify(); } else if keystroke.key == "up" { let new_index = self.previous_code_block_index(ctx); if new_index.is_some() { self.selected_code_block = new_index; ctx.notify(); } } else if keystroke.cmd && keystroke.key == "c" { self.copy_code_to_clipboard(selected_block_index, ctx); } else if keystroke.cmd && keystroke.key == "enter" { self.paste_in_terminal_input(selected_block_index, ctx); } else if keystroke.cmd && keystroke.key == "s" { self.open_workflow_modal(selected_block_index, ctx); } else if keystroke.key == "escape" { self.selected_code_block = None; ctx.emit(TranscriptEvent::FocusEditor); ctx.notify(); } // If we took an action on a code block or changed code blocks, let's scroll to it // so the user knows what's going on. if let Some(selected_code_block) = self.selected_code_block { self.scroll_to_code_block(selected_code_block, ctx); } } /// Only scrolls to the code block if it isn't already in the viewport. fn scroll_to_code_block( &mut self, code_block_index: CodeBlockIndex, ctx: &mut ViewContext, ) { let Some(transcript_pos) = ctx.element_position_by_id(TRANSCRIPT_POSITION_ID) else { return; }; let Some(code_block_pos) = ctx.element_position_by_id(code_block_position_id(code_block_index)) else { return; }; let current_scroll_top_px = self.clipped_scroll_state.scroll_start(); let viewable_transcript_height_px = transcript_pos.height().into_pixels(); let code_block_start_y_px = code_block_pos.origin_y().into_pixels() - transcript_pos.origin_y().into_pixels(); let code_block_end_y_px = code_block_pos.origin_y().into_pixels() + code_block_pos.height().into_pixels(); // We only need to scroll if either the start of the code block is cut off or the end is cut off. if code_block_start_y_px < Pixels::zero() || code_block_end_y_px > viewable_transcript_height_px { // In the case that the new scroll top exceeds the max scroll top, the after_layout // of clipped scrollable will re-adjust accordingly, so this is safe. self.clipped_scroll_state.scroll_to( current_scroll_top_px + code_block_start_y_px - *SCROLL_BUFFER_OFFSET_PX, ); ctx.notify(); } } pub fn scroll_to_bottom_of_transcript(&mut self, ctx: &mut ViewContext) { // This relies on the fact that the clipped scrollable will recompute // the scroll_top in after_layout if it exceeds the true max. self.clipped_scroll_state.scroll_to(f32::MAX.into_pixels()); ctx.notify(); } fn previous_code_block_index(&self, ctx: &mut ViewContext) -> Option { let transcript = self.requests_model.as_ref(ctx).transcript(); let selected_code_block_index = self.selected_code_block?; let transcript_index = selected_code_block_index.transcript_index(); // Try to find the prev code block in the current part. let found = transcript .get(transcript_index) .and_then(|p| p.prev_code_block_index(selected_code_block_index)); // If it's not in the current part, then take the last code block from the closest // transcript part to this one (in reverse). found.or_else(|| { transcript .get(..transcript_index)? .iter() .rev() .find_map(|part| part.last_code_block_index()) }) } fn next_code_block_index(&self, ctx: &mut ViewContext) -> Option { let transcript = self.requests_model.as_ref(ctx).transcript(); let selected_code_block_index = self.selected_code_block?; let transcript_index = selected_code_block_index.transcript_index(); // Try to find the next code block in the current part. let found = transcript .get(transcript_index) .and_then(|p| p.next_code_block_index(selected_code_block_index)); // If it's not in the current part, then take the first code block from the closest // transcript part to this one (in sequence). found.or_else(|| { transcript .get(transcript_index + 1..)? .iter() .find_map(|part| part.first_code_block_index()) }) } pub fn select_last_code_block(&mut self, ctx: &mut ViewContext) { let transcript = self.requests_model.as_ref(ctx).transcript(); // The last code block will be the last code block in the first transcript part starting from the end. let code_block_index = transcript .iter() .rev() .find_map(|p| p.last_code_block_index()); self.selected_code_block = code_block_index; if let Some(new_code_block) = self.selected_code_block { self.scroll_to_code_block(new_code_block, ctx); ctx.emit(TranscriptEvent::ClickedCodeBlock); } ctx.notify(); } pub fn clear_selected_block(&mut self, ctx: &mut ViewContext) { self.selected_code_block = None; ctx.notify(); } pub fn code_for_index( &self, code_block_index: CodeBlockIndex, app: &AppContext, ) -> Option { let transcript = self.requests_model.as_ref(app).transcript(); transcript .get(code_block_index.transcript_index()) .and_then(|p| p.code_for_block(code_block_index).map(ToOwned::to_owned)) } pub fn reset(&mut self, ctx: &mut ViewContext) { self.selected_code_block = None; ctx.notify(); } } /// Rendering-related implementation. impl Transcript { fn render_code_block_actions( &self, code_block_index: CodeBlockIndex, appearance: &Appearance, code_block_info: &CodeBlockText, mouse_state_handles: &CodeBlockMouseStateHandles, ) -> Box { let mut buttons = Flex::row() .with_main_axis_alignment(MainAxisAlignment::End) .with_cross_axis_alignment(CrossAxisAlignment::End) .with_main_axis_size(MainAxisSize::Max); let copy_button = appearance .ui_builder() .copy_button(COPY_BUTTON_SIZE, mouse_state_handles.copy_button.clone()) .build() .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(TranscriptAction::CopyCodeToClipboard { code_block_index, }); }) .with_cursor(Cursor::PointingHand) .finish(); buttons.add_child(appearance.ui_builder().tool_tip_on_element( "Copy code to clipboard [Cmd + C]".to_string(), mouse_state_handles.copy_button_tooltip.clone(), copy_button, ParentAnchor::TopRight, ChildAnchor::BottomRight, vec2f(0., -5.), )); if code_block_info.lang.ends_with("sh") || code_block_info.lang == RUNNABLE_BLOCK_MARKDOWN_LANG { let insert_button = appearance .ui_builder() .animated_button( mouse_state_handles.play_button.clone(), TERMINAL_INPUT_SVG_PATH, AnimatedButtonOptions { size: TERMINAL_INPUT_BUTTON_SIZE, padding: Some(4.), color: None, with_accent_animations: true, circular: true, }, ) .build() .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(TranscriptAction::PasteInTerminalInput { code_block_index, }); }) .with_cursor(Cursor::PointingHand) .finish(); buttons.add_child( Container::new(appearance.ui_builder().tool_tip_on_element( "Insert code into terminal input [Cmd + Enter]".to_string(), mouse_state_handles.play_button_tooltip.clone(), insert_button, ParentAnchor::TopRight, ChildAnchor::BottomRight, vec2f(0., -5.), )) .with_margin_left(10.) .with_margin_bottom(-4.) .finish(), ); let save_as_workflow_button = appearance .ui_builder() .animated_button( mouse_state_handles.save_as_workflow_button.clone(), SAVE_WORKFLOW_ICON_PATH, AnimatedButtonOptions { size: SAVE_AS_WORKFLOW_BUTTON_SIZE, padding: Some(4.), color: None, with_accent_animations: true, circular: true, }, ) .build() .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(TranscriptAction::OpenWorkflowModal(code_block_index)) }) .with_cursor(Cursor::PointingHand) .finish(); buttons.add_child( SavePosition::new( Container::new(appearance.ui_builder().tool_tip_on_element( "Save as workflow [Cmd + S]".to_string(), mouse_state_handles.save_as_workflow_button_tooltip.clone(), save_as_workflow_button, ParentAnchor::TopRight, ChildAnchor::BottomRight, vec2f(0., -5.), )) .with_margin_left(2.) .with_margin_bottom(-4.) .finish(), &save_as_workflow_position_id(code_block_index), ) .finish(), ); } buttons.finish() } fn render_assistant_answer( &self, transcript_part_index: usize, part: &AssistantTranscriptPart, appearance: &Appearance, ) -> Box { let theme = appearance.theme(); let background_color = theme.surface_2().into_solid(); let icon = if part.is_error { ConstrainedBox::new( Icon::new(HEXAGON_ALERT_SVG_PATH, appearance.theme().ui_error_color()).finish(), ) .with_height(18.) .with_width(18.) .finish() } else { ConstrainedBox::new( Icon::new( AI_ASSISTANT_SVG_PATH, theme.main_text_color(background_color.into()), ) .finish(), ) .with_height(16.) .with_width(16.) .finish() }; let bottom_right_element = part.copy_all_tooltip_and_button_mouse_handles.clone().map( |(tooltip_handle, button_handle)| { let copy_button = appearance .ui_builder() .copy_button(16., button_handle) .build() .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(TranscriptAction::CopyAnswerToClipboard { transcript_part_index, }) }) .with_cursor(Cursor::PointingHand) .finish(); appearance.ui_builder().tool_tip_on_element( "Copy answer to clipboard".to_string(), tooltip_handle, copy_button, ParentAnchor::TopRight, ChildAnchor::BottomRight, vec2f(0., -5.), ) }, ); self.render_message( &part.formatted_message, background_color, icon, bottom_right_element, appearance, ) } fn render_user_prompt( &self, dialogue: &FormattedTranscriptMessage, appearance: &Appearance, ) -> Box { let theme = appearance.theme(); let background_color = theme.surface_1().into_solid(); let icon = ConstrainedBox::new( Icon::new( USER_ICON_SVG_PATH, theme.main_text_color(background_color.into()), ) .finish(), ) .with_height(16.) .with_width(16.) .finish(); self.render_message(dialogue, background_color, icon, None, appearance) } /// Renders a single message (whether that be a user's prompt or assistant's answer). fn render_message( &self, dialogue: &FormattedTranscriptMessage, background_color: ColorU, icon: Box, bottom_right_element: Option>, appearance: &Appearance, ) -> Box { let theme = appearance.theme(); let inline_code_bg_color = appearance.theme().surface_3().into_solid(); let body = if let Some(parts) = &dialogue.markdown { let mut column = Flex::column(); for part in parts { let column_part = match part { MarkdownSegment::Other { formatted_text, highlighted_hyperlink, } => FormattedTextElement::new( formatted_text.to_owned(), BODY_FONT_SIZE, appearance.ui_font_family(), appearance.monospace_font_family(), theme.main_text_color(theme.surface_2()).into_solid(), highlighted_hyperlink.clone(), ) .with_inline_code_properties( Some(theme.nonactive_ui_text_color().into()), Some(inline_code_bg_color), ) .register_default_click_handlers(move |url, ctx, _| { ctx.dispatch_typed_action(TranscriptAction::ClickedUrl(url)); }) .finish(), MarkdownSegment::CodeBlock { index, code, mouse_state_handles, } => { let actions = self.render_code_block_actions( *index, appearance, code, mouse_state_handles, ); let code = code.code.clone(); let (border_fill, border_width, padding) = if self.selected_code_block == Some(*index) { (appearance.theme().accent(), 1.5, 11.5) } else { (appearance.theme().outline(), 1., 12.) }; let code_block_index = *index; EventHandler::new( Container::new( SavePosition::new( Container::new( Flex::column() .with_child( appearance .ui_builder() .wrappable_text(code, true) .with_style(UiComponentStyles { font_family_id: Some( appearance.monospace_font_family(), ), font_size: Some(CODE_FONT_SIZE), ..Default::default() }) .build() .with_margin_bottom(10.) .finish(), ) .with_child(actions) .finish(), ) .with_uniform_padding(padding) .with_border( Border::all(border_width).with_border_fill(border_fill), ) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.))) .finish(), &code_block_position_id(code_block_index), ) .finish(), ) .with_margin_top(10.) .with_margin_bottom(10.) .finish(), ) .on_left_mouse_down(move |ctx, _, _| { ctx.dispatch_typed_action(TranscriptAction::ClickedCodeBlock { code_block_index, }); DispatchEventResult::StopPropagation }) .finish() } }; column.add_child(column_part); } column.finish() } else { // If we don't have the markdown representation, just render it as basic text. appearance .ui_builder() .wrappable_text(dialogue.raw.to_owned(), true) .with_style(UiComponentStyles { font_size: Some(BODY_FONT_SIZE), ..Default::default() }) .build() .finish() }; let mut final_col = Flex::column().with_child(body); if let Some(bottom_right_element) = bottom_right_element { final_col.add_child( Align::new( Container::new(bottom_right_element) .with_margin_top(16.) .finish(), ) .right() .finish(), ); } let row = Flex::row() .with_main_axis_size(MainAxisSize::Max) .with_child( Container::new(icon) .with_margin_right(12.) .with_margin_top(3.) .finish(), ) .with_child(Shrinkable::new(1., Container::new(final_col.finish()).finish()).finish()); Container::new(row.finish()) .with_background_color(background_color) .with_padding_left(PANEL_LEFT_MARGIN) .with_padding_top(16.) .with_padding_bottom(16.) .with_padding_right(20.) .finish() } fn render_prepared_responses(&self, appearance: &Appearance) -> Box { Wrap::row() .with_run_spacing(10.) .with_main_axis_alignment(MainAxisAlignment::Center) .with_child(render_prepared_response_button( appearance, self.mouse_state_handles.what_to_do_next_button.clone(), None, Some(8.), WHAT_TO_DO_NEXT_PROMPT, )) .with_child( Container::new(render_prepared_response_button( appearance, self.mouse_state_handles.show_examples_button.clone(), None, Some(8.), SHOW_EXAMPLES_PROMPT, )) .with_margin_left(10.) .with_margin_right(10.) .finish(), ) .with_child(render_prepared_response_button( appearance, self.mouse_state_handles.how_do_i_fix_button.clone(), None, Some(8.), HOW_DO_I_FIX_PROMPT, )) .finish() } fn render_warning_message(&self, message: String, appearance: &Appearance) -> Box { Flex::row() .with_main_axis_size(MainAxisSize::Max) .with_main_axis_alignment(MainAxisAlignment::Center) .with_child( Text::new_inline( message, appearance.ui_font_family(), WARNING_MESSAGE_FONT_SIZE, ) .with_color(blended_colors::text_sub( appearance.theme(), appearance.theme().background(), )) .finish(), ) .finish() } } impl View for Transcript { fn ui_name() -> &'static str { "AIAssistantTranscript" } fn render(&self, app: &AppContext) -> Box { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); let transcript = self.requests_model.as_ref(app).transcript(); let request_status = self.requests_model.as_ref(app).request_status(); let num_remaining_reqs = self.requests_model.as_ref(app).num_remaining_reqs(); let mut blocks = Flex::column(); for (index, part) in transcript.iter().enumerate() { blocks.add_child(self.render_user_prompt(&part.user, appearance)); blocks.add_child(self.render_assistant_answer(index, &part.assistant, appearance)); } if let RequestStatus::InFlight { request, .. } = request_status { blocks.add_child(self.render_user_prompt(request, appearance)); let transcript_part_index = transcript.len(); let in_flight_request_markdown = markdown_segments_from_text( transcript_part_index, TranscriptPartSubType::Answer, IN_FLIGHT_REQUEST_TEXT, ); blocks.add_child(self.render_assistant_answer( transcript_part_index, &AssistantTranscriptPart { is_error: false, copy_all_tooltip_and_button_mouse_handles: None, formatted_message: FormattedTranscriptMessage { markdown: in_flight_request_markdown, raw: IN_FLIGHT_REQUEST_TEXT.to_owned(), }, }, appearance, )); } if !transcript.is_empty() && matches!(request_status, RequestStatus::NotInFlight) { // Only show the prepared responses if the last response wasn't an error // and the user still has remaining requests. if !transcript.last().is_none_or(|p| p.assistant.is_error) && num_remaining_reqs > 0 { blocks.add_child( Container::new(self.render_prepared_responses(appearance)) .with_margin_top(15.) .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 { blocks.add_child( Container::new(render_request_limit_info( &self.requests_model, app, appearance, )) .with_margin_top(15.) .finish(), ); } let current_transcript_summarized = self .requests_model .as_ref(app) .current_transcript_summarized(); blocks.add_child( Container::new( self.render_warning_message(ACCURACY_NOTICE_TEXT.to_string(), appearance), ) .with_margin_top(DETAILS_BOTTOM_MARGIN) .with_margin_bottom(if current_transcript_summarized { DETAILS_BOTTOM_MARGIN / 2. } else { DETAILS_BOTTOM_MARGIN }) .finish(), ); if current_transcript_summarized { blocks.add_child( Container::new(self.render_warning_message( MISSING_CONTEXT_NOTICE_TEXT.to_string(), appearance, )) .with_margin_bottom(DETAILS_BOTTOM_MARGIN) .finish(), ); } } // Note: we don't render a scrollbar because the gutter makes the segmented transcript // look "broken". let transcript = SavePosition::new( ClippedScrollable::vertical( self.clipped_scroll_state.clone(), blocks.finish(), ScrollbarWidth::None, theme.disabled_text_color(theme.background()).into(), theme.main_text_color(theme.background()).into(), Fill::None, ) .with_padding_end(0.) .with_padding_start(0.) .finish(), TRANSCRIPT_POSITION_ID, ) .finish(); let mut navigatable_transcript = EventHandler::new(transcript).on_left_mouse_down(|ctx, _, _| { ctx.dispatch_typed_action(TranscriptAction::MouseDown); DispatchEventResult::StopPropagation }); // Only handle keydown events when a code block is selected and the transcript is focused. let is_focused = self .view_handle .upgrade(app) .is_some_and(|v| v.is_focused(app)); if self.selected_code_block.is_some() && is_focused { navigatable_transcript = navigatable_transcript.on_keydown(|ctx, _, keystroke| { ctx.dispatch_typed_action(TranscriptAction::Keydown(keystroke.to_owned())); DispatchEventResult::StopPropagation }); } let mut stack = Stack::new(); stack.add_child( Container::new(navigatable_transcript.finish()) .with_padding_top(HEADER_HEIGHT) .finish(), ); stack.finish() } fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext) { if focus_ctx.is_self_focused() { // Force a re-render to reflect the fact that this view is now focused. ctx.notify(); } } fn on_blur(&mut self, blur_ctx: &BlurContext, ctx: &mut ViewContext) { if blur_ctx.is_self_blurred() { // Force a re-render to reflect the fact that this view is now blurred. ctx.notify(); } } } #[cfg(test)] #[path = "transcript_tests.rs"] mod transcript_tests;