use std::cmp::Ordering; use std::path::Path; use std::rc::Rc; use std::sync::Arc; use std::time::Duration; use galaxy_core::features::FeatureFlag; use galaxy_core::report_error; use galaxy_core::semantic_selection::SemanticSelection; use galaxy_core::ui::appearance::Appearance; use galaxy_core::ui::theme::color::internal_colors; use galaxy_editor::content::buffer::InitialBufferState; use galaxy_editor::render::element::VerticalExpansionBehavior; use galaxyui::clipboard::ClipboardContent; use galaxyui::elements::new_scrollable::SingleAxisConfig; use galaxyui::elements::{ resizable_state_handle, Border, ChildAnchor, ChildView, ClippedScrollStateHandle, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, DragBarSide, DropShadow, Empty, Expanded, Fill, Flex, FormattedTextElement, Highlight, HighlightedHyperlink, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle, NewScrollable, OffsetPositioning, ParentElement, PositionedElementAnchor, PositionedElementOffsetBounds, Radius, Resizable, ResizableStateHandle, SavePosition, SelectableArea, SelectionHandle, Shrinkable, SizeConstraintCondition, SizeConstraintSwitch, Stack, Text, }; use galaxyui::fonts::{Properties, Style, Weight}; use galaxyui::keymap::{EditableBinding, Keystroke}; use galaxyui::platform::{Cursor, OperatingSystem}; use galaxyui::r#async::{SpawnedFutureHandle, Timer}; use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles}; use galaxyui::{ AppContext, Element, Entity, EntityId, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle, }; use lazy_static::lazy_static; use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine}; use parking_lot::{FairMutex, RwLock}; use pathfinder_color::ColorU; use pathfinder_geometry::vector::vec2f; use settings::Setting as _; use super::cli_controller::{CLISubagentController, CLISubagentEvent, UserTakeOverReason}; use super::model::{AIBlockModel, AIBlockModelHelper, AIBlockModelImpl, AIBlockOutputStatus}; use super::view_impl::common::{ render_debug_footer, render_failed_output, render_informational_footer, render_text_sections, DebugFooterProps, FailedOutputProps, TextSectionsProps, }; use super::view_impl::output::are_all_text_sections_empty; use super::{EmbeddedCodeEditorView, SecretRedactionState, TableSectionHandles}; use crate::ai::agent::conversation::AIConversationId; use crate::ai::agent::icons::yellow_stop_icon; use crate::ai::agent::task::TaskId; use crate::ai::agent::{ AIAgentActionType, AIAgentInput, AIAgentOutput, AIAgentOutputMessageType, AIAgentPtyWriteMode, AIAgentText, AIAgentTextSection, CancellationReason, ProgrammingLanguage, WebSearchStatus, }; use crate::ai::blocklist::block::view_impl::common::{ render_query_text, UserQueryProps, BLOCKED_ACTION_MESSAGE_FOR_GREP_OR_FILE_GLOB, BLOCKED_ACTION_MESSAGE_FOR_READING_FILES, BLOCKED_ACTION_MESSAGE_FOR_SEARCHING_CODEBASE, BLOCKED_ACTION_MESSAGE_FOR_WRITE_TO_LONG_RUNNING_SHELL_COMMAND, LOAD_OUTPUT_MESSAGE_FOR_FILE_GLOB, LOAD_OUTPUT_MESSAGE_FOR_GREP, LOAD_OUTPUT_MESSAGE_FOR_READING_FILES, LOAD_OUTPUT_MESSAGE_FOR_SEARCH_CODEBASE, LOAD_OUTPUT_MESSAGE_FOR_WEB_SEARCH, }; use crate::ai::blocklist::block::TextLocation; use crate::ai::blocklist::code_block::CodeSnippetButtonHandles; use crate::ai::blocklist::inline_action::inline_action_icons::icon_size; use crate::ai::blocklist::permissions::is_agent_mode_autonomy_allowed; use crate::ai::blocklist::{ BlocklistAIActionModel, BlocklistAIController, BlocklistAIHistoryEvent, BlocklistAIPermissions, }; use crate::ai::control_code_parser::{parse_control_codes_from_bytes, ParsedControlCodeOutput}; use crate::ai::execution_profiles::profiles::{ AIExecutionProfilesModel, AIExecutionProfilesModelEvent, }; use crate::code::editor::view::{CodeEditorEvent, CodeEditorRenderOptions, CodeEditorView}; use crate::code::editor_management::CodeSource; use crate::editor::InteractionState; use crate::menu::{Event as MenuEvent, Menu, MenuItemFields, MenuVariant}; use crate::server::telemetry::TelemetryEvent; use crate::settings::AISettings; use crate::settings_view::SettingsSection; use crate::terminal::input::SET_INPUT_MODE_TERMINAL_ACTION_NAME; use crate::terminal::model::block::BlockId; use crate::terminal::safe_mode_settings::get_secret_obfuscation_mode; use crate::terminal::{ShellLaunchData, TerminalModel}; use crate::ui_components::blended_colors; use crate::ui_components::icons::Icon; use crate::util::link_detection::{detect_links, DetectedLinksState}; use crate::view_components::action_button::{ ButtonSize, KeystrokeSource, NakedTheme, PrimaryTheme, }; use crate::view_components::compactible_action_button::{ render_compact_and_regular_button_rows, CompactibleActionButton, RenderCompactibleActionButton, }; use crate::view_components::compactible_split_action_button::CompactibleSplitActionButton; use crate::view_components::DismissibleToast; use crate::workspace::WorkspaceAction; use crate::{send_telemetry_from_ctx, BlocklistAIHistoryModel, ToastStack}; const MENU_WIDTH: f32 = 200.0; const MAX_HEIGHT: f32 = 320.0; const MIN_RESIZABLE_WIDTH: f32 = 360.0; const MIN_RESIZABLE_HEIGHT: f32 = 40.0; const MIN_REMAINING_WINDOW_WIDTH: f32 = 200.0; const MIN_REMAINING_WINDOW_HEIGHT: f32 = 100.0; const AVATAR_RIGHT_MARGIN: f32 = 8.; const CONTENT_PADDING: f32 = 12.; const ALLOW_ACTION_POSITION_ID: &str = "allow-action-position-id"; const USER_QUERY_POSITION_ID: &str = "cli-subagent-user-query-position-id"; lazy_static! { static ref ACCEPT_KEYSTROKE: Keystroke = Keystroke { key: "enter".to_owned(), ..Default::default() }; static ref REJECT_KEYSTROKE: Keystroke = Keystroke::parse("ctrl-c").expect("Failed to parse take over keystroke"); static ref AUTO_APPROVE_KEYSTROKE: Keystroke = { let binding = if OperatingSystem::get().is_mac() { "cmd-shift-I" } else { "ctrl-shift-I" }; Keystroke::parse(binding).expect("Failed to parse auto approve keystroke") }; } const HAS_PENDING_CLI_ACTION_CONTEXT_KEY: &str = "HasPendingCLIAgentAction"; const HAS_PENDING_NON_TRANSFER_CONTROL_ACTION_CONTEXT_KEY: &str = "HasPendingNonTransferControlCLIAgentAction"; const BLOCKED_ACTION_MESSAGE_FOR_TRANSFER_CONTROL: &str = "Agent is asking you to take control."; const BLOCKED_ACTION_MESSAGE_FOR_INTERRUPT: &str = "Agent wants to interrupt this running command with Ctrl+C."; pub fn init(app: &mut AppContext) { use galaxyui::keymap::macros::*; use galaxyui::keymap::FixedBinding; app.register_fixed_bindings([ FixedBinding::new( ACCEPT_KEYSTROKE.normalized(), CLISubagentAction::ExecuteBlockedAction, id!(CLISubagentView::ui_name()) & id!(HAS_PENDING_CLI_ACTION_CONTEXT_KEY), ), FixedBinding::new( REJECT_KEYSTROKE.normalized(), CLISubagentAction::RejectBlockedAction { should_user_take_over: false, }, id!(CLISubagentView::ui_name()) & id!(HAS_PENDING_CLI_ACTION_CONTEXT_KEY), ), FixedBinding::new( "escape", CLISubagentAction::RejectBlockedAction { should_user_take_over: true, }, id!(CLISubagentView::ui_name()) & id!(HAS_PENDING_NON_TRANSFER_CONTROL_ACTION_CONTEXT_KEY), ), FixedBinding::new( AUTO_APPROVE_KEYSTROKE.normalized(), CLISubagentAction::ExecuteAndAutoApprove, id!(CLISubagentView::ui_name()) & id!(HAS_PENDING_NON_TRANSFER_CONTROL_ACTION_CONTEXT_KEY), ), ]); app.register_editable_bindings([EditableBinding::new( SET_INPUT_MODE_TERMINAL_ACTION_NAME, "Take control of running command", CLISubagentAction::TakeControlOfRunningCommand, ) .with_mac_key_binding("cmd-i") .with_linux_or_windows_key_binding("ctrl-i") .with_context_predicate( id!(CLISubagentView::ui_name()) & id!(HAS_PENDING_CLI_ACTION_CONTEXT_KEY), )]); } #[derive(Default)] struct StateHandles { invalid_api_key_button_handle: MouseStateHandle, debug_copy_button_handle: MouseStateHandle, submit_issue_button_handle: MouseStateHandle, query_selection_handle: SelectionHandle, output_selection_handle: SelectionHandle, action_selection_handle: SelectionHandle, speedbump_checkbox_handle: MouseStateHandle, ai_settings_link: HighlightedHyperlink, output_scroll_state: ClippedScrollStateHandle, action_scroll_state: ClippedScrollStateHandle, input_scroll_state: ClippedScrollStateHandle, query_scroll_state: ClippedScrollStateHandle, input_hover_state: MouseStateHandle, dismiss_input_mouse_state: MouseStateHandle, approval_header_mouse_state: MouseStateHandle, } pub struct CLISubagentView { block_id: BlockId, model: Rc>, subagent_controller: ModelHandle, action_model: ModelHandle, terminal_model: Arc>, conversation_id: AIConversationId, task_id: TaskId, terminal_view_id: EntityId, state_handles: StateHandles, code_editor_views: Vec, code_editor_buttons: Vec, table_section_handles: Vec, secret_redaction_state: SecretRedactionState, link_detection_state: DetectedLinksState, selected_text: Arc>>, allow_button: CompactibleSplitActionButton, reject_button: CompactibleActionButton, take_over_button: CompactibleActionButton, transfer_control_button: CompactibleActionButton, allow_menu: ViewHandle>, is_allow_menu_open: bool, is_approval_expanded: bool, always_allow_write_to_pty_checked: bool, always_allow_read_files_checked: bool, is_input_dismissed: bool, input_dismiss_timer_handle: Option, resizable_width: ResizableStateHandle, resizable_height: ResizableStateHandle, current_working_directory: Option, shell_launch_data: Option, } impl CLISubagentView { #[allow(clippy::too_many_arguments)] pub fn new( block_id: BlockId, action_model: ModelHandle, subagent_controller: ModelHandle, terminal_model: Arc>, conversation_id: AIConversationId, task_id: TaskId, current_working_directory: Option, shell_launch_data: Option, ctx: &mut ViewContext, ) -> Self { let allow_button = CompactibleSplitActionButton::new( "Allow".to_string(), Some(KeystrokeSource::Fixed(ACCEPT_KEYSTROKE.clone())), ButtonSize::Small, CLISubagentAction::ExecuteBlockedAction, CLISubagentAction::ToggleAllowMenu, Icon::Check, true, Some(ALLOW_ACTION_POSITION_ID.to_string()), ctx, ); let reject_button = CompactibleActionButton::new( "Refine".to_string(), Some(KeystrokeSource::Fixed(REJECT_KEYSTROKE.clone())), ButtonSize::Small, CLISubagentAction::RejectBlockedAction { should_user_take_over: false, }, Icon::X, Arc::new(NakedTheme), ctx, ); let take_over_button = CompactibleActionButton::new( "Take over".to_string(), Some(KeystrokeSource::Binding( SET_INPUT_MODE_TERMINAL_ACTION_NAME, )), ButtonSize::Small, CLISubagentAction::RejectBlockedAction { should_user_take_over: true, }, Icon::Hand, Arc::new(NakedTheme), ctx, ); let transfer_control_button = CompactibleActionButton::new( "Take control".to_string(), Some(KeystrokeSource::Binding( SET_INPUT_MODE_TERMINAL_ACTION_NAME, )), ButtonSize::Small, CLISubagentAction::ExecuteBlockedAction, Icon::Hand, Arc::new(PrimaryTheme), ctx, ); let allow_menu = ctx.add_typed_action_view(|ctx| { let theme = Appearance::as_ref(ctx).theme(); Menu::new() .with_width(MENU_WIDTH) .with_menu_variant(MenuVariant::Fixed) .with_border(Border::all(1.).with_border_fill(theme.outline())) .prevent_interaction_with_other_elements() }); allow_menu.update(ctx, |menu, ctx| { menu.set_items( vec![ MenuItemFields::new("Accept".to_string()) .with_key_shortcut_label(Some(ACCEPT_KEYSTROKE.displayed())) .with_on_select_action(CLISubagentAction::ExecuteBlockedAction) .into_item(), MenuItemFields::new("Auto-approve".to_string()) .with_key_shortcut_label(Some(AUTO_APPROVE_KEYSTROKE.displayed())) .with_on_select_action(CLISubagentAction::ExecuteAndAutoApprove) .into_item(), ], ctx, ); }); ctx.subscribe_to_view(&allow_menu, |me, _menu, event, ctx| match event { MenuEvent::Close { .. } => { me.is_allow_menu_open = false; ctx.notify(); } MenuEvent::ItemSelected | MenuEvent::ItemHovered => {} }); // We want to default the checkbox to true when rendering the speedbump for the first time. // Otherwise, update it when the permission changes. let always_allow_write_to_pty_checked = if should_show_write_to_pty_speedbump(ctx) { true } else { BlocklistAIPermissions::as_ref(ctx) .can_write_to_pty(&conversation_id, Some(ctx.view_id()), ctx) .is_always_allow() }; let always_allow_read_files_checked = if should_show_read_files_speedbump(ctx) { true } else { BlocklistAIPermissions::as_ref(ctx) .can_read_files(Some(&conversation_id), Vec::new(), Some(ctx.view_id()), ctx) .is_allowed() }; let history_model = BlocklistAIHistoryModel::handle(ctx); let mut task_id_clone = task_id.clone(); ctx.subscribe_to_model( &history_model, move |me, _history_model, event, ctx| match event { BlocklistAIHistoryEvent::UpgradedTask { optimistic_id: old_id, server_id: new_id, .. } if *old_id == task_id_clone => { task_id_clone = new_id.clone(); me.task_id = new_id.clone(); } BlocklistAIHistoryEvent::AppendedExchange { exchange_id, task_id, conversation_id, .. } => { if task_id == &task_id_clone { if let Ok(model) = AIBlockModelImpl::::new( *exchange_id, *conversation_id, false, false, ctx, ) { model.on_updated_output( Box::new(|me, ctx| { me.handle_updated_exchange_output(ctx); }), ctx, ); me.model = Rc::new(model); me.set_state_from_updated_inputs(ctx); } ctx.notify(); } } _ => { ctx.notify(); } }, ); ctx.subscribe_to_model( &AIExecutionProfilesModel::handle(ctx), move |me, _, event, ctx| { let should_update_permissions = match event { AIExecutionProfilesModelEvent::UpdatedActiveProfile { terminal_view_id } => { *terminal_view_id == me.terminal_view_id } AIExecutionProfilesModelEvent::ProfileUpdated(profile_id) => { let active_profile = AIExecutionProfilesModel::as_ref(ctx) .active_profile(Some(me.terminal_view_id), ctx); *profile_id == *active_profile.id() } _ => false, }; if should_update_permissions { let ai_permission = BlocklistAIPermissions::as_ref(ctx); if should_show_write_to_pty_speedbump(ctx) { me.always_allow_write_to_pty_checked = ai_permission .can_write_to_pty(&me.conversation_id, Some(me.terminal_view_id), ctx) .is_always_allow(); } if should_show_read_files_speedbump(ctx) { me.always_allow_read_files_checked = ai_permission .get_read_files_setting(ctx, Some(me.terminal_view_id)) .is_always_allow(); } ctx.notify(); } }, ); let exchange_id = history_model .as_ref(ctx) .conversation(&conversation_id) .and_then(|c| { c.get_task(&task_id) .and_then(|t| t.last_exchange().map(|e| e.id)) }) .expect("Exchange exists."); let model = AIBlockModelImpl::::new( exchange_id, conversation_id, false, false, ctx, ) .expect("Exchange exists."); model.on_updated_output( Box::new(|me, ctx| { me.handle_updated_exchange_output(ctx); }), ctx, ); // Permission changes can arrive after the model output stops streaming. // Redraw immediately so the approval controls appear without window focus. ctx.subscribe_to_model(&action_model, |me, _, event, ctx| { if event.conversation_id() == Some(me.conversation_id) { if matches!( event, crate::ai::blocklist::action_model::BlocklistAIActionEvent::ActionBlockedOnUserConfirmation { .. } ) { me.is_approval_expanded = true; } ctx.notify(); } }); ctx.subscribe_to_model(&subagent_controller, |me, _, event, ctx| match event { CLISubagentEvent::UpdatedControl { block_id, .. } => { if *block_id == me.block_id { ctx.notify(); } } CLISubagentEvent::ToggledHideResponses => { me.reset_input_dismiss_timer(ctx); ctx.notify(); } _ => {} }); let mut view = Self { block_id, model: Rc::new(model), action_model, terminal_model, subagent_controller, conversation_id, task_id, terminal_view_id: ctx.view_id(), link_detection_state: Default::default(), code_editor_views: Default::default(), code_editor_buttons: Default::default(), table_section_handles: Default::default(), secret_redaction_state: Default::default(), state_handles: Default::default(), allow_button, reject_button, take_over_button, transfer_control_button, allow_menu, is_allow_menu_open: false, is_approval_expanded: true, always_allow_write_to_pty_checked, always_allow_read_files_checked, is_input_dismissed: false, input_dismiss_timer_handle: None, resizable_width: resizable_state_handle(MIN_RESIZABLE_WIDTH), resizable_height: resizable_state_handle(MAX_HEIGHT), current_working_directory, shell_launch_data, selected_text: Arc::new(RwLock::new(None)), }; view.set_state_from_updated_inputs(ctx); view.handle_updated_exchange_output(ctx); view } fn task_inputs_to_render(&self, app: &AppContext) -> Vec { let inputs = BlocklistAIHistoryModel::as_ref(app) .conversation(&self.conversation_id) .and_then(|conversation| conversation.get_task(&self.task_id)) .map(|task| { task.exchanges() .flat_map(|exchange| exchange.input.iter()) .cloned() .collect::>() }) .unwrap_or_else(|| self.model.inputs_to_render(app).to_vec()); inputs .into_iter() .rev() .find_map(|input| match &input { AIAgentInput::UserQuery { query, .. } if query != BlocklistAIController::cli_monitor_nudge_message() => { Some(input) } _ => None, }) .into_iter() .collect() } /// Builds the compact visible transcript for the monitor task. /// /// The view shows at most one user query and one assistant progress message. Historical tool /// calls and monitor nudges are implementation details, not user-facing transcript content. fn task_output_to_render(&self, app: &AppContext) -> AIAgentOutput { let Some(task) = BlocklistAIHistoryModel::as_ref(app) .conversation(&self.conversation_id) .and_then(|conversation| conversation.get_task(&self.task_id)) else { return self .model .status(app) .output_to_render() .map(|output| compact_monitor_output([output.get().clone()])) .unwrap_or_default(); }; let outputs = task.exchanges().filter_map(|exchange| { exchange .output_status .output() .map(|output| output.get().clone()) }); compact_monitor_output(outputs) } fn execute_pending_action(&mut self, ctx: &mut ViewContext) { let Some(blocked_action) = self.model.blocked_action(&self.action_model, ctx) else { return; }; self.action_model.update(ctx, |action_model, ctx| { action_model.execute_next_action_for_user(self.conversation_id, ctx); }); self.maybe_update_speedbump(&blocked_action.action, ctx); } fn has_pending_transfer_control_action(&self, app: &AppContext) -> bool { self.model .blocked_action(&self.action_model, app) .is_some_and(|action| { matches!( action.action, AIAgentActionType::TransferShellCommandControlToUser { .. } ) }) } fn handle_execute_blocked_action( &mut self, is_autoexecuted: bool, ctx: &mut ViewContext, ) { self.execute_pending_action(ctx); if is_autoexecuted { self.enable_autoexecute_override(ctx); } send_telemetry_from_ctx!( TelemetryEvent::CLISubagentActionExecuted { conversation_id: self.conversation_id, block_id: self.block_id.clone(), is_autoexecuted, }, ctx ); } fn handle_reject_blocked_action( &mut self, should_user_take_over: bool, ctx: &mut ViewContext, ) { self.reject_blocked_action(should_user_take_over, ctx); send_telemetry_from_ctx!( TelemetryEvent::CLISubagentActionRejected { conversation_id: self.conversation_id, block_id: self.block_id.clone(), user_took_over: should_user_take_over, }, ctx ); } fn take_control_of_running_command(&mut self, ctx: &mut ViewContext) { if self.has_pending_transfer_control_action(ctx) { self.handle_execute_blocked_action(false, ctx); } else { self.handle_reject_blocked_action(true, ctx); } } fn reject_blocked_action(&mut self, should_user_take_over: bool, ctx: &mut ViewContext) { let Some(blocked_action) = self.model.blocked_action(&self.action_model, ctx) else { return; }; self.action_model.update(ctx, |action_model, ctx| { action_model.cancel_action_with_id( self.conversation_id, &blocked_action.id, CancellationReason::ManuallyCancelled, ctx, ); }); if should_user_take_over { self.subagent_controller.update(ctx, |controller, ctx| { controller.switch_control_to_user(UserTakeOverReason::Manual, ctx); }); ctx.notify(); } self.maybe_update_speedbump(&blocked_action.action, ctx); } fn enable_autoexecute_override(&mut self, ctx: &mut ViewContext) { let Some(conversation) = BlocklistAIHistoryModel::as_ref(ctx).conversation(&self.conversation_id) else { return; }; if !conversation.autoexecute_any_action() { BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { history.toggle_autoexecute_override( &self.conversation_id, self.terminal_view_id, ctx, ); }); } } fn toggle_allow_menu(&mut self, ctx: &mut ViewContext) { self.is_allow_menu_open = !self.is_allow_menu_open; if self.is_allow_menu_open { ctx.focus(&self.allow_menu); } ctx.notify(); } // If the speedbump is shown, we update the settings such that the speedbump won't be shown again, and the permission reflect the checked value. // This is called on any user action instead of on render time to ensure the state is updated correctly. fn maybe_update_speedbump(&mut self, action: &AIAgentActionType, ctx: &mut ViewContext) { match action { AIAgentActionType::WriteToLongRunningShellCommand { .. } if should_show_write_to_pty_speedbump(ctx) => { AISettings::handle(ctx).update(ctx, |settings, ctx| { let _ = settings .should_show_agent_mode_write_to_pty_speedbump .set_value(false, ctx); }); BlocklistAIPermissions::handle(ctx).update(ctx, |permissions, ctx| { if let Err(e) = permissions.set_always_allow_write_to_pty( self.always_allow_write_to_pty_checked, self.terminal_view_id, ctx, ) { report_error!(e); } }); ctx.notify(); } AIAgentActionType::SearchCodebase(_) | AIAgentActionType::ReadFiles(_) | AIAgentActionType::Grep { .. } | AIAgentActionType::FileGlobV2 { .. } if should_show_read_files_speedbump(ctx) => { AISettings::handle(ctx).update(ctx, |settings, ctx| { let _ = settings .should_show_agent_mode_autoread_files_speedbump .set_value(false, ctx); }); BlocklistAIPermissions::handle(ctx).update(ctx, |permissions, ctx| { if let Err(e) = permissions.set_always_allow_read_files( self.always_allow_read_files_checked, self.terminal_view_id, ctx, ) { report_error!(e); } }); ctx.notify(); } _ => {} } } fn handle_updated_exchange_output(&mut self, ctx: &mut ViewContext) { let output = self.task_output_to_render(ctx); if !output.messages.is_empty() { self.handle_updated_output(&output, ctx); if self.model.status(ctx).is_complete() { self.handle_complete_output(&output, ctx); } } ctx.notify(); } fn handle_updated_output(&mut self, output: &AIAgentOutput, ctx: &mut ViewContext) { // Build the views and stream new content for suggested code snippets. output .all_text() .flat_map(|text| text.sections.iter()) .filter_map(|section| match section { AIAgentTextSection::Code { code, language, source, } => Some((code, language, source)), _ => None, }) .enumerate() .for_each(|(index, (code, language, source))| { self.handle_code_section_stream_update(index, code, language, source, ctx); }); if get_secret_obfuscation_mode(ctx).should_redact_secret() { self.secret_redaction_state .run_incremental_redaction_on_partial_output( output, get_secret_obfuscation_mode(ctx).is_visually_obfuscated(), ); } } fn handle_code_section_stream_update( &mut self, index: usize, code: &str, language: &Option, source: &Option, ctx: &mut ViewContext, ) { match self.code_editor_views.get_mut(index) { Some(embedded_view) => { embedded_view.view.update(ctx, |view, ctx| { // The language and starting line number may not be specified in the output for the first iteration. // Only set the language/starting line number the first time that they are specified or if they change. if embedded_view.language != *language { embedded_view.language = language.clone(); if let Some(extension) = language .as_ref() .and_then(|language| language.to_extension()) { // Since this is a code snippet, construct a fake path name for looking up the language. let fake_path = format!("/snippet.{extension}"); view.set_language_with_local_path(Path::new(&fake_path), ctx); } } let starting_line_number = source.as_ref().and_then(|s| { if let CodeSource::Link { range_start, .. } = s { range_start.as_ref().map(|ls| ls.line_num) } else { None } }); if view.starting_line_number() != starting_line_number { view.set_starting_line_number(starting_line_number); } // Update the buffer with just the new or deleted range. // Assumption: Only the end of the string is updated. // Assumption: The only time text is deleted is at the end of parsing, where it has partially // received the ``` end marker. // Ex: Iteration 57: "a += 12\n``" // Ex: Iteration 58: "a += 12" match code.len().cmp(&embedded_view.length) { Ordering::Greater => { view.append_at_end(&code[embedded_view.length..], ctx); ctx.notify(); } Ordering::Less => { view.truncate(code.len(), ctx); ctx.notify(); } Ordering::Equal => {} } embedded_view.length = code.len(); }); } None => { let view = ctx.add_typed_action_view(|ctx| { CodeEditorView::new( None, None, CodeEditorRenderOptions::new(VerticalExpansionBehavior::InfiniteHeight), ctx, ) .with_can_show_diff_ui(false) }); view.update(ctx, |view, ctx| { view.set_starting_line_number({ source.as_ref().and_then(|s| match s { CodeSource::Link { range_start, .. } => { range_start.as_ref().map(|ls| ls.line_num) } _ => None, }) }); view.set_show_current_line_highlights(false, ctx); view.set_interaction_state(InteractionState::Selectable, ctx); let state = InitialBufferState::plain_text(code); view.reset(state, ctx); ctx.notify(); }); ctx.subscribe_to_view(&view, |me, view, event, ctx| match event { CodeEditorEvent::SelectionChanged => { if view.as_ref(ctx).selected_text(ctx).is_some() { me.clear_other_selections(Some(view.id()), ctx); ctx.emit(CLISubagentViewEvent::TextSelected); } } CodeEditorEvent::CopiedEmptyText => { ctx.emit(CLISubagentViewEvent::CopiedEmptyText); } #[cfg(windows)] CodeEditorEvent::WindowsCtrlC { .. } => { ctx.emit(CLISubagentViewEvent::WindowsCtrlC); } _ => {} }); self.code_editor_views.push(EmbeddedCodeEditorView { view, language: Default::default(), length: code.len(), }); self.code_editor_buttons.push(Default::default()); } } } fn handle_complete_output(&mut self, output: &AIAgentOutput, ctx: &mut ViewContext) { // Run secret detection at the end of the stream to catch any secrets we might've missed while streaming, // due to secret patterns that may include whitespace within them (we delimit on whitespace with the optimized // secret detection approach). if get_secret_obfuscation_mode(ctx).is_visually_obfuscated() { self.secret_redaction_state .run_redaction_on_complete_output(output); } } fn reset_input_dismiss_timer(&mut self, ctx: &mut ViewContext) { self.is_input_dismissed = false; if let Some(handle) = self.input_dismiss_timer_handle.take() { handle.abort(); } let has_user_input = self .task_inputs_to_render(ctx) .iter() .any(|input| input.is_user_query()); let should_hide_responses = self .terminal_model .lock() .block_list() .active_block() .should_hide_responses(); if has_user_input && should_hide_responses { let handle = ctx.spawn_abortable( Timer::after(Duration::from_secs(4)), |me, _, ctx| { me.is_input_dismissed = true; me.input_dismiss_timer_handle = None; ctx.notify(); }, |_, _| {}, ); self.input_dismiss_timer_handle = Some(handle); } } fn set_state_from_updated_inputs(&mut self, ctx: &mut ViewContext) { // Clear existing link detection state self.link_detection_state.detected_links_by_location.clear(); self.reset_input_dismiss_timer(ctx); // Detect links in all user queries for (input_index, input) in self.task_inputs_to_render(ctx).iter().enumerate() { if let AIAgentInput::UserQuery { query, .. } = input { detect_links( &mut self.link_detection_state, query, TextLocation::Query { input_index }, self.current_working_directory.as_ref(), self.shell_launch_data.as_ref(), ); // Run secret redaction on user queries let secret_redaction_mode = get_secret_obfuscation_mode(ctx); if secret_redaction_mode.should_redact_secret() { let should_obfuscate = secret_redaction_mode.is_visually_obfuscated(); self.secret_redaction_state.run_redaction_for_location( query, TextLocation::Query { input_index }, should_obfuscate, ); } } } } /// Clears text selections at the `CLISubagentView` level (e.g. user query text). /// This does _not_ clear the selection of the child views (code blocks). fn clear_view_level_selection(&mut self) { self.state_handles.query_selection_handle.clear(); self.state_handles.output_selection_handle.clear(); self.state_handles.action_selection_handle.clear(); *self.selected_text.write() = None; } /// Clears all text selections in all components within this `CLISubagentView`'s view sub-hierarchy /// _other_ than the one that triggered a selection change. /// /// Call this after text is selected in one part of the view (e.g. a code snippet), to ensure /// that there's only one active selection at a time. fn clear_other_selections( &mut self, source_view_id: Option, ctx: &mut ViewContext, ) { for editor_view in self.code_editor_views.iter() { // Don't clear selections for the view that triggered this change. if source_view_id.is_some_and(|entity_id| editor_view.view.id() == entity_id) { continue; } editor_view .view .update(ctx, |view, ctx| view.clear_selection(ctx)); } // If the event was dispatched by a nested view (i.e. code block), // clear the text selection at the `CLISubagentView` level (outside the code block). // We want to have only 1 selection active at any one point in time. if source_view_id.is_some() { self.clear_view_level_selection(); } } /// Clears all text selections in all components within this `CLISubagentView`'s view sub-hierarchy. /// This includes the `CLISubagentView` level and all child views (code blocks). pub fn clear_all_selections(&mut self, ctx: &mut ViewContext) { self.clear_other_selections(None, ctx); self.clear_view_level_selection(); } pub fn selected_text(&self, ctx: &AppContext) -> Option { self.code_editor_views .iter() .find_map(|editor_view| editor_view.view.as_ref(ctx).selected_text(ctx)) .or_else(|| self.selected_text.read().clone()) .filter(|selection| !selection.is_empty()) } } #[derive(Debug, Clone)] pub enum CLISubagentViewEvent { TextSelected, CopiedEmptyText, #[cfg(windows)] WindowsCtrlC, } impl Entity for CLISubagentView { type Event = CLISubagentViewEvent; } impl View for CLISubagentView { fn ui_name() -> &'static str { "CLISubagentView" } fn render(&self, app: &galaxyui::AppContext) -> Box { let terminal_model = self.terminal_model.lock(); let Some(block) = terminal_model.block_list().block_with_id(&self.block_id) else { return Empty::new().finish(); }; if !block.is_agent_monitoring() || block.is_eligible_for_agent_handoff() { return Empty::new().finish(); } let appearance = Appearance::as_ref(app); let theme = appearance.theme(); let semantic_selection = SemanticSelection::handle(app).as_ref(app); let resizable_height = self .resizable_height .lock() .map(|g| g.size()) .unwrap_or(MAX_HEIGHT); let mut result = Flex::column() .with_main_axis_size(MainAxisSize::Min) .with_cross_axis_alignment(CrossAxisAlignment::Stretch); // Render user queries/follow-ups with avatar and interactive text let inputs = self.task_inputs_to_render(app); for (input_index, input) in inputs.iter().enumerate() { if let AIAgentInput::UserQuery { query, .. } = input { let text = render_query_text( UserQueryProps { text: query.to_owned(), query_prefix_highlight_len: None, detected_links_state: &self.link_detection_state, secret_redaction_state: &self.secret_redaction_state, input_index, is_selecting: self.state_handles.query_selection_handle.is_selecting(), is_ai_input_enabled: false, find_context: None, font_properties: &Properties { style: Style::Normal, weight: Weight::Normal, }, }, app, ); let selected_text = self.selected_text.clone(); let output_selection_handle = self.state_handles.output_selection_handle.clone(); let action_selection_handle = self.state_handles.action_selection_handle.clone(); let mut selectable_text = SelectableArea::new( self.state_handles.query_selection_handle.clone(), move |selection_args, ctx, _| { if let Some(selection) = selection_args .selection .filter(|selection| !selection.is_empty()) { output_selection_handle.clear(); action_selection_handle.clear(); *selected_text.write() = Some(selection); ctx.dispatch_typed_action(CLISubagentAction::SelectText); } }, text.finish(), ) .with_word_boundaries_policy(semantic_selection.word_boundary_policy()) .with_smart_select_fn(semantic_selection.smart_select_fn()); if FeatureFlag::RectSelection.is_enabled() { selectable_text = selectable_text.should_support_rect_select(); } let scrollable_container = render_scrollable_container( ScrollableContainerProps { scroll_state: self.state_handles.query_scroll_state.clone(), child: selectable_text.finish(), background_color: internal_colors::accent_bg(theme).into(), border: Some(Border::all(1.).with_border_fill(theme.accent())), max_height: resizable_height, }, app, ) .with_margin_bottom(8.) .finish(); let dismissable_stack = render_dismissable_container( DismissableContainerProps { child: scrollable_container, hover_state: self.state_handles.input_hover_state.clone(), dismiss_mouse_state: self.state_handles.dismiss_input_mouse_state.clone(), position_id: USER_QUERY_POSITION_ID.to_string(), }, app, ); if !self.is_input_dismissed { result.add_child(dismissable_stack); } } } // Render agent outputs and actions let mut output_items = Flex::column() .with_main_axis_size(MainAxisSize::Min) .with_cross_axis_alignment(CrossAxisAlignment::Stretch); let status = self.model.status(app); let blocked_action = self.model.blocked_action(&self.action_model, app); let has_blocked_action = blocked_action.is_some(); let should_hide_responses = block.should_hide_responses(); let mut has_visible_response = false; let output = self.task_output_to_render(app); if !output.messages.is_empty() { let mut code_section_index = 0; let mut text_section_index = 0; let mut table_section_index = 0; let mut image_section_index = 0; fn copy_code_action(snippet: String) -> CLISubagentAction { CLISubagentAction::CopyCode(snippet) } fn open_code_block_action(source: CodeSource) -> CLISubagentAction { CLISubagentAction::OpenCodeBlock(source) } for output_message in output.messages.iter() { match &output_message.message { AIAgentOutputMessageType::Text(AIAgentText { sections }) if !are_all_text_sections_empty(sections) => { has_visible_response = true; let text_color = blended_colors::text_main(theme, theme.surface_1()); output_items.add_child(render_text_sections( TextSectionsProps { model: self.model.as_ref(), starting_text_section_index: &mut text_section_index, starting_code_section_index: &mut code_section_index, starting_table_section_index: &mut table_section_index, starting_image_section_index: &mut image_section_index, sections, is_selecting_text: self .state_handles .output_selection_handle .is_selecting(), selectable: true, text_color, is_ai_input_enabled: false, secret_redaction_state: &self.secret_redaction_state, find_context: None, shell_launch_data: None, current_working_directory: None, embedded_code_editor_views: &self.code_editor_views, code_snippet_button_handles: &self.code_editor_buttons, table_section_handles: &self.table_section_handles, // CLI subagent blocks don't render block-list images yet, // so there are no per-image tooltip handles to thread. image_section_tooltip_handles: &[], open_code_block_action_factory: Some(&open_code_block_action), copy_code_action_factory: Some(©_code_action), detected_links: Some(&self.link_detection_state), item_spacing: CONTENT_PADDING, #[cfg(feature = "local_fs")] resolved_code_block_paths: None, #[cfg(feature = "local_fs")] resolved_blocklist_image_sources: None, }, app, )); } AIAgentOutputMessageType::Action(action) => { let is_cancelled = self .action_model .as_ref(app) .get_action_status(self.conversation_id, &action.id) .is_some_and(|status| status.is_cancelled()); if blocked_action.is_none() && !is_cancelled && !should_hide_responses { if let Some(rendered_action) = render_action(action.action.clone(), app) { has_visible_response = true; result.add_child( render_scrollable_container( ScrollableContainerProps { scroll_state: self .state_handles .action_scroll_state .clone(), child: rendered_action, background_color: internal_colors::neutral_2( appearance.theme(), ), border: Some(Border::all(1.).with_border_fill( internal_colors::neutral_3(theme), )), max_height: resizable_height, }, app, ) .with_margin_bottom(8.) .finish(), ); } } } AIAgentOutputMessageType::WebSearch(WebSearchStatus::Searching { query }) if !should_hide_responses => { has_visible_response = true; result.add_child( render_scrollable_container( ScrollableContainerProps { scroll_state: self.state_handles.action_scroll_state.clone(), child: render_web_search(query.clone(), app), background_color: internal_colors::neutral_2( appearance.theme(), ), border: Some( Border::all(1.) .with_border_fill(internal_colors::neutral_3(theme)), ), max_height: resizable_height, }, app, ) .with_margin_bottom(8.) .finish(), ); } _ => (), } } } let mut output_border = Border::all(1.).with_border_fill(internal_colors::neutral_3(theme)); if let AIBlockOutputStatus::Failed { error, .. } = &status { // While an automatic resume is still in flight, keep the failed exchange // quiet: don't switch to the error border, and skip the banner, the "won't // count towards usage" notice, and the debug footer. The full failure UI is // surfaced only once recovery has actually failed. Dogfood builds (Local/Dev) // opt out so developers still see every transport failure aggressively. if !error.should_suppress_during_recovery() { has_visible_response = true; output_border = Border::all(1.).with_border_color(theme.ui_error_color()); output_items.add_child(render_failed_output( FailedOutputProps { error, is_ai_input_enabled: false, invalid_api_key_button_handle: &self .state_handles .invalid_api_key_button_handle, aws_bedrock_credentials_error_view: None, icon_right_margin: AVATAR_RIGHT_MARGIN, }, app, )); if !self.model.is_restored() && !error.is_invalid_api_key() { output_items.add_child( Container::new(render_informational_footer( app, "This response won't count towards your usage. \"Take over\" to continue." .to_string(), )) .with_margin_top(8.) .with_margin_left(icon_size(app) + AVATAR_RIGHT_MARGIN) .finish(), ); output_items.add_child( Container::new(render_debug_footer( DebugFooterProps { conversation: self.model.conversation(app), model: self.model.as_ref(), debug_copy_button_handle: self .state_handles .debug_copy_button_handle .clone(), submit_issue_button_handle: self .state_handles .submit_issue_button_handle .clone(), should_render_feedback_below: true, }, |debug_id, ctx| { ctx.dispatch_typed_action(CLISubagentAction::CopyDebugId(debug_id)) }, |ctx| ctx.dispatch_typed_action(CLISubagentAction::OpenFeedbackDocs), app, )) .with_margin_top(8.) .with_margin_left(icon_size(app) + AVATAR_RIGHT_MARGIN) .finish(), ); } } } if !has_visible_response && !has_blocked_action && !should_hide_responses { result.add_child( render_scrollable_container( ScrollableContainerProps { scroll_state: self.state_handles.action_scroll_state.clone(), child: render_action_status( "Agent is monitoring the command…".to_string(), Icon::ClockRefresh, app, ), background_color: internal_colors::neutral_2(appearance.theme()), border: Some( Border::all(1.).with_border_fill(internal_colors::neutral_3(theme)), ), max_height: resizable_height, }, app, ) .with_margin_bottom(8.) .finish(), ); } if !output_items.is_empty() && !should_hide_responses { let selected_text = self.selected_text.clone(); let query_selection_handle = self.state_handles.query_selection_handle.clone(); let action_selection_handle = self.state_handles.action_selection_handle.clone(); let mut output = SelectableArea::new( self.state_handles.output_selection_handle.clone(), move |selection_args, ctx, _| { if let Some(selection) = selection_args .selection .filter(|selection| !selection.is_empty()) { query_selection_handle.clear(); action_selection_handle.clear(); *selected_text.write() = Some(selection); ctx.dispatch_typed_action(CLISubagentAction::SelectText); } }, output_items.finish(), ) .with_word_boundaries_policy(semantic_selection.word_boundary_policy()) .with_smart_select_fn(semantic_selection.smart_select_fn()); if FeatureFlag::RectSelection.is_enabled() { output = output.should_support_rect_select(); } result.add_child( render_scrollable_container( ScrollableContainerProps { scroll_state: self.state_handles.output_scroll_state.clone(), child: output.finish(), background_color: internal_colors::neutral_2(appearance.theme()), border: Some(output_border), max_height: resizable_height, }, app, ) .with_margin_bottom(8.) .finish(), ); } if let Some(rendered_action) = blocked_action.and_then(|action| match action.action { AIAgentActionType::WriteToLongRunningShellCommand { input, mode, .. } => { let header = if mode.is_shell_interrupt(&input) { BLOCKED_ACTION_MESSAGE_FOR_INTERRUPT } else { BLOCKED_ACTION_MESSAGE_FOR_WRITE_TO_LONG_RUNNING_SHELL_COMMAND }; Some(render_blocked_action( BlockedActionProps { header: header.to_string(), review_detail: self .action_model .as_ref(app) .review_display(self.conversation_id, &action.id) .map(|review| review.detail), is_expanded: self.is_approval_expanded, header_mouse_state: &self.state_handles.approval_header_mouse_state, description: Some(render_write_to_pty_input( WriteToPtyInputProps { input: input.clone(), mode, scroll_state: self.state_handles.input_scroll_state.clone(), max_height: resizable_height, }, app, )), is_allow_menu_open: self.is_allow_menu_open, allow_menu: Some(&self.allow_menu), buttons: vec![ &self.allow_button, &self.reject_button, &self.take_over_button, ], speedbump: should_show_write_to_pty_speedbump(app).then_some( PermissionsSpeedbumpProps { always_allow_checked: self.always_allow_write_to_pty_checked, speedbump_checkbox_handle: &self .state_handles .speedbump_checkbox_handle, speedbump_checkbox_action: CLISubagentAction::ToggleAlwaysAllowWriteToPty, ai_settings_link: &self.state_handles.ai_settings_link, }, ), }, app, )) } AIAgentActionType::TransferShellCommandControlToUser { ref reason } => { Some(render_blocked_action( BlockedActionProps { header: BLOCKED_ACTION_MESSAGE_FOR_TRANSFER_CONTROL.to_string(), review_detail: self .action_model .as_ref(app) .review_display(self.conversation_id, &action.id) .map(|review| review.detail), is_expanded: self.is_approval_expanded, header_mouse_state: &self.state_handles.approval_header_mouse_state, description: Some(render_transfer_control_reason(reason, app)), is_allow_menu_open: false, allow_menu: None, buttons: vec![&self.reject_button, &self.transfer_control_button], speedbump: None, }, app, )) } AIAgentActionType::ReadFiles(..) | AIAgentActionType::SearchCodebase(..) | AIAgentActionType::Grep { .. } | AIAgentActionType::FileGlobV2 { .. } => Some(render_blocked_action( BlockedActionProps { header: get_blocked_action_header(action.action.clone()).unwrap_or_default(), review_detail: self .action_model .as_ref(app) .review_display(self.conversation_id, &action.id) .map(|review| review.detail), is_expanded: self.is_approval_expanded, header_mouse_state: &self.state_handles.approval_header_mouse_state, description: render_search_action_input(action.action.clone(), app), is_allow_menu_open: self.is_allow_menu_open, allow_menu: Some(&self.allow_menu), buttons: vec![ &self.allow_button, &self.reject_button, &self.take_over_button, ], speedbump: should_show_read_files_speedbump(app).then_some( PermissionsSpeedbumpProps { always_allow_checked: self.always_allow_read_files_checked, speedbump_checkbox_handle: &self .state_handles .speedbump_checkbox_handle, speedbump_checkbox_action: CLISubagentAction::ToggleAlwaysAllowReadFiles, ai_settings_link: &self.state_handles.ai_settings_link, }, ), }, app, )), _ => None, }) { let selected_text = self.selected_text.clone(); let query_selection_handle = self.state_handles.query_selection_handle.clone(); let output_selection_handle = self.state_handles.output_selection_handle.clone(); let mut selectable_action = SelectableArea::new( self.state_handles.action_selection_handle.clone(), move |selection_args, ctx, _| { if let Some(selection) = selection_args .selection .filter(|selection| !selection.is_empty()) { query_selection_handle.clear(); output_selection_handle.clear(); *selected_text.write() = Some(selection); ctx.dispatch_typed_action(CLISubagentAction::SelectText); } }, rendered_action, ) .with_word_boundaries_policy(semantic_selection.word_boundary_policy()) .with_smart_select_fn(semantic_selection.smart_select_fn()); if FeatureFlag::RectSelection.is_enabled() { selectable_action = selectable_action.should_support_rect_select(); } result.add_child( Container::new(selectable_action.finish()) .with_margin_bottom(8.) .finish(), ); } let content = result.finish(); let width_resizable = Resizable::new(self.resizable_width.clone(), content) .with_dragbar_side(DragBarSide::Left) .on_resize(|ctx, _| ctx.notify()) .with_bounds_callback(Box::new(|window_size| { let max = (window_size.x() - MIN_REMAINING_WINDOW_WIDTH).max(MIN_RESIZABLE_WIDTH); (MIN_RESIZABLE_WIDTH, max) })) .finish(); Resizable::new(self.resizable_height.clone(), width_resizable) .with_dragbar_side(DragBarSide::Top) .on_resize(|ctx, _| ctx.notify()) .with_bounds_callback(Box::new(|window_size| { let max = (window_size.y() - MIN_REMAINING_WINDOW_HEIGHT).max(MIN_RESIZABLE_HEIGHT); (MIN_RESIZABLE_HEIGHT, max) })) .finish() } fn keymap_context(&self, app: &AppContext) -> galaxyui::keymap::Context { let mut context = Self::default_keymap_context(); let terminal_model = self.terminal_model.lock(); let active_block = terminal_model.block_list().active_block(); if active_block.is_agent_blocked() { context.set.insert(HAS_PENDING_CLI_ACTION_CONTEXT_KEY); if !self.has_pending_transfer_control_action(app) { context .set .insert(HAS_PENDING_NON_TRANSFER_CONTROL_ACTION_CONTEXT_KEY); } } context } } #[derive(Debug, Clone)] pub enum CLISubagentAction { CopyCode(String), OpenCodeBlock(CodeSource), ExecuteBlockedAction, ExecuteAndAutoApprove, RejectBlockedAction { should_user_take_over: bool }, TakeControlOfRunningCommand, ToggleAllowMenu, ToggleApprovalExpanded, ToggleAlwaysAllowWriteToPty, ToggleAlwaysAllowReadFiles, DismissInput, SelectText, CopyDebugId(String), OpenFeedbackDocs, } impl TypedActionView for CLISubagentView { type Action = CLISubagentAction; fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { match action { CLISubagentAction::CopyCode(code) => { ctx.clipboard() .write(ClipboardContent::plain_text(code.clone())); let window_id = ctx.window_id(); ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| { toast_stack.add_ephemeral_toast( DismissibleToast::success(String::from("Copied to clipboard")), window_id, ctx, ); }); } CLISubagentAction::OpenCodeBlock(source) => { // TODO(zachbai): Implement this. log::info!("Received open code block action: {source:?}"); } CLISubagentAction::ExecuteBlockedAction => { self.handle_execute_blocked_action(false, ctx); } CLISubagentAction::ExecuteAndAutoApprove => { self.handle_execute_blocked_action(true, ctx); } CLISubagentAction::RejectBlockedAction { should_user_take_over, } => { self.handle_reject_blocked_action(*should_user_take_over, ctx); } CLISubagentAction::TakeControlOfRunningCommand => { self.take_control_of_running_command(ctx); } CLISubagentAction::ToggleAllowMenu => { self.toggle_allow_menu(ctx); } CLISubagentAction::ToggleApprovalExpanded => { self.is_approval_expanded = !self.is_approval_expanded; ctx.notify(); } CLISubagentAction::ToggleAlwaysAllowWriteToPty => { self.always_allow_write_to_pty_checked = !self.always_allow_write_to_pty_checked; BlocklistAIPermissions::handle(ctx).update(ctx, |model, ctx| { if let Err(e) = model.set_always_allow_write_to_pty( self.always_allow_write_to_pty_checked, self.terminal_view_id, ctx, ) { report_error!(e); } }); ctx.notify(); } CLISubagentAction::ToggleAlwaysAllowReadFiles => { self.always_allow_read_files_checked = !self.always_allow_read_files_checked; BlocklistAIPermissions::handle(ctx).update(ctx, |model, ctx| { if let Err(e) = model.set_always_allow_read_files( self.always_allow_read_files_checked, self.terminal_view_id, ctx, ) { report_error!(e); } }); ctx.notify(); } CLISubagentAction::DismissInput => { self.is_input_dismissed = true; if let Some(handle) = self.input_dismiss_timer_handle.take() { handle.abort(); } ctx.notify(); send_telemetry_from_ctx!( TelemetryEvent::CLISubagentInputDismissed { conversation_id: self.conversation_id, block_id: self.block_id.clone(), }, ctx ); } CLISubagentAction::SelectText => { self.clear_other_selections(None, ctx); ctx.reset_cursor(); ctx.focus_self(); ctx.emit(CLISubagentViewEvent::TextSelected); } CLISubagentAction::CopyDebugId(debug_id) => { ctx.clipboard() .write(ClipboardContent::plain_text(debug_id.clone())); } CLISubagentAction::OpenFeedbackDocs => { ctx.open_url("https://docs.warp.dev/support-and-billing/sending-us-feedback"); } } } } fn should_show_write_to_pty_speedbump(app: &AppContext) -> bool { is_agent_mode_autonomy_allowed(app) && *AISettings::as_ref(app).should_show_agent_mode_write_to_pty_speedbump } fn should_show_read_files_speedbump(app: &AppContext) -> bool { is_agent_mode_autonomy_allowed(app) && *AISettings::as_ref(app).should_show_agent_mode_autoread_files_speedbump } fn compact_monitor_output(outputs: impl IntoIterator) -> AIAgentOutput { let latest_text = outputs .into_iter() .flat_map(|output| output.messages) .filter(|message| should_retain_task_output_message(&message.message)) .last(); // Keep the monitor transcript to one user-visible assistant message. Tool calls are // implementation details; the model's latest explanation is the useful progress update. AIAgentOutput { messages: latest_text.into_iter().collect(), ..Default::default() } } fn should_retain_task_output_message(message: &AIAgentOutputMessageType) -> bool { matches!(message, AIAgentOutputMessageType::Text(_)) } fn get_action_loading_text(action: AIAgentActionType) -> Option { if action.is_shell_command_interrupt() { return Some("Interrupting the running command with Ctrl+C…".to_string()); } match action { AIAgentActionType::SearchCodebase(_) => { Some(LOAD_OUTPUT_MESSAGE_FOR_SEARCH_CODEBASE.to_string()) } AIAgentActionType::ReadFiles(_) => Some(LOAD_OUTPUT_MESSAGE_FOR_READING_FILES.to_string()), AIAgentActionType::Grep { .. } => Some(LOAD_OUTPUT_MESSAGE_FOR_GREP.to_string()), AIAgentActionType::FileGlobV2 { .. } => Some(LOAD_OUTPUT_MESSAGE_FOR_FILE_GLOB.to_string()), AIAgentActionType::ReadShellCommandOutput { delay, .. } => match delay { Some(crate::ai::agent::ShellCommandDelay::OnCompletion) => { Some("Waiting for the command to finish…".to_string()) } Some(crate::ai::agent::ShellCommandDelay::Duration(_)) | None => { Some("Reading the latest command output…".to_string()) } }, AIAgentActionType::WriteToLongRunningShellCommand { .. } => { Some("Sending input to the running command…".to_string()) } _ => None, } } fn get_action_icon(action: AIAgentActionType) -> Option { if action.is_shell_command_interrupt() { return Some(Icon::Stop); } match action { AIAgentActionType::SearchCodebase(_) | AIAgentActionType::ReadFiles(_) | AIAgentActionType::Grep { .. } | AIAgentActionType::FileGlobV2 { .. } => Some(Icon::Search), AIAgentActionType::ReadShellCommandOutput { .. } => Some(Icon::ClockRefresh), AIAgentActionType::WriteToLongRunningShellCommand { .. } => Some(Icon::TerminalInput), _ => None, } } fn render_action(action: AIAgentActionType, app: &AppContext) -> Option> { let text = get_action_loading_text(action.clone())?; let icon = get_action_icon(action)?; Some(render_action_status(text, icon, app)) } fn render_action_status(text: String, icon: Icon, app: &AppContext) -> Box { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); let icon = Container::new( ConstrainedBox::new( galaxyui::elements::Icon::new(icon.into(), internal_colors::neutral_5(theme)).finish(), ) .with_width(icon_size(app)) .with_height(icon_size(app)) .finish(), ) .with_margin_right(AVATAR_RIGHT_MARGIN) .finish(); let text = Expanded::new( 1., Text::new( text, appearance.monospace_font_family(), appearance.monospace_font_size(), ) .with_color(blended_colors::text_main(theme, theme.surface_1())) .finish(), ) .finish(); Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_main_axis_alignment(MainAxisAlignment::Center) .with_children([icon, text]) .finish() } fn render_web_search(query: Option, app: &AppContext) -> Box { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); let text = if let Some(q) = query { format!("Searching the web for \"{q}\"") } else { LOAD_OUTPUT_MESSAGE_FOR_WEB_SEARCH.to_string() }; let icon = Container::new( ConstrainedBox::new( galaxyui::elements::Icon::new(Icon::Search.into(), internal_colors::neutral_5(theme)) .finish(), ) .with_width(icon_size(app)) .with_height(icon_size(app)) .finish(), ) .with_margin_right(AVATAR_RIGHT_MARGIN) .finish(); let text = Expanded::new( 1., Text::new( text, appearance.monospace_font_family(), appearance.monospace_font_size(), ) .with_color(blended_colors::text_main(theme, theme.surface_1())) .finish(), ) .finish(); Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_main_axis_alignment(MainAxisAlignment::Center) .with_children([icon, text]) .finish() } struct DismissableContainerProps { child: Box, hover_state: MouseStateHandle, dismiss_mouse_state: MouseStateHandle, position_id: String, } fn render_dismissable_container( props: DismissableContainerProps, app: &AppContext, ) -> Box { let DismissableContainerProps { child, hover_state, dismiss_mouse_state, position_id, } = props; let hoverable = Hoverable::new(hover_state, |mouse_state| { let mut stack = Stack::new().with_child(SavePosition::new(child, &position_id).finish()); if mouse_state.is_hovered() { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); let ui_builder = appearance.ui_builder(); let dismiss_button = Container::new( ui_builder .close_button(16., dismiss_mouse_state.clone()) .with_style(UiComponentStyles { font_color: Some(blended_colors::text_main(theme, theme.surface_1())), background: Some(internal_colors::accent_bg(theme).into()), border_radius: Some(CornerRadius::with_all(Radius::Percentage(50.))), border_width: Some(1.), border_color: Some(theme.accent().into()), padding: Some(Coords::uniform(2.)), ..Default::default() }) .build() .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(CLISubagentAction::DismissInput); }) .finish(), ) .finish(); stack.add_positioned_child( dismiss_button, OffsetPositioning::offset_from_save_position_element( position_id, vec2f(4., -4.), PositionedElementOffsetBounds::WindowByPosition, PositionedElementAnchor::TopRight, ChildAnchor::TopRight, ), ); } stack.finish() }); hoverable .with_hover_out_delay(Duration::from_millis(500)) .finish() } struct ScrollableContainerProps { scroll_state: ClippedScrollStateHandle, child: Box, background_color: ColorU, border: Option, max_height: f32, } fn render_scrollable_container(props: ScrollableContainerProps, _app: &AppContext) -> Container { let ScrollableContainerProps { scroll_state, child, background_color, border, max_height, } = props; let scrollable = NewScrollable::vertical( SingleAxisConfig::Clipped { handle: scroll_state, child, }, Fill::None, Fill::None, Fill::None, ) .with_propagate_mousewheel_if_not_handled(true) .finish(); let clipped = ConstrainedBox::new(scrollable) .with_max_height(max_height) .finish(); let mut container = Container::new(clipped) .with_background_color(background_color) .with_horizontal_padding(CONTENT_PADDING) .with_vertical_padding(CONTENT_PADDING) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.))) .with_drop_shadow(DropShadow::default()); if let Some(border) = border { container = container.with_border(border); } container } fn render_action_buttons( buttons: Vec<&dyn RenderCompactibleActionButton>, app: &AppContext, ) -> Box { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); let (regular_row, compact_row) = render_compact_and_regular_button_rows(buttons, None, appearance, app); let regular_wrapped = Container::new(regular_row) .with_vertical_padding(8.) .with_horizontal_padding(CONTENT_PADDING) .with_background(theme.background()) .with_border(Border::top(1.).with_border_color(internal_colors::neutral_3(theme))) .finish(); let compact_wrapped = Container::new(compact_row) .with_vertical_padding(8.) .with_horizontal_padding(CONTENT_PADDING) .with_background(theme.background()) .with_border(Border::top(1.).with_border_color(internal_colors::neutral_3(theme))) .finish(); let size_switch_threshold = 250. * appearance.monospace_ui_scalar(); SizeConstraintSwitch::new( regular_wrapped, vec![( SizeConstraintCondition::WidthLessThan(size_switch_threshold), compact_wrapped, )], ) .finish() } struct PermissionsSpeedbumpProps<'a> { always_allow_checked: bool, speedbump_checkbox_handle: &'a MouseStateHandle, speedbump_checkbox_action: CLISubagentAction, ai_settings_link: &'a HighlightedHyperlink, } fn render_permissions_speedbump( props: PermissionsSpeedbumpProps<'_>, app: &AppContext, ) -> Box { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); let font_size = appearance.monospace_font_size() - 2.; let font_family = appearance.ui_font_family(); let font_color = internal_colors::neutral_6(theme); let checkbox = appearance .ui_builder() .checkbox(props.speedbump_checkbox_handle.clone(), Some(font_size)) .check(props.always_allow_checked) .with_style(UiComponentStyles { font_color: Some(font_color), font_size: Some(font_size), ..Default::default() }) .build() .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(props.speedbump_checkbox_action.clone()); }) .with_cursor(Cursor::PointingHand) .finish(); let checkbox_text = appearance .ui_builder() .span("Always allow") .with_style(UiComponentStyles { font_color: Some(font_color), font_size: Some(font_size), padding: Some(Coords::default().left(4.)), ..Default::default() }) .with_soft_wrap() .build() .finish(); let formatted_text = FormattedTextElement::new( FormattedText::new([FormattedTextLine::Line(vec![ FormattedTextFragment::hyperlink("Manage Agent permissions", "Settings > AI"), ])]), font_size, font_family, font_family, font_color, props.ai_settings_link.clone(), ) .with_hyperlink_font_color(blended_colors::accent_fg_strong(theme).into()) .register_default_click_handlers(|_, ctx, _| { ctx.dispatch_typed_action(WorkspaceAction::ShowSettingsPage( SettingsSection::WarpAgent, )); }) .finish(); Container::new( 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.0, Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child(checkbox) .with_child(Shrinkable::new(1.0, checkbox_text).finish()) .finish(), ) .finish(), ) .with_child(Shrinkable::new(1.0, formatted_text).finish()) .finish(), ) .with_vertical_padding(8.) .with_horizontal_padding(CONTENT_PADDING) .with_background(theme.background()) .with_border(Border::top(1.).with_border_color(internal_colors::neutral_3(theme))) .finish() } fn render_transfer_control_reason(reason: &str, app: &AppContext) -> Box { let appearance = Appearance::as_ref(app); let text = Text::new( reason.to_string(), appearance.ai_font_family(), appearance.monospace_font_size(), ) .with_color(blended_colors::text_main( appearance.theme(), appearance.theme().surface_1(), )) .finish(); Container::new(text) .with_background_color(internal_colors::neutral_2(appearance.theme())) .with_horizontal_padding(CONTENT_PADDING) .with_vertical_padding(8.) .finish() } fn get_blocked_action_header(action: AIAgentActionType) -> Option { if action.is_shell_command_interrupt() { return Some(BLOCKED_ACTION_MESSAGE_FOR_INTERRUPT.to_string()); } match action { AIAgentActionType::WriteToLongRunningShellCommand { .. } => { Some(BLOCKED_ACTION_MESSAGE_FOR_WRITE_TO_LONG_RUNNING_SHELL_COMMAND.to_string()) } AIAgentActionType::ReadFiles(..) => { Some(BLOCKED_ACTION_MESSAGE_FOR_READING_FILES.to_string()) } AIAgentActionType::SearchCodebase(..) => { Some(BLOCKED_ACTION_MESSAGE_FOR_SEARCHING_CODEBASE.to_string()) } AIAgentActionType::Grep { .. } | AIAgentActionType::FileGlobV2 { .. } => { Some(BLOCKED_ACTION_MESSAGE_FOR_GREP_OR_FILE_GLOB.to_string()) } _ => None, } } struct WriteToPtyInputProps { input: bytes::Bytes, mode: AIAgentPtyWriteMode, scroll_state: ClippedScrollStateHandle, max_height: f32, } fn render_write_to_pty_input(props: WriteToPtyInputProps, app: &AppContext) -> Box { let WriteToPtyInputProps { input, mode, scroll_state, max_height, } = props; let appearance = Appearance::as_ref(app); let theme = appearance.theme(); let decorated_bytes = mode.decorate_bytes(input.to_vec(), false); let parsed = if let AIAgentPtyWriteMode::Block = mode { ParsedControlCodeOutput { display: String::from_utf8_lossy(&input).to_string(), control_code_ranges: vec![], } } else { parse_control_codes_from_bytes(&decorated_bytes) }; let text = Text::new( parsed.display, appearance.monospace_font_family(), appearance.monospace_font_size(), ) .with_color(theme.sub_text_color(theme.background()).into()) .with_single_highlight( Highlight::new() .with_foreground_color(theme.hint_text_color(theme.surface_2()).into()) .with_properties(Properties { style: Style::Italic, ..Default::default() }), parsed.control_code_ranges.into_iter().flatten().collect(), ) .finish(); let scrollable = NewScrollable::vertical( SingleAxisConfig::Clipped { handle: scroll_state, child: text, }, Fill::None, Fill::None, Fill::None, ) .with_propagate_mousewheel_if_not_handled(true) .finish(); let clipped = ConstrainedBox::new(scrollable) .with_max_height(max_height) .finish(); Container::new(clipped) .with_background(theme.background()) .with_horizontal_padding(CONTENT_PADDING) .with_padding_top(8.) .with_padding_bottom(12.) .finish() } fn render_search_action_input( action: AIAgentActionType, app: &AppContext, ) -> Option> { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); let description_text = match action { AIAgentActionType::ReadFiles(ref request) => request .locations .iter() .map(|loc| loc.name.as_str()) .collect::>() .join("\n"), AIAgentActionType::SearchCodebase(ref request) => { let repo = request.codebase_path.as_deref()?; repo.to_string() } AIAgentActionType::Grep { ref queries, ref path, } => { let display_path = if path == "." { "the current directory" } else { path.as_str() }; if queries.len() == 1 { format!("Grep for `{}` in {}", queries[0], display_path) } else { let patterns_list = queries .iter() .map(|q| format!(" - `{q}`")) .collect::>() .join("\n"); format!("Grep for the following patterns in {display_path}:\n{patterns_list}") } } AIAgentActionType::FileGlobV2 { ref patterns, ref search_dir, } => { let display_path = search_dir.as_deref().unwrap_or("the current directory"); if patterns.len() == 1 { format!( "Search for files that match `{}` in {}", patterns[0], display_path ) } else { let patterns_list = patterns .iter() .map(|p| format!(" - `{p}`")) .collect::>() .join("\n"); format!( "Find files that match the following patterns in {display_path}:\n{patterns_list}" ) } } _ => return None, }; let text = Text::new( description_text, appearance.monospace_font_family(), appearance.monospace_font_size(), ) .with_color(blended_colors::text_main(theme, theme.surface_1())) .finish(); Some( Container::new(text) .with_background(theme.background()) .with_uniform_padding(CONTENT_PADDING) .finish(), ) } struct BlockedActionProps<'a> { header: String, review_detail: Option, is_expanded: bool, header_mouse_state: &'a MouseStateHandle, description: Option>, is_allow_menu_open: bool, allow_menu: Option<&'a ViewHandle>>, buttons: Vec<&'a dyn RenderCompactibleActionButton>, speedbump: Option>, } fn render_blocked_action(props: BlockedActionProps<'_>, app: &AppContext) -> Box { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); let header_text = props.header.clone(); let text = Text::new( header_text, appearance.ui_font_family(), appearance.monospace_font_size(), ) .with_color(blended_colors::text_main(theme, theme.surface_2())) .finish(); let icon = Container::new( ConstrainedBox::new(yellow_stop_icon(appearance).finish()) .with_width(icon_size(app)) .with_height(icon_size(app)) .finish(), ) .with_margin_right(AVATAR_RIGHT_MARGIN) .finish(); let chevron = if props.is_expanded { Icon::ChevronDown } else { Icon::ChevronRight }; let header = Container::new( Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_children(vec![ icon, Shrinkable::new(1.0, text).finish(), chevron .to_galaxyui_icon(theme.sub_text_color(theme.surface_2())) .finish(), ]) .finish(), ) .with_background(theme.surface_2()) .with_uniform_padding(CONTENT_PADDING) .with_corner_radius(CornerRadius::with_top(Radius::Pixels(4.))) .finish(); let header = Hoverable::new(props.header_mouse_state.clone(), |_| header) .on_click(|ctx, _, _| { ctx.dispatch_typed_action(CLISubagentAction::ToggleApprovalExpanded); }) .with_cursor(Cursor::PointingHand) .finish(); let mut body_children = vec![header]; if props.is_expanded { if let Some(description) = props.description { body_children.push( Container::new( Flex::column() .with_child( Text::new( "REQUESTED ACTION", appearance.ui_font_family(), appearance.ui_font_size(), ) .with_color(theme.accent().into()) .finish(), ) .with_child(Container::new(description).with_margin_top(4.).finish()) .finish(), ) .with_background(theme.background()) .with_horizontal_padding(CONTENT_PADDING) .with_padding_top(14.) .finish(), ); } if let Some(review_detail) = props.review_detail { body_children.push( Container::new( Flex::column() .with_child( Text::new( "RISK REVIEW", appearance.ui_font_family(), appearance.ui_font_size(), ) .with_color(theme.accent().into()) .finish(), ) .with_child( Container::new( Text::new( review_detail, appearance.ai_font_family(), appearance.monospace_font_size(), ) .with_color(blended_colors::text_sub(theme, theme.surface_1())) .finish(), ) .with_margin_top(4.) .finish(), ) .finish(), ) .with_background(theme.background()) .with_horizontal_padding(CONTENT_PADDING) .with_padding_top(12.) .with_padding_bottom(12.) .finish(), ); } if let Some(speedbump) = props.speedbump { body_children.push(render_permissions_speedbump(speedbump, app)); } } let buttons = render_action_buttons(props.buttons, app); body_children.push(buttons); let body = Flex::column() .with_cross_axis_alignment(CrossAxisAlignment::Stretch) .with_children(body_children) .finish(); let mut stack = Stack::new(); stack.add_child( Container::new(body) .with_drop_shadow(DropShadow::default()) .finish(), ); if props.is_allow_menu_open { if let Some(allow_menu) = props.allow_menu { stack.add_positioned_child( ChildView::new(allow_menu).finish(), OffsetPositioning::offset_from_save_position_element( ALLOW_ACTION_POSITION_ID.to_string(), vec2f(0., 8.), PositionedElementOffsetBounds::WindowByPosition, PositionedElementAnchor::BottomRight, ChildAnchor::TopRight, ), ); } } Expanded::new( 1.0, Container::new(stack.finish()) .with_corner_radius(CornerRadius::with_top(Radius::Pixels(4.))) .with_border(Border::all(1.).with_border_color(internal_colors::neutral_3(theme))) .finish(), ) .finish() } #[cfg(test)] #[path = "cli_tests.rs"] mod tests;