From 5a70995502e725e9236fd55cfa10f6215f696443 Mon Sep 17 00:00:00 2001 From: Ryan Ward Date: Wed, 30 Sep 2026 11:09:04 -0500 Subject: [PATCH] Show and refresh monitoring command approvals --- .../ai/blocklist/action_model/risk_review.rs | 11 ++ app/src/ai/blocklist/block.rs | 32 ++++ app/src/ai/blocklist/block/cli.rs | 151 +++++++++++++++-- app/src/ai/blocklist/block/view_impl.rs | 1 + .../ai/blocklist/block/view_impl/output.rs | 160 ++++++++++++++---- .../inline_action/requested_command.rs | 2 +- 6 files changed, 309 insertions(+), 48 deletions(-) diff --git a/app/src/ai/blocklist/action_model/risk_review.rs b/app/src/ai/blocklist/action_model/risk_review.rs index 64acee90..d7c26f95 100644 --- a/app/src/ai/blocklist/action_model/risk_review.rs +++ b/app/src/ai/blocklist/action_model/risk_review.rs @@ -119,6 +119,17 @@ fn review_summary(action: &AIAgentAction) -> String { AIAgentActionType::RequestCommandOutput { command, .. } => { format!("Run: {command}") } + AIAgentActionType::WriteToLongRunningShellCommand { input, mode, .. } + if mode.is_shell_interrupt(input) => + { + "Interrupt the running command (Ctrl+C)".to_string() + } + AIAgentActionType::WriteToLongRunningShellCommand { input, .. } => { + format!( + "Send to running command: {}", + String::from_utf8_lossy(input) + ) + } _ => action.action.user_friendly_name(), }; let summary = description.split_whitespace().collect::>().join(" "); diff --git a/app/src/ai/blocklist/block.rs b/app/src/ai/blocklist/block.rs index f89b7438..98604cfb 100644 --- a/app/src/ai/blocklist/block.rs +++ b/app/src/ai/blocklist/block.rs @@ -1088,6 +1088,9 @@ pub struct AIBlock { /// Map from collapsible block message IDs (reasoning or summarization) to their states. collapsible_block_states: HashMap, + /// Expansion state for Galaxy's per-action permission review panes. + review_pane_states: HashMap, + /// Map from suggested prompt action ID to its view handle and status. unit_tests_suggestions: HashMap>, @@ -1658,6 +1661,7 @@ impl AIBlock { todo_list_states: Default::default(), comment_states, collapsible_block_states: Default::default(), + review_pane_states: Default::default(), unit_tests_suggestions: Default::default(), secret_redaction_state, find_state: FindState::default(), @@ -2174,6 +2178,10 @@ impl AIBlock { } self.requested_action_ids = new_action_ids; + self.review_pane_states + .entry(action.id.clone()) + .or_default(); + if !self.action_buttons.contains_key(&action.id) { let run_button = CompactibleActionButton::new( "Run".to_string(), @@ -4847,6 +4855,9 @@ impl AIBlock { match event { BlocklistAIActionEvent::ExecutingAction { .. } => { + if let Some(state) = me.review_pane_states.get_mut(action_id) { + state.finish_summarization(); + } match &me.autonomy_setting_speedbump { AutonomySettingSpeedbump::ShouldShowForAutoexecutingReadonlyCommands { action_id: speedbump_action_id, @@ -4917,10 +4928,23 @@ impl AIBlock { } } BlocklistAIActionEvent::ActionBlockedOnUserConfirmation { .. } => { + if let Some(state) = me.review_pane_states.get_mut(action_id) { + state.sync_finished_state(false); + if !state.user_toggled_while_streaming { + state.expand(); + } + } + if let Some(buttons) = me.action_buttons.get_mut(action_id) { + buttons.run_button.set_label("Approve".to_string(), ctx); + buttons.cancel_button.set_label("Deny".to_string(), ctx); + } ctx.emit(AIBlockEvent::ActionBlockedOnUserConfirmation); ctx.notify(); } BlocklistAIActionEvent::FinishedAction { action_id, .. } => { + if let Some(state) = me.review_pane_states.get_mut(action_id) { + state.finish_summarization(); + } me.abort_auto_expand_requested_command_timer(); // Handle auto-collapsing of requested commands that were marked for auto-collapse @@ -6590,6 +6614,8 @@ pub enum AIBlockAction { action_id: AIAgentActionId, }, + ToggleReviewPaneExpanded(AIAgentActionId), + /// Execute the next pending action, if any. ExecuteNextPendingAction, @@ -6840,6 +6866,12 @@ impl TypedActionView for AIBlock { action_model.execute_action(action_id, self.client_ids.conversation_id, ctx) }); } + AIBlockAction::ToggleReviewPaneExpanded(action_id) => { + if let Some(state) = self.review_pane_states.get_mut(action_id) { + state.toggle_expansion(); + ctx.notify(); + } + } AIBlockAction::ChangedHoverOnLink { link_range, location, diff --git a/app/src/ai/blocklist/block/cli.rs b/app/src/ai/blocklist/block/cli.rs index a86db5f3..c438b37e 100644 --- a/app/src/ai/blocklist/block/cli.rs +++ b/app/src/ai/blocklist/block/cli.rs @@ -191,6 +191,7 @@ struct StateHandles { query_scroll_state: ClippedScrollStateHandle, input_hover_state: MouseStateHandle, dismiss_input_mouse_state: MouseStateHandle, + approval_header_mouse_state: MouseStateHandle, } pub struct CLISubagentView { @@ -218,6 +219,7 @@ pub struct CLISubagentView { 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, @@ -439,6 +441,20 @@ impl CLISubagentView { 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 { @@ -473,6 +489,7 @@ impl CLISubagentView { 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, @@ -1364,6 +1381,13 @@ impl View for CLISubagentView { 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(), @@ -1399,6 +1423,13 @@ impl View for CLISubagentView { 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, @@ -1414,6 +1445,13 @@ impl View for CLISubagentView { | 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), @@ -1516,6 +1554,7 @@ pub enum CLISubagentAction { RejectBlockedAction { should_user_take_over: bool }, TakeControlOfRunningCommand, ToggleAllowMenu, + ToggleApprovalExpanded, ToggleAlwaysAllowWriteToPty, ToggleAlwaysAllowReadFiles, DismissInput, @@ -1562,6 +1601,10 @@ impl TypedActionView for CLISubagentView { 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| { @@ -1895,14 +1938,14 @@ fn render_action_buttons( let regular_wrapped = Container::new(regular_row) .with_vertical_padding(8.) .with_horizontal_padding(CONTENT_PADDING) - .with_background_color(internal_colors::neutral_2(theme)) + .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_color(internal_colors::neutral_2(theme)) + .with_background(theme.background()) .with_border(Border::top(1.).with_border_color(internal_colors::neutral_3(theme))) .finish(); @@ -2002,7 +2045,7 @@ fn render_permissions_speedbump( ) .with_vertical_padding(8.) .with_horizontal_padding(CONTENT_PADDING) - .with_background_color(internal_colors::neutral_2(theme)) + .with_background(theme.background()) .with_border(Border::top(1.).with_border_color(internal_colors::neutral_3(theme))) .finish() } @@ -2111,9 +2154,10 @@ fn render_write_to_pty_input(props: WriteToPtyInputProps, app: &AppContext) -> B .finish(); Container::new(clipped) - .with_background_color(internal_colors::neutral_2(theme)) + .with_background(theme.background()) .with_horizontal_padding(CONTENT_PADDING) - .with_vertical_padding(8.) + .with_padding_top(8.) + .with_padding_bottom(12.) .finish() } @@ -2191,7 +2235,7 @@ fn render_search_action_input( Some( Container::new(text) - .with_background_color(internal_colors::neutral_2(theme)) + .with_background(theme.background()) .with_uniform_padding(CONTENT_PADDING) .finish(), ) @@ -2199,6 +2243,9 @@ fn render_search_action_input( struct BlockedActionProps<'a> { header: String, + review_detail: Option, + is_expanded: bool, + header_mouse_state: &'a MouseStateHandle, description: Option>, is_allow_menu_open: bool, @@ -2217,7 +2264,7 @@ fn render_blocked_action(props: BlockedActionProps<'_>, app: &AppContext) -> Box appearance.ui_font_family(), appearance.monospace_font_size(), ) - .with_color(theme.active_ui_text_color().into()) + .with_color(blended_colors::text_main(theme, theme.surface_2())) .finish(); let icon = Container::new( @@ -2229,27 +2276,101 @@ fn render_blocked_action(props: BlockedActionProps<'_>, app: &AppContext) -> Box .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()]) + .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_color(internal_colors::neutral_3(theme)) + .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 let Some(description) = props.description { - body_children.push(description); + 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); - if let Some(speedbump) = props.speedbump { - body_children.push(render_permissions_speedbump(speedbump, app)); - } let body = Flex::column() .with_cross_axis_alignment(CrossAxisAlignment::Stretch) @@ -2281,7 +2402,7 @@ fn render_blocked_action(props: BlockedActionProps<'_>, app: &AppContext) -> Box Expanded::new( 1.0, Container::new(stack.finish()) - .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.))) + .with_corner_radius(CornerRadius::with_top(Radius::Pixels(4.))) .with_border(Border::all(1.).with_border_color(internal_colors::neutral_3(theme))) .finish(), ) diff --git a/app/src/ai/blocklist/block/view_impl.rs b/app/src/ai/blocklist/block/view_impl.rs index e2c52766..5b281a96 100644 --- a/app/src/ai/blocklist/block/view_impl.rs +++ b/app/src/ai/blocklist/block/view_impl.rs @@ -1109,6 +1109,7 @@ impl View for AIBlock { unit_test_suggestions: &self.unit_tests_suggestions, todo_list_states: &self.todo_list_states, collapsible_block_states: &self.collapsible_block_states, + review_pane_states: &self.review_pane_states, is_selecting_text: self.state_handles.selection_handle.is_selecting(), is_ai_input_enabled: self .context_model diff --git a/app/src/ai/blocklist/block/view_impl/output.rs b/app/src/ai/blocklist/block/view_impl/output.rs index 72a1acf1..1428e57b 100644 --- a/app/src/ai/blocklist/block/view_impl/output.rs +++ b/app/src/ai/blocklist/block/view_impl/output.rs @@ -164,6 +164,7 @@ pub(crate) struct Props<'a> { &'a HashMap>, pub(super) todo_list_states: &'a HashMap, pub(super) collapsible_block_states: &'a HashMap, + pub(super) review_pane_states: &'a HashMap, pub(crate) is_selecting_text: bool, pub(super) is_ai_input_enabled: bool, pub(crate) find_context: Option>, @@ -324,7 +325,8 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box { .as_ref(app) .review_display(props.conversation_id, &action.id) { - output_items.add_child(render_review_status(review, app)); + output_items + .add_child(render_review_status(&action.id, review, props, app)); } } match &output_message.message { @@ -1320,8 +1322,22 @@ fn render_streaming_tool_call( render_tool_pane_shell(Clipped::new(header).finish(), false, false, false, app) } -fn render_review_status(review: ReviewDisplay, app: &AppContext) -> Box { +fn render_review_status( + action_id: &AIAgentActionId, + review: ReviewDisplay, + props: Props, + app: &AppContext, +) -> Box { let appearance = Appearance::as_ref(app); + let theme = appearance.theme(); + let state = props.review_pane_states.get(action_id); + let is_expanded = state.is_some_and(|state| { + matches!( + state.expansion_state, + CollapsibleExpansionState::Expanded { .. } + ) + }); + let needs_approval = matches!(review.state, ReviewDisplayState::AwaitingApproval); let icon = match review.state { ReviewDisplayState::Assessing => Icon::Loading.to_galaxyui_icon( galaxy_core::ui::theme::Fill::Solid(internal_colors::neutral_6(appearance.theme())), @@ -1336,37 +1352,117 @@ fn render_review_status(review: ReviewDisplay, app: &AppContext) -> Box "Approved", ReviewDisplayState::Denied => "Denied", }; - let header = HeaderConfig::new(title, app) + let mut header = HeaderConfig::new(title, app) .with_icon(icon) - .with_corner_radius_override(CornerRadius::with_all(Radius::Pixels(8.))) - .render(app); - let detail = format!("{}\n{}", review.summary, review.detail); - let body = Container::new( - Text::new( - detail, - appearance.ai_font_family(), - appearance.monospace_font_size(), - ) - .with_color(blended_colors::text_main( - appearance.theme(), - appearance.theme().surface_1(), - )) - .finish(), - ) - .with_margin_left(16.) - .with_margin_right(16.) - .with_margin_bottom(12.) - .finish(); - render_tool_pane_shell( - Flex::column() - .with_child(Clipped::new(header).finish()) - .with_child(body) - .finish(), - matches!(review.state, ReviewDisplayState::AwaitingApproval), - false, - false, - app, - ) + .with_corner_radius_override(CornerRadius::with_top(Radius::Pixels(8.))); + if let Some(state) = state { + let action_id = action_id.clone(); + header = header.with_interaction_mode(InteractionMode::ManuallyExpandable( + ExpandedConfig::new(is_expanded, state.expansion_toggle_mouse_state.clone()) + .with_toggle_callback(move |ctx| { + ctx.dispatch_typed_action(AIBlockAction::ToggleReviewPaneExpanded( + action_id.clone(), + )); + }), + )); + } + + let mut content = Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_child(Clipped::new(header.render(app)).finish()); + if is_expanded { + let detail = Flex::column() + .with_child( + Text::new( + "ACTION", + appearance.ui_font_family(), + appearance.ui_font_size(), + ) + .with_color(theme.accent().into()) + .finish(), + ) + .with_child( + Container::new( + Text::new( + review.summary, + appearance.monospace_font_family(), + appearance.monospace_font_size(), + ) + .with_color(blended_colors::text_main(theme, theme.surface_1())) + .with_selectable(true) + .finish(), + ) + .with_margin_top(4.) + .with_margin_bottom(12.) + .finish(), + ) + .with_child( + Text::new( + "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())) + .with_selectable(true) + .finish(), + ) + .with_margin_top(4.) + .finish(), + ) + .finish(); + content.add_child( + Container::new(detail) + .with_horizontal_padding(INLINE_ACTION_HORIZONTAL_PADDING) + .with_padding_top(14.) + .with_padding_bottom(12.) + .with_background(theme.background()) + .finish(), + ); + } + if needs_approval { + if let Some(buttons) = props.action_buttons.get(action_id) { + content.add_child( + Container::new( + Flex::row() + .with_main_axis_alignment(MainAxisAlignment::End) + .with_child(buttons.cancel_button.render_expanded_button()) + .with_child( + Container::new(buttons.run_button.render_expanded_button()) + .with_margin_left(8.) + .finish(), + ) + .finish(), + ) + .with_horizontal_padding(INLINE_ACTION_HORIZONTAL_PADDING) + .with_padding_top(8.) + .with_padding_bottom(12.) + .with_background(theme.background()) + .finish(), + ); + } + } + let border_color = if needs_approval { + theme.accent() + } else { + theme.surface_2() + }; + Container::new(content.finish()) + .with_margin_left(CONTENT_HORIZONTAL_PADDING) + .with_margin_right(CONTENT_HORIZONTAL_PADDING) + .with_margin_bottom(CONTENT_ITEM_VERTICAL_MARGIN) + .with_corner_radius(CornerRadius::with_top(Radius::Pixels(8.))) + .with_border(Border::all(1.).with_border_fill(border_color)) + .finish() } fn render_runtime_activity( diff --git a/app/src/ai/blocklist/inline_action/requested_command.rs b/app/src/ai/blocklist/inline_action/requested_command.rs index aabdf43e..078b600e 100644 --- a/app/src/ai/blocklist/inline_action/requested_command.rs +++ b/app/src/ai/blocklist/inline_action/requested_command.rs @@ -934,7 +934,7 @@ impl RequestedCommandView { ) .with_child( Text::new( - "Your profile is set to always ask for permission to execute commands.", + "Command auto-execution is disabled in this profile.", appearance.ui_font_family(), font_size, )