first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
@@ -1,90 +1,79 @@
use std::{collections::HashSet, sync::Arc, time::Duration};
use std::collections::HashSet;
use std::sync::Arc;
use std::time::Duration;
use ai::agent::{
action::{AskUserQuestionItem, AskUserQuestionOption, AskUserQuestionType},
action_result::{AskUserQuestionAnswerItem, AskUserQuestionResult},
};
use galaxy_core::ui::theme::{color::internal_colors, GalaxyTheme};
use galaxyui::{
elements::{
new_scrollable::SingleAxisConfig, Border, ChildView, Clipped, ClippedScrollStateHandle,
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Expanded, Fill, Flex,
FormattedTextElement, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement,
Radius, Text, DEFAULT_UI_LINE_HEIGHT_RATIO,
},
keymap::{FixedBinding, Keystroke},
r#async::{SpawnedFutureHandle, Timer},
units::Pixels,
AppContext, Element, Entity, FocusContext, ModelHandle, SingletonEntity, TypedActionView, View,
ViewContext, ViewHandle,
};
use ai::agent::action::{AskUserQuestionItem, AskUserQuestionOption, AskUserQuestionType};
use ai::agent::action_result::{AskUserQuestionAnswerItem, AskUserQuestionResult};
use itertools::Itertools;
use crate::{
ai::{
agent::{
conversation::AIConversationId, icons::yellow_stop_icon, task::TaskId, AIAgentActionId,
AIAgentActionResult, AIAgentActionResultType,
},
blocklist::{
action_model::{AIActionStatus, BlocklistAIActionEvent, BlocklistAIActionModel},
block::{
compact_agent_input,
number_shortcut_buttons::{
self, NumberShortcutButtonBuilder, NumberShortcutButtons,
NumberShortcutButtonsConfig,
},
view_impl::{CONTENT_HORIZONTAL_PADDING, CONTENT_ITEM_VERTICAL_MARGIN},
},
inline_action::{
inline_action_header::{
ExpandedConfig, HeaderConfig, InteractionMode,
INLINE_ACTION_HEADER_VERTICAL_PADDING, INLINE_ACTION_HORIZONTAL_PADDING,
},
inline_action_icons::{self, icon_size},
requested_action::CTRL_C_KEYSTROKE,
},
BlocklistAIHistoryModel,
},
},
terminal::input::message_bar::{
common::{render_standard_message, standard_message_bar_height, styles},
Message, MessageItem,
},
ui_components::{blended_colors, icons::Icon},
view_components::{
action_button::{ButtonSize, KeystrokeSource, NakedTheme, PrimaryTheme},
compactible_action_button::CompactibleActionButton,
},
Appearance,
use galaxy_core::ui::theme::color::internal_colors;
use galaxy_core::ui::theme::WarpTheme;
use galaxyui::elements::new_scrollable::SingleAxisConfig;
use galaxyui::elements::{
Border, ChildView, Clipped, ClippedScrollStateHandle, ConstrainedBox, Container, CornerRadius,
CrossAxisAlignment, Fill, Flex, FormattedTextElement, MainAxisAlignment, MainAxisSize,
MouseStateHandle, ParentElement, Point, Radius, Stack, Text, DEFAULT_UI_LINE_HEIGHT_RATIO,
};
use galaxyui::event::DispatchedEvent;
use galaxyui::geometry::vector::Vector2F;
use galaxyui::keymap::{FixedBinding, Keystroke};
use galaxyui::r#async::{SpawnedFutureHandle, Timer};
use galaxyui::ui_components::components::Coords;
use galaxyui::units::Pixels;
use galaxyui::{
AfterLayoutContext, AppContext, Element, Entity, EntityId, EventContext, FocusContext,
LayoutContext, ModelHandle, PaintContext, SingletonEntity, SizeConstraint, TypedActionView,
View, ViewContext, ViewHandle,
};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::icons::yellow_stop_icon;
use crate::ai::agent::task::TaskId;
use crate::ai::agent::{AIAgentActionId, AIAgentActionResult, AIAgentActionResultType};
use crate::ai::blocklist::action_model::{
AIActionStatus, BlocklistAIActionEvent, BlocklistAIActionModel,
};
use crate::ai::blocklist::block::compact_agent_input;
use crate::ai::blocklist::block::number_shortcut_buttons::{
self, NumberShortcutButtonBuilder, NumberShortcutButtons, NumberShortcutButtonsConfig,
};
use crate::ai::blocklist::block::view_impl::{
render_autonomy_dropdown_setting_speedbump_footer, CONTENT_HORIZONTAL_PADDING,
CONTENT_ITEM_VERTICAL_MARGIN,
};
use crate::ai::blocklist::inline_action::inline_action_header::{
ExpandedConfig, HeaderConfig, InteractionMode, INLINE_ACTION_HEADER_VERTICAL_PADDING,
INLINE_ACTION_HORIZONTAL_PADDING,
};
use crate::ai::blocklist::inline_action::inline_action_icons::{self, icon_size};
use crate::ai::blocklist::inline_action::requested_action::CTRL_C_KEYSTROKE;
use crate::ai::blocklist::BlocklistAIHistoryModel;
use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel;
use crate::ai::execution_profiles::AskUserQuestionPermission;
use crate::terminal::input::message_bar::common::{
render_standard_message, standard_message_bar_height, styles,
};
use crate::terminal::input::message_bar::{Message, MessageItem};
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
use crate::view_components::action_button::{
ButtonSize, KeystrokeSource, NakedTheme, PrimaryTheme,
};
use crate::view_components::compactible_action_button::CompactibleActionButton;
use crate::view_components::dropdown::{Dropdown, DropdownItem};
use crate::Appearance;
const ASK_USER_QUESTION_ACTIVE: &str = "AskUserQuestionActive";
pub(crate) const ASK_USER_QUESTION_AUTO_ADVANCE_DELAY: Duration = Duration::from_millis(300);
pub(crate) const ASK_USER_QUESTION_MAX_CONTAINER_HEIGHT: f32 = 320.;
pub(crate) const ASK_USER_QUESTION_OPTION_BUTTON_VERTICAL_SPACING: f32 = 4.;
pub(crate) const ASK_USER_QUESTION_MAX_CONTAINER_HEIGHT: f32 = 520.;
pub(crate) const ASK_USER_QUESTION_SINGLE_MAX_CONTAINER_HEIGHT: f32 = 800.;
// Must match `MARGIN_BETWEEN_BUTTONS` in number_shortcut_buttons.rs so off-screen measurement
// copies match the interactive option list's height.
const ASK_USER_QUESTION_OPTION_BUTTON_VERTICAL_SPACING: f32 = 4.;
pub(crate) const ASK_USER_QUESTION_TEXT_TOP_PADDING: f32 = 16.;
pub(crate) const ASK_USER_QUESTION_TEXT_BOTTOM_PADDING: f32 = 8.;
pub(crate) const ASK_USER_QUESTION_OPTIONS_BOTTOM_PADDING: f32 = 16.;
// Assumes single-line labels; wrapped text will be taller but the container
// caps at ASK_USER_QUESTION_MAX_CONTAINER_HEIGHT and scrolls on overflow.
fn estimated_min_height_for_all_options(max_option_count: usize, monospace_font_size: f32) -> f32 {
(max_option_count as f32 * (monospace_font_size + 16.))
+ (max_option_count.saturating_sub(1) as f32
* ASK_USER_QUESTION_OPTION_BUTTON_VERTICAL_SPACING)
+ ASK_USER_QUESTION_OPTIONS_BOTTOM_PADDING
}
fn ask_user_question_text_height(appearance: &Appearance, app: &AppContext) -> f32 {
app.font_cache().line_height(
appearance.monospace_font_size(),
appearance.line_height_ratio(),
) + ASK_USER_QUESTION_TEXT_TOP_PADDING
+ ASK_USER_QUESTION_TEXT_BOTTOM_PADDING
}
fn ask_user_question_header_height(appearance: &Appearance, app: &AppContext) -> f32 {
let title_line_height = app.font_cache().line_height(
appearance.monospace_font_size(),
@@ -96,21 +85,6 @@ fn ask_user_question_header_height(appearance: &Appearance, app: &AppContext) ->
+ (2. * INLINE_ACTION_HEADER_VERTICAL_PADDING)
}
fn ask_user_question_container_height(
max_option_count: usize,
appearance: &Appearance,
has_nav_footer: bool,
app: &AppContext,
) -> f32 {
let mut natural_height = ask_user_question_header_height(appearance, app)
+ ask_user_question_text_height(appearance, app)
+ estimated_min_height_for_all_options(max_option_count, appearance.monospace_font_size());
if has_nav_footer {
natural_height += standard_message_bar_height(app) + 1.;
}
natural_height.min(ASK_USER_QUESTION_MAX_CONTAINER_HEIGHT)
}
fn ask_user_question_auto_advance_enabled(is_multiselect: bool, is_last_question: bool) -> bool {
is_last_question || !is_multiselect
}
@@ -145,7 +119,7 @@ pub fn init(app: &mut AppContext) {
}
/// View-level interactions for the ask-user-question UI (buttons, keyboard, and text input).
#[derive(Clone, Debug)]
#[derive(Clone, Debug, PartialEq)]
pub enum AskUserQuestionViewAction {
OptionToggled { option_index: usize },
SelectionConfirmed,
@@ -156,12 +130,15 @@ pub enum AskUserQuestionViewAction {
NavigatePrev,
ToggleExpanded,
EnterPressed,
SetPermission(AskUserQuestionPermission),
}
/// Emitted when local questionnaire state changes and the parent needs to refresh.
#[derive(Clone, Debug)]
pub enum AskUserQuestionViewEvent {
Updated,
/// Fired when the user selects a new permission from the speedbump dropdown.
SpeedbumpPermissionChanged(AskUserQuestionPermission),
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
@@ -400,6 +377,17 @@ impl AskUserQuestionSession {
})
}
/// Saved draft for the question at `index` (None when unanswered or not editing). Lets the
/// off-screen measurement copies reflect each question's real answer state, so a long saved
/// "Other..." answer is accounted for in the reserved height instead of resizing the card when
/// the user navigates back to that question.
fn draft_for_question(&self, index: usize) -> Option<&QuestionDraft> {
let AskUserQuestionState::Editing(editing) = &self.state else {
return None;
};
editing.draft_for_question(index)
}
fn current_question_index(&self) -> usize {
match &self.state {
AskUserQuestionState::Editing(editing) => editing.current_question_index(),
@@ -416,14 +404,6 @@ impl AskUserQuestionSession {
}
}
fn max_option_count(&self) -> usize {
self.questions
.iter()
.map(AskUserQuestionItem::numbered_option_count)
.max()
.unwrap_or(1)
}
// Centralize all state transitions so the view layer only maps UI events to actions and then
// applies the returned effect.
fn apply(&mut self, action: AskUserQuestionAction) -> AskUserQuestionEffect {
@@ -732,6 +712,12 @@ pub(crate) struct AskUserQuestionView {
toggle_mouse_state: MouseStateHandle,
skip_button: CompactibleActionButton,
next_button: CompactibleActionButton,
/// Settings link handle for the Ask-User-Question autonomy speedbump footer,
/// set by `AIBlock` when the speedbump is seeded for this action.
speedbump_settings_link_handle: Option<MouseStateHandle>,
/// Lazily created dropdown for the speedbump footer; owned here so the
/// view handle (and its event subscription) survives re-renders.
speedbump_dropdown: Option<ViewHandle<Dropdown<AskUserQuestionViewAction>>>,
}
impl AskUserQuestionView {
@@ -791,6 +777,8 @@ impl AskUserQuestionView {
toggle_mouse_state: MouseStateHandle::default(),
skip_button,
next_button,
speedbump_settings_link_handle: None,
speedbump_dropdown: None,
};
ctx.subscribe_to_model(&action_model, |me, _, event, ctx| {
@@ -818,6 +806,75 @@ impl AskUserQuestionView {
self.session.is_editing()
}
/// Sets the settings-link mouse handle used in the speedbump footer.
/// The footer is only actually drawn during the completed/finished render paths.
pub fn set_speedbump_settings_link(
&mut self,
settings_link_handle: Option<MouseStateHandle>,
ctx: &mut ViewContext<Self>,
) {
self.speedbump_settings_link_handle = settings_link_handle;
ctx.notify();
}
/// Creates the dropdown view for the speedbump footer. No-op if already initialized.
pub fn init_speedbump_dropdown(&mut self, ctx: &mut ViewContext<Self>) {
if self.speedbump_dropdown.is_some() {
return;
}
let view = ctx.add_typed_action_view(|ctx| {
let dropdown_font_size = Appearance::as_ref(ctx).monospace_font_size() - 1.;
let mut dropdown = Dropdown::new(ctx);
dropdown.set_font_size(dropdown_font_size, ctx);
dropdown.set_padding(
Coords {
top: 2.,
bottom: 2.,
left: 8.,
right: 8.,
},
ctx,
);
dropdown.set_vertical_margin(0., ctx);
dropdown.set_top_bar_height(24., ctx);
dropdown.set_main_axis_size(MainAxisSize::Min, ctx);
let permissions = [
AskUserQuestionPermission::Never,
AskUserQuestionPermission::AskExceptInAutoApprove,
AskUserQuestionPermission::AlwaysAsk,
];
dropdown.set_items(
permissions
.into_iter()
.map(|p| {
DropdownItem::new(p.label(), AskUserQuestionViewAction::SetPermission(p))
})
.collect(),
ctx,
);
dropdown
});
self.speedbump_dropdown = Some(view);
}
/// Updates the dropdown's selected item to match the active execution profile.
pub fn refresh_speedbump_dropdown_selection(
&mut self,
terminal_view_id: EntityId,
ctx: &mut ViewContext<Self>,
) {
let Some(dropdown) = self.speedbump_dropdown.clone() else {
return;
};
let permission = AIExecutionProfilesModel::as_ref(ctx)
.active_profile(Some(terminal_view_id), ctx)
.data()
.ask_user_question;
dropdown.update(ctx, |dropdown, ctx| {
dropdown.set_selected_by_name(permission.label(), ctx);
});
}
/// Recover completed/cancelled status even if the live action entry is gone, so restored
/// conversations still render deterministically.
fn action_status(&self, app: &AppContext) -> Option<AIActionStatus> {
@@ -1183,20 +1240,23 @@ impl AskUserQuestionView {
fn render_active(&self, appearance: &Appearance, app: &AppContext) -> Option<Box<dyn Element>> {
let theme = appearance.theme();
let current = self.session.current()?;
let mut question_text = current.question.question.clone();
if current.question.is_multiselect() {
question_text.push_str(" (select all that apply)");
let question_text = Self::question_display_text(current.question);
let is_single_question = !self.session.has_multiple_questions();
let has_nav_footer = !is_single_question;
let max_height = if is_single_question {
ASK_USER_QUESTION_SINGLE_MAX_CONTAINER_HEIGHT
} else {
ASK_USER_QUESTION_MAX_CONTAINER_HEIGHT
};
let mut non_body_height = ask_user_question_header_height(appearance, app);
if has_nav_footer {
non_body_height += standard_message_bar_height(app) + 1.;
}
let has_nav_footer = self.session.has_multiple_questions();
let container_height = ask_user_question_container_height(
self.session.max_option_count(),
appearance,
has_nav_footer,
app,
);
let body_max_height = (max_height - non_body_height).max(0.);
let mut content = Flex::column()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Stretch);
let mut header_right = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
@@ -1212,12 +1272,33 @@ impl AskUserQuestionView {
.with_corner_radius_override(CornerRadius::with_top(Radius::Pixels(8.)))
.render_header(app, Some(header_right.finish())),
);
// The body sizes to its content, capped at `body_max_height` (scrolling beyond that). For
// multi-question cards we stack an off-screen, non-interactive measurement copy of every
// question behind the visible one. `Stack` sizes to its tallest child, so the card collapses
// to the height of the tallest question and stays fixed as the user navigates, rather than
// resizing per question (only the visible question can be measured directly otherwise).
let visible_body = self.render_question_body(&question_text, appearance, theme);
let body: Box<dyn Element> = if is_single_question {
visible_body
} else {
let mut stack = Stack::new();
for (index, question) in self.session.questions().iter().enumerate() {
stack.add_child(Self::render_measurement_body(
question,
self.session.draft_for_question(index),
appearance,
theme,
app,
));
}
stack.add_child(visible_body);
stack.finish()
};
content.add_child(
Expanded::new(
1.,
self.render_question_body(&question_text, appearance, theme),
)
.finish(),
ConstrainedBox::new(body)
.with_max_height(body_max_height)
.finish(),
);
if has_nav_footer {
@@ -1228,7 +1309,7 @@ impl AskUserQuestionView {
Some(
wrap_with_content_item_spacing(
ConstrainedBox::new(content.finish())
.with_height(container_height)
.with_max_height(max_height)
.finish(),
)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
@@ -1309,27 +1390,92 @@ impl AskUserQuestionView {
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
let header = HeaderConfig::new(label, app)
let speedbump_footer = self
.speedbump_settings_link_handle
.clone()
.filter(|_| self.speedbump_dropdown.is_some())
.and_then(|handle| self.render_speedbump_footer(handle, appearance, app));
let has_speedbump_footer = speedbump_footer.is_some();
let mut header_config = HeaderConfig::new(label, app)
.with_icon(status_icon)
.with_interaction_mode(InteractionMode::ManuallyExpandable(
ExpandedConfig::new(self.is_expanded, self.toggle_mouse_state.clone())
.with_toggle_callback(|ctx| {
ctx.dispatch_typed_action(AskUserQuestionViewAction::ToggleExpanded);
}),
))
.render(app);
));
if has_speedbump_footer {
header_config = header_config
.with_corner_radius_override(CornerRadius::with_top(Radius::Pixels(8.)));
}
let header = header_config.render(app);
// Collapsed state: just the header — but still draw the speedbump footer if it's
// present, since the autonomy nudge must stay visible even if the user collapses
// their answers.
if !self.is_expanded {
if let Some(footer) = speedbump_footer {
let mut wrapper =
Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
wrapper.add_child(header);
wrapper.add_child(footer);
return wrap_with_agent_output_item_spacing(wrapper.finish(), app).finish();
}
return wrap_with_agent_output_item_spacing(header, app).finish();
}
let mut wrapper = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
wrapper.add_child(header);
wrapper.add_child(render_answers(questions, answers, appearance));
wrapper.add_child(render_answers(
questions,
answers,
appearance,
has_speedbump_footer,
));
if let Some(footer) = speedbump_footer {
wrapper.add_child(footer);
return wrap_with_agent_output_item_spacing(wrapper.finish(), app).finish();
}
wrap_with_agent_output_item_spacing(wrapper.finish(), app)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.finish()
}
fn render_speedbump_footer(
&self,
settings_link_handle: MouseStateHandle,
appearance: &Appearance,
app: &AppContext,
) -> Option<Box<dyn Element>> {
let theme = appearance.theme();
let dropdown = self.speedbump_dropdown.as_ref()?;
let row = render_autonomy_dropdown_setting_speedbump_footer(
"Allow the agent to ask questions:",
dropdown,
settings_link_handle,
app,
);
Some(
Container::new(row)
.with_horizontal_padding(INLINE_ACTION_HORIZONTAL_PADDING)
.with_vertical_padding(4.)
.with_background(theme.surface_1())
.with_corner_radius(CornerRadius::with_bottom(Radius::Pixels(8.)))
.finish(),
)
}
/// The question prompt as shown to the user, with the multiselect hint appended. Shared by the
/// live body and its measurement copies so both wrap at the same height.
fn question_display_text(question: &AskUserQuestionItem) -> String {
let mut text = question.question.clone();
if question.is_multiselect() {
text.push_str(" (select all that apply)");
}
text
}
fn render_question_text(
question_text: &str,
appearance: &Appearance,
@@ -1359,16 +1505,45 @@ impl AskUserQuestionView {
appearance: &Appearance,
theme: &GalaxyTheme,
) -> Box<dyn Element> {
let body = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(Self::render_question_text(question_text, appearance, theme))
.with_child(self.render_options_list())
.finish();
Self::wrap_scrollable_body(
Self::render_question_text(question_text, appearance, theme),
self.render_options_list(),
self.options_scroll_state.clone(),
theme,
)
}
/// Stacks the question text above its options. Shared by the live body and its measurement
/// copies. `MainAxisSize::Min` keeps the column content-sized so the surrounding scrollable
/// reports the natural height (clamped to the cap) instead of filling the available space.
fn body_column(question_text: Box<dyn Element>, options: Box<dyn Element>) -> Box<dyn Element> {
Flex::column()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(question_text)
.with_child(options)
.finish()
}
/// Applies the body's horizontal insets. Shared so the live body and its measurement copies
/// wrap text at exactly the same width.
fn with_body_insets(content: Box<dyn Element>) -> Box<dyn Element> {
Container::new(content)
.with_margin_left(INLINE_ACTION_HORIZONTAL_PADDING)
.with_margin_right(12.)
.finish()
}
fn wrap_scrollable_body(
question_text: Box<dyn Element>,
options: Box<dyn Element>,
scroll_state: ClippedScrollStateHandle,
theme: &WarpTheme,
) -> Box<dyn Element> {
let scrollable = galaxyui::elements::NewScrollable::vertical(
SingleAxisConfig::Clipped {
handle: self.options_scroll_state.clone(),
child: body,
handle: scroll_state,
child: Self::body_column(question_text, options),
},
theme.nonactive_ui_detail().into(),
theme.active_ui_detail().into(),
@@ -1376,9 +1551,55 @@ impl AskUserQuestionView {
)
.finish();
Container::new(Clipped::new(scrollable).finish())
.with_margin_left(INLINE_ACTION_HORIZONTAL_PADDING)
.with_margin_right(12.)
Self::with_body_insets(Clipped::new(scrollable).finish())
}
/// Builds a non-interactive, non-painted copy of a question's body purely for layout
/// measurement. It goes through the same `wrap_scrollable_body` wrapper as the live body so the
/// two measure identically once a question overflows (the scrollable affects wrapping and
/// clamping); `MeasureOnly` keeps it off-screen and inert. The question's saved `draft` is
/// passed through so the copy reflects its real answer state (e.g. a long custom "Other..."
/// answer) and the card stays a fixed height across navigation.
fn render_measurement_body(
question: &AskUserQuestionItem,
draft: Option<&QuestionDraft>,
appearance: &Appearance,
theme: &WarpTheme,
app: &AppContext,
) -> Box<dyn Element> {
let current = AskUserQuestionCurrent { question, draft };
let body = Self::wrap_scrollable_body(
Self::render_question_text(&Self::question_display_text(question), appearance, theme),
Self::render_static_options(current, app),
ClippedScrollStateHandle::new(),
theme,
);
MeasureOnly::new(body).finish()
}
/// Renders a question's option buttons as static, non-interactive elements matching the live
/// `NumberShortcutButtons` layout. Used only for measurement copies.
fn render_static_options(
current: AskUserQuestionCurrent<'_>,
app: &AppContext,
) -> Box<dyn Element> {
let builders = Self::build_question_buttons(Some(current), None);
let button_count = builders.len();
let mut options = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
for (index, builder) in builders.iter().enumerate() {
let margin_bottom = if index + 1 == button_count {
0.
} else {
ASK_USER_QUESTION_OPTION_BUTTON_VERTICAL_SPACING
};
options.add_child(
Container::new(builder.build_measurement_element(app))
.with_margin_bottom(margin_bottom)
.finish(),
);
}
Container::new(options.finish())
.with_padding_bottom(ASK_USER_QUESTION_OPTIONS_BOTTOM_PADDING)
.finish()
}
@@ -1506,11 +1727,13 @@ impl TypedActionView for AskUserQuestionView {
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
// Editing actions only run while this inline block is still the active user blocker. The
// completion summary stays interactive via ToggleExpanded after completion.
if !matches!(action, AskUserQuestionViewAction::ToggleExpanded)
&& (!self.session.is_editing()
|| !self.is_waiting_on_user_answers(ctx)
|| self.session.current().is_none())
// completion summary stays interactive via ToggleExpanded and SetPermission after completion.
if !matches!(
action,
AskUserQuestionViewAction::ToggleExpanded | AskUserQuestionViewAction::SetPermission(_)
) && (!self.session.is_editing()
|| !self.is_waiting_on_user_answers(ctx)
|| self.session.current().is_none())
{
return;
}
@@ -1561,6 +1784,11 @@ impl TypedActionView for AskUserQuestionView {
AskUserQuestionViewAction::ToggleExpanded => {
self.is_expanded = !self.is_expanded;
}
AskUserQuestionViewAction::SetPermission(permission) => {
ctx.emit(AskUserQuestionViewEvent::SpeedbumpPermissionChanged(
*permission,
));
}
AskUserQuestionViewAction::EnterPressed => {
self.abort_auto_advance();
let highlighted_index = self
@@ -1616,6 +1844,7 @@ fn render_answers(
questions: &[AskUserQuestionItem],
answers: Option<&[AskUserQuestionAnswerItem]>,
appearance: &Appearance,
flatten_bottom: bool,
) -> Box<dyn Element> {
let theme = appearance.theme();
let font_size = appearance.monospace_font_size();
@@ -1651,10 +1880,11 @@ fn render_answers(
content.add_child(item_container.finish());
}
Container::new(content.finish())
.with_background(theme.surface_2())
.with_corner_radius(CornerRadius::with_bottom(Radius::Pixels(8.)))
.finish()
let mut container = Container::new(content.finish()).with_background(theme.surface_2());
if !flatten_bottom {
container = container.with_corner_radius(CornerRadius::with_bottom(Radius::Pixels(8.)));
}
container.finish()
}
fn wrap_with_content_item_spacing(element: Box<dyn Element>) -> Container {
@@ -1699,6 +1929,58 @@ pub(crate) fn render_text_with_markdown_support(
}
}
/// An element that lays out its child—so the child contributes to sizing—but never paints it or
/// routes events to it, clamping the reported size to the incoming constraint. Combined with
/// `Stack`, this lets a multi-question card measure off-screen copies of every question and size to
/// the tallest one, without showing or interacting with them.
struct MeasureOnly {
child: Box<dyn Element>,
}
impl MeasureOnly {
fn new(child: Box<dyn Element>) -> Self {
Self { child }
}
}
impl Element for MeasureOnly {
fn layout(
&mut self,
constraint: SizeConstraint,
ctx: &mut LayoutContext,
app: &AppContext,
) -> Vector2F {
// Clamp to the incoming constraint so an over-tall copy can't push the `Stack` past the
// available height (the copy has no scrollable of its own to do this clamping).
self.child.layout(constraint, ctx, app).min(constraint.max)
}
fn after_layout(&mut self, _ctx: &mut AfterLayoutContext, _app: &AppContext) {
// Measurement-only: the child is never painted, so there is nothing to register here.
}
fn paint(&mut self, _origin: Vector2F, _ctx: &mut PaintContext, _app: &AppContext) {
// Intentionally not painted.
}
fn size(&self) -> Option<Vector2F> {
self.child.size()
}
fn origin(&self) -> Option<Point> {
None
}
fn dispatch_event(
&mut self,
_event: &DispatchedEvent,
_ctx: &mut EventContext,
_app: &AppContext,
) -> bool {
false
}
}
#[cfg(test)]
#[path = "ask_user_question_view_tests.rs"]
mod tests;
@@ -1,11 +1,10 @@
use ai::agent::action::{AskUserQuestionItem, AskUserQuestionOption, AskUserQuestionType};
use ai::agent::action_result::AskUserQuestionAnswerItem;
use super::{
ask_user_question_view_state, AskUserQuestionAction, AskUserQuestionEffect,
AskUserQuestionPhase, AskUserQuestionSession, AskUserQuestionViewState,
};
use ai::agent::{
action::{AskUserQuestionItem, AskUserQuestionOption, AskUserQuestionType},
action_result::AskUserQuestionAnswerItem,
};
fn build_question(
question_id: &str,
@@ -11,15 +11,12 @@ use galaxyui::{
};
use settings::Setting as _;
use crate::report_if_error;
use crate::Appearance;
use super::inline_action_icons::icon_size;
use crate::ai::blocklist::view_util::error_color;
use crate::settings::{AISettings, AISettingsChangedEvent};
use crate::ui_components::blended_colors;
use crate::view_components::action_button::{ActionButton, ButtonSize, NakedTheme, PrimaryTheme};
use super::inline_action_icons::icon_size;
use crate::ai::blocklist::view_util::error_color;
use crate::{report_if_error, Appearance};
#[derive(Clone, Debug)]
pub enum AwsBedrockCredentialsErrorAction {
@@ -1,46 +1,51 @@
use crate::ai::blocklist::view_util::render_provider_icon_button;
use crate::ai::skills::{SkillOpenOrigin, SkillTelemetryEvent};
use std::collections::HashMap;
use std::ops::Range;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::sync::Arc;
use std::time::Duration;
use ai::diff_validation::{
fuzzy_match_diffs, fuzzy_match_v4a_diffs, parse_line_numbers, DiffDelta, DiffType, ParsedDiff,
SearchAndReplace, V4AHunk,
};
use anyhow::Result;
use galaxy_core::{
features::FeatureFlag,
platform::SessionPlatform,
settings::ToggleableSetting,
ui::{
appearance::Appearance,
color::CLAUDE_ORANGE,
theme::{
color::internal_colors::{fg_overlay_6, neutral_1, neutral_4},
Fill,
},
},
HostId,
};
use galaxy_editor::{
content::buffer::InitialBufferState, render::element::VerticalExpansionBehavior,
};
use galaxy_util::file::FileSaveError;
use galaxy_util::path::common_path;
use galaxy_util::standardized_path::StandardizedPath;
use galaxyui::{
elements::{
new_scrollable::{ScrollableAppearance, SingleAxisConfig},
Align, Border, ChildAnchor, ChildView, Clipped, ClippedScrollStateHandle, ConstrainedBox,
Container, CornerRadius, CrossAxisAlignment, DispatchEventResult, Empty, EventHandler,
Flex, FormattedTextElement, HighlightedHyperlink, Hoverable, MainAxisAlignment,
MainAxisSize, MouseStateHandle, NewScrollable, OffsetPositioning, ParentAnchor,
ParentElement, ParentOffsetBounds, PositionedElementAnchor, PositionedElementOffsetBounds,
Radius, SavePosition, ScrollTarget, ScrollToPositionMode, ScrollbarWidth, Shrinkable,
SizeConstraintCondition, SizeConstraintSwitch, Stack, Text,
},
keymap::{EditableBinding, FixedBinding, Keystroke},
platform::{Cursor, OperatingSystem},
ui_components::components::{Coords, UiComponent, UiComponentStyles},
AppContext, Element, Entity, FocusContext, ModelHandle, SingletonEntity, TypedActionView, View,
ViewContext, ViewHandle,
};
use lazy_static::lazy_static;
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
use pathfinder_geometry::vector::vec2f;
use rand::distributions::Alphanumeric;
use rand::{thread_rng, Rng as _};
use galaxy_core::features::FeatureFlag;
use galaxy_core::platform::SessionPlatform;
use galaxy_core::settings::ToggleableSetting;
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::color::CLAUDE_ORANGE;
use galaxy_core::ui::theme::color::internal_colors::{fg_overlay_6, neutral_1, neutral_4};
use galaxy_core::ui::theme::Fill;
use galaxy_core::HostId;
use galaxy_editor::content::buffer::InitialBufferState;
use galaxy_editor::render::element::VerticalExpansionBehavior;
use galaxy_util::file::FileSaveError;
use galaxy_util::local_or_remote_path::LocalOrRemotePath;
use galaxy_util::remote_path::RemotePath;
use galaxy_util::standardized_path::StandardizedPath;
use galaxyui::elements::new_scrollable::{ScrollableAppearance, SingleAxisConfig};
use galaxyui::elements::{
Align, Border, ChildAnchor, ChildView, Clipped, ClippedScrollStateHandle, ConstrainedBox,
Container, CornerRadius, CrossAxisAlignment, DispatchEventResult, Empty, EventHandler, Flex,
FormattedTextElement, HighlightedHyperlink, Hoverable, MainAxisAlignment, MainAxisSize,
MouseStateHandle, NewScrollable, OffsetPositioning, ParentAnchor, ParentElement,
ParentOffsetBounds, PositionedElementAnchor, PositionedElementOffsetBounds, Radius,
SavePosition, ScrollTarget, ScrollToPositionMode, ScrollbarWidth, Shrinkable,
SizeConstraintCondition, SizeConstraintSwitch, Stack, Text,
};
use galaxyui::keymap::{EditableBinding, FixedBinding, Keystroke};
use galaxyui::platform::{Cursor, OperatingSystem};
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use galaxyui::{
AppContext, Element, Entity, FocusContext, ModelHandle, SingletonEntity, TypedActionView, View,
ViewContext, ViewHandle,
};
use rand::{distributions::Alphanumeric, thread_rng, Rng as _};
use std::{
collections::HashMap,
@@ -51,76 +56,58 @@ use std::{
};
use super::malformed_line_heuristics::has_malformed_terminal_correction_signal;
use crate::view_components::action_button::{ActionButton, NakedTheme};
use crate::{
ai::{
agent::{
icons::{self, yellow_stop_icon},
AIAgentActionId, AIIdentifiers, FileEdit, FileLocations, ServerOutputId,
},
blocklist::{
action_model::{
AIActionStatus, BlocklistAIActionEvent, BlocklistAIActionModel,
EditAcceptAndContinueClickedEvent, EditAcceptClickedEvent, EditResolvedEvent,
EditStats, MalformedFinalLineProxyEvent, RequestFileEditsFormatKind,
RequestFileEditsTelemetryEvent,
},
history_model::BlocklistAIHistoryModel,
inline_action::{
inline_action_header::INLINE_ACTION_HORIZONTAL_PADDING,
inline_action_icons::{cancelled_icon, green_check_icon, icon_size, reverted_icon},
},
model::{AIBlockModel, AIBlockModelHelper},
RequestedEditResolution,
},
mcp::{mcp_provider_from_file_path, MCPProvider},
paths::host_native_absolute_path,
predict::prompt_suggestions::ACCEPT_PROMPT_SUGGESTION_KEYBINDING,
skills::{
icon_override_for_skill_name, render_skill_button, skill_path_from_file_path,
SkillManager, SkillReference,
},
},
cmd_or_ctrl_shift,
code::{
diff_viewer::{DiffViewer, DisplayMode},
editor::{
add_color, remove_color,
view::{CodeEditorEvent, CodeEditorRenderOptions, CodeEditorView},
},
inline_diff::{InlineDiffView, InlineDiffViewEvent},
DiffResult,
},
code_review::telemetry_event::CodeReviewPaneEntrypoint,
menu::{Event as MenuEvent, Menu, MenuItemFields, MenuVariant},
pane_group::{
focus_state::PaneFocusHandle,
pane::{view, PaneId},
BackingView, PaneEvent,
},
send_telemetry_from_ctx,
server::telemetry::{AgentModeCodeFileNavigationSource, ToggleCodeSuggestionsSettingSource},
settings::AISettings,
terminal::{input::SET_INPUT_MODE_AGENT_ACTION_NAME, ShellLaunchData},
ui_components::{blended_colors, icons::Icon},
util::bindings::keybinding_name_to_keystroke,
view_components::{
action_button::{ButtonSize, KeystrokeSource},
compactible_action_button::{
render_compact_and_regular_button_rows, CompactibleActionButton,
RenderCompactibleActionButton, MEDIUM_SIZE_SWITCH_THRESHOLD,
XLARGE_SIZE_SWITCH_THRESHOLD,
},
compactible_split_action_button::CompactibleSplitActionButton,
DismissibleToast,
},
workspace::ToastStack,
TelemetryEvent,
use crate::ai::agent::icons::{self, yellow_stop_icon};
use crate::ai::agent::{AIAgentActionId, AIIdentifiers, FileEdit, FileLocations, ServerOutputId};
use crate::ai::blocklist::action_model::{
AIActionStatus, BlocklistAIActionEvent, BlocklistAIActionModel,
EditAcceptAndContinueClickedEvent, EditAcceptClickedEvent, EditResolvedEvent, EditStats,
MalformedFinalLineProxyEvent, RequestFileEditsFormatKind, RequestFileEditsTelemetryEvent,
};
use ai::diff_validation::{
fuzzy_match_diffs, fuzzy_match_v4a_diffs, parse_line_numbers, DiffDelta, DiffType, ParsedDiff,
SearchAndReplace, V4AHunk,
use crate::ai::blocklist::history_model::BlocklistAIHistoryModel;
use crate::ai::blocklist::inline_action::inline_action_header::INLINE_ACTION_HORIZONTAL_PADDING;
use crate::ai::blocklist::inline_action::inline_action_icons::{
cancelled_icon, green_check_icon, icon_size, reverted_icon,
};
use crate::ai::blocklist::model::{AIBlockModel, AIBlockModelHelper};
use crate::ai::blocklist::view_util::render_provider_icon_button;
use crate::ai::blocklist::RequestedEditResolution;
use crate::ai::mcp::{mcp_provider_from_file_path, MCPProvider};
use crate::ai::paths::host_native_absolute_path;
use crate::ai::predict::prompt_suggestions::ACCEPT_PROMPT_SUGGESTION_KEYBINDING;
use crate::ai::skills::{
icon_override_for_skill_name, render_skill_button, skill_path_from_location, SkillManager,
SkillOpenOrigin, SkillReference, SkillTelemetryEvent,
};
use crate::code::diff_viewer::{DiffViewer, DisplayMode};
use crate::code::editor::view::{CodeEditorEvent, CodeEditorRenderOptions, CodeEditorView};
use crate::code::editor::{add_color, remove_color};
use crate::code::inline_diff::{InlineDiffView, InlineDiffViewEvent};
use crate::code::DiffResult;
use crate::code_review::telemetry_event::CodeReviewPaneEntrypoint;
use crate::menu::{Event as MenuEvent, Menu, MenuItemFields, MenuVariant};
use crate::pane_group::focus_state::PaneFocusHandle;
use crate::pane_group::pane::{view, PaneId};
use crate::pane_group::{BackingView, PaneEvent};
use crate::server::telemetry::{
AgentModeCodeFileNavigationSource, ToggleCodeSuggestionsSettingSource,
};
use crate::settings::AISettings;
use crate::terminal::input::SET_INPUT_MODE_AGENT_ACTION_NAME;
use crate::terminal::ShellLaunchData;
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
use crate::util::bindings::keybinding_name_to_keystroke;
use crate::view_components::action_button::{
ActionButton, ButtonSize, KeystrokeSource, NakedTheme,
};
use crate::view_components::compactible_action_button::{
render_compact_and_regular_button_rows, CompactibleActionButton, RenderCompactibleActionButton,
MEDIUM_SIZE_SWITCH_THRESHOLD, XLARGE_SIZE_SWITCH_THRESHOLD,
};
use crate::view_components::compactible_split_action_button::CompactibleSplitActionButton;
use crate::view_components::DismissibleToast;
use crate::workspace::ToastStack;
use crate::{cmd_or_ctrl_shift, send_telemetry_from_ctx, TelemetryEvent};
const REQUESTED_EDIT_CANCEL_LABEL: &str = "Cancel";
const REQUESTED_EDIT_REFINE_LABEL: &str = "Refine";
@@ -270,7 +257,7 @@ pub enum CodeDiffViewEvent {
/// Emitted when the user opens a skill file from a code diff
OpenSkill {
reference: SkillReference,
path: PathBuf,
path: LocalOrRemotePath,
},
/// Emitted when the user opens an MCP config file from a code diff
OpenMCPConfig {
@@ -445,7 +432,7 @@ pub enum CodeDiffViewAction {
RevertChanges,
OpenSkill {
reference: SkillReference,
path: PathBuf,
path: LocalOrRemotePath,
mouse_state: MouseStateHandle,
},
OpenMCPConfig {
@@ -1000,15 +987,14 @@ impl CodeDiffView {
let file_path = diff.base.file_path.clone();
// Set up the editor buffer with the pre-loaded content.
let path = Path::new(&file_path);
let standardized_path = StandardizedPath::try_new(&file_path).ok();
editor.update(ctx, |editor_view, ctx| {
editor_view.set_language_with_path(path, ctx);
editor_view.set_language_with_local_path(Path::new(&file_path), ctx);
let state = InitialBufferState::plain_text(&diff.base.content);
editor_view.reset(state, ctx);
});
// Create the InlineDiffView which applies diffs to the editor buffer.
let standardized_path = StandardizedPath::try_new(&file_path).ok();
let diff_viewer = ctx.add_typed_action_view(|ctx| {
InlineDiffView::new(
editor.clone(),
@@ -1596,21 +1582,27 @@ impl CodeDiffView {
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Min);
let file_paths: Vec<PathBuf> = self
let file_locations: Vec<LocalOrRemotePath> = self
.pending_diffs
.iter()
.filter_map(|diff| {
diff.diff_view
.as_ref(app)
.file_path()
.and_then(|p| p.to_local_path())
self.location_for_standardized_path(diff.diff_view.as_ref(app).file_path()?)
})
.collect();
// Renders the 'open skill' button if all edited files live in the same skill directory
let skill = common_path(&file_paths)
.and_then(|common| skill_path_from_file_path(&common))
.and_then(|skill_path| SkillManager::as_ref(app).skill_by_path(&skill_path));
// Renders the 'open skill' button only if every edited file lives in the same skill directory.
let skill_paths = file_locations
.iter()
.map(skill_path_from_location)
.collect::<Option<Vec<_>>>();
let skill = skill_paths.and_then(|skill_paths| {
let first_path = skill_paths.first()?;
skill_paths
.iter()
.all(|path| path == first_path)
.then(|| SkillManager::as_ref(app).skill_by_path(first_path))
.flatten()
});
if let Some(skill) = skill {
let skill_path = skill.path.clone();
let skill_reference = SkillManager::handle(app)
@@ -1643,7 +1635,12 @@ impl CodeDiffView {
// Renders the 'open config' button only when every MCP config file in this diff
// belongs to the same provider. Mixed-provider diffs (e.g. editing both a Claude
// config and a Warp config at once) show no badge to avoid misleading attribution.
let mcp_configs: Vec<_> = file_paths
// MCP config actions currently operate on local paths only.
let local_file_paths: Vec<PathBuf> = file_locations
.iter()
.filter_map(|path| path.to_local_path().map(Path::to_path_buf))
.collect();
let mcp_configs: Vec<_> = local_file_paths
.iter()
.filter_map(|path| {
mcp_provider_from_file_path(path).map(|provider| (provider, path.to_path_buf()))
@@ -2375,7 +2372,21 @@ impl CodeDiffView {
file_path_str = rename.to_string_lossy().to_string();
}
let was_edited = diff.diff_view.as_ref(ctx).was_edited();
let changed_lines = diff.diff_view.as_ref(ctx).changed_lines(ctx);
let editor_changed_lines = diff.diff_view.as_ref(ctx).changed_lines(ctx);
let changed_lines = changed_lines_for_result(
editor_changed_lines.clone(),
diff.diff_view.as_ref(ctx).diff(),
);
let changed_lines_for_malformed_signal = if editor_changed_lines.is_empty()
{
changed_lines
.iter()
.cloned()
.map(file_context_range_to_editor_range)
.collect()
} else {
editor_changed_lines
};
let has_malformed_terminal_signal = diff
.diff_view
.as_ref(ctx)
@@ -2383,7 +2394,7 @@ impl CodeDiffView {
.is_some_and(|editor_diff| {
has_malformed_terminal_correction_signal(
editor_diff,
&changed_lines,
&changed_lines_for_malformed_signal,
)
});
@@ -2401,12 +2412,7 @@ impl CodeDiffView {
updated_files.push((
FileLocations {
name: file_path_str,
lines: if FeatureFlag::ChangedLinesOnlyApplyDiffResult.is_enabled()
{
changed_lines
} else {
vec![]
},
lines: changed_lines,
},
was_edited,
));
@@ -2611,6 +2617,27 @@ impl CodeDiffView {
.file_path()
.map(|p| p.to_string())
}
/// Returns the primary file location as a `LocalOrRemotePath`,
/// using `diff_session_type` to correctly identify remote files.
pub fn primary_file_location(&self, app: &AppContext) -> Option<LocalOrRemotePath> {
self.pending_diffs
.first()?
.diff_view
.as_ref(app)
.file_path()
.and_then(|path| self.location_for_standardized_path(path))
}
fn location_for_standardized_path(&self, path: &StandardizedPath) -> Option<LocalOrRemotePath> {
match &self.diff_session_type {
DiffSessionType::Local => path.to_local_path().map(LocalOrRemotePath::Local),
DiffSessionType::Remote(host_id) => Some(LocalOrRemotePath::Remote(RemotePath {
host_id: host_id.clone(),
path: path.clone(),
})),
}
}
}
impl Entity for CodeDiffView {
@@ -3044,7 +3071,7 @@ pub fn convert_file_edits_to_file_diffs(
for (index, (_, search_content)) in search_blocks_with_ranges.iter().enumerate() {
if index > 0 {
// add "..." between each edit to indicate the seperation
// add "..." between each edit to indicate the separation
dummy_content.push_str("...\n");
}
for search_line in search_content.lines() {
@@ -3190,3 +3217,48 @@ fn keystroke_for_mode(key: &str, is_passive: bool) -> Keystroke {
..Default::default()
}
}
fn changed_lines_for_result(
editor_changed_lines: Vec<Range<usize>>,
diff_type: Option<&DiffType>,
) -> Vec<Range<usize>> {
if !editor_changed_lines.is_empty() {
return editor_changed_lines
.into_iter()
.map(editor_range_to_file_context_range)
.collect();
}
match diff_type {
Some(DiffType::Create { delta }) => inserted_content_range(1, &delta.insertion)
.into_iter()
.collect(),
Some(DiffType::Update { deltas, .. }) => deltas
.iter()
.filter_map(changed_line_range_for_delta)
.collect(),
Some(DiffType::Delete { .. }) | None => vec![],
}
}
fn changed_line_range_for_delta(delta: &DiffDelta) -> Option<Range<usize>> {
let replacement_range = &delta.replacement_line_range;
if replacement_range.start == replacement_range.end {
return inserted_content_range(replacement_range.start.max(1), &delta.insertion);
}
Some(replacement_range.clone())
}
fn inserted_content_range(start: usize, content: &str) -> Option<Range<usize>> {
let line_count = content.lines().count();
(line_count > 0).then_some(start..start + line_count)
}
fn editor_range_to_file_context_range(range: Range<usize>) -> Range<usize> {
range.start.saturating_add(1)..range.end.saturating_add(1)
}
fn file_context_range_to_editor_range(range: Range<usize>) -> Range<usize> {
range.start.saturating_sub(1)..range.end.saturating_sub(1)
}
@@ -0,0 +1,109 @@
use warpui::elements::{ChildView, Element, Empty};
use warpui::{AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle};
use crate::settings_view::handoff_environment_creation_modal::{
HandoffEnvironmentCreationModal, HandoffEnvironmentCreationModalEvent,
};
use crate::view_components::DismissibleToast;
use crate::workspace::ToastStack;
#[derive(Debug, Clone)]
pub enum CreateEnvironmentModalEvent {
Cancelled,
Created { environment_id: String },
}
pub struct CreateEnvironmentModal {
visible: bool,
handoff_modal: ViewHandle<HandoffEnvironmentCreationModal>,
}
impl CreateEnvironmentModal {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let handoff_modal =
ctx.add_typed_action_view(HandoffEnvironmentCreationModal::new_for_orchestration);
ctx.subscribe_to_view(&handoff_modal, |me, _, event, ctx| match event {
HandoffEnvironmentCreationModalEvent::Created { env_id } => {
me.visible = false;
ctx.emit(CreateEnvironmentModalEvent::Created {
environment_id: env_id.uid(),
});
ctx.notify();
}
HandoffEnvironmentCreationModalEvent::Cancelled => {
me.cancel(ctx);
}
HandoffEnvironmentCreationModalEvent::CreationFailed { error_message } => {
me.visible = false;
me.show_error_toast(
format!("Failed to create environment: {error_message}"),
ctx,
);
ctx.notify();
}
});
Self {
visible: false,
handoff_modal,
}
}
pub fn is_visible(&self) -> bool {
self.visible
}
pub fn show(&mut self, ctx: &mut ViewContext<Self>) {
self.visible = true;
self.handoff_modal.update(ctx, |modal, ctx| {
modal.show(ctx);
});
ctx.focus(&self.handoff_modal);
ctx.notify();
}
pub fn hide(&mut self, ctx: &mut ViewContext<Self>) {
self.visible = false;
ctx.notify();
}
fn cancel(&mut self, ctx: &mut ViewContext<Self>) {
self.hide(ctx);
ctx.emit(CreateEnvironmentModalEvent::Cancelled);
}
fn show_error_toast(&self, message: String, ctx: &mut ViewContext<Self>) {
let window_id = ctx.window_id();
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
toast_stack.add_ephemeral_toast(DismissibleToast::error(message), window_id, ctx);
});
}
}
impl Entity for CreateEnvironmentModal {
type Event = CreateEnvironmentModalEvent;
}
impl TypedActionView for CreateEnvironmentModal {
type Action = ();
fn handle_action(&mut self, _action: &Self::Action, _ctx: &mut ViewContext<Self>) {}
}
impl View for CreateEnvironmentModal {
fn ui_name() -> &'static str {
"CreateEnvironmentModal"
}
fn render(&self, _app: &AppContext) -> Box<dyn Element> {
if !self.visible {
return Empty::new().finish();
}
ChildView::new(&self.handoff_modal).finish()
}
}
#[cfg(test)]
#[path = "create_environment_modal_tests.rs"]
mod tests;
@@ -0,0 +1,84 @@
use galaxy_core::ui::appearance::Appearance;
use warpui::elements::Empty;
use warpui::platform::WindowStyle;
use warpui::{
AddSingletonModel, App, AppContext, Element, Entity, TypedActionView, View, WindowId,
};
use super::CreateEnvironmentModal;
use crate::ai::ambient_agents::github_auth_notifier::GitHubAuthNotifier;
use crate::auth::AuthStateProvider;
use crate::cloud_object::model::persistence::CloudModel;
use crate::network::NetworkStatus;
use crate::server::cloud_objects::update_manager::UpdateManager;
use crate::server::server_api::ServerApiProvider;
use crate::server::sync_queue::SyncQueue;
use crate::settings::PrivacySettings;
use crate::settings_view::keybindings::KeybindingChangedNotifier;
use crate::test_util::settings::initialize_settings_for_tests;
use crate::workspaces::team_tester::TeamTesterStatus;
use crate::workspaces::user_workspaces::UserWorkspaces;
#[derive(Default)]
struct TestRootView;
impl Entity for TestRootView {
type Event = ();
}
impl View for TestRootView {
fn ui_name() -> &'static str {
"TestRootView"
}
fn render(&self, _: &AppContext) -> Box<dyn Element> {
Empty::new().finish()
}
}
impl TypedActionView for TestRootView {
type Action = ();
}
fn create_test_window(app: &mut App) -> WindowId {
let (window_id, _root_view) = app.add_window(WindowStyle::NotStealFocus, |_| TestRootView);
window_id
}
fn init_create_environment_modal_test_models(app: &mut App) {
initialize_settings_for_tests(app);
app.add_singleton_model(|_ctx| ServerApiProvider::new_for_test());
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
app.add_singleton_model(|_| Appearance::mock());
app.add_singleton_model(CloudModel::mock);
app.add_singleton_model(UserWorkspaces::default_mock);
app.add_singleton_model(|_| NetworkStatus::new());
app.add_singleton_model(PrivacySettings::mock);
app.add_singleton_model(TeamTesterStatus::mock);
app.add_singleton_model(SyncQueue::mock);
app.add_singleton_model(UpdateManager::mock);
app.add_singleton_model(|_| KeybindingChangedNotifier::new());
app.add_singleton_model(|_| GitHubAuthNotifier::new());
}
#[test]
fn test_create_environment_modal_uses_orchestration_form_configuration() {
App::test((), |mut app| async move {
init_create_environment_modal_test_models(&mut app);
let window_id = create_test_window(&mut app);
app.update(|ctx| {
let view_handle = ctx.add_typed_action_view(window_id, CreateEnvironmentModal::new);
let modal = view_handle.as_ref(ctx);
assert!(
modal
.handoff_modal
.as_ref(ctx)
.uses_orchestration_form_configuration_for_test(ctx),
"Expected CreateEnvironmentModal to construct the handoff modal with orchestration form configuration"
);
});
})
}
@@ -1,16 +1,12 @@
use galaxyui::SingletonEntity;
use galaxyui::{
elements::{
Clipped, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Expanded, Flex,
Hoverable, Icon, MouseStateHandle, ParentElement, Radius, Text,
},
platform::Cursor,
AppContext, Element,
};
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::icons::Icon as CoreIcon;
use galaxy_core::ui::theme::color::internal_colors::neutral_2;
use galaxyui::elements::{
Clipped, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Expanded, Flex,
Hoverable, Icon, MouseStateHandle, ParentElement, Radius, Text,
};
use galaxyui::platform::Cursor;
use galaxyui::{AppContext, Element, SingletonEntity};
use crate::ai::blocklist::block::view_impl::WithContentItemSpacing;
use crate::ai::blocklist::inline_action::inline_action_header::{
@@ -0,0 +1,577 @@
//! Picker for the cloud-agent worker host slug.
//!
//! In list mode it shows a dropdown styled to match the other orchestration
//! pickers; in custom mode it swaps the top bar for an inline editor that
//! accepts a self-hosted worker slug. The layout mirrors the Oz webapp's
//! host selector: workspace default first (badged "Default"), then warp,
//! then connected worker hosts, then the user's most recent custom slug
//! marked as disconnected if it is not currently connected, then a
//! "Custom host…" entry.
use galaxy_core::ui::theme::Fill;
use warpui::elements::{
Border, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
Expanded, Flex, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement,
PositionedElementAnchor, Radius,
};
use warpui::fonts::{Properties, Weight};
use warpui::platform::Cursor;
use warpui::{
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use crate::ai::blocklist::inline_action::orchestration_controls::{
self as oc, ORCHESTRATION_PICKER_BORDER_WIDTH, ORCHESTRATION_PICKER_FONT_SIZE,
ORCHESTRATION_PICKER_HEIGHT, ORCHESTRATION_PICKER_RADIUS, ORCHESTRATION_WARP_WORKER_HOST,
};
use crate::appearance::Appearance;
use crate::editor::{
EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys, SingleLineEditorOptions,
TextOptions,
};
use crate::menu::{MenuItem, MenuItemFields};
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
use crate::view_components::dropdown::{
Dropdown, DropdownAction, DropdownEvent, DropdownStyle, DROPDOWN_PADDING,
};
// ── Public API types ────────────────────────────────────────────────
#[derive(Debug, Clone)]
pub enum HostPickerEvent {
/// Emitted when the dropdown opens so the parent can refresh dynamic
/// host sources before/while the menu is visible.
Opened,
/// Emitted with a non-empty, trimmed slug whenever the user picks a
/// known host or commits a custom entry.
HostChanged { slug: String },
/// Emitted when the menu closes or the inline editor blurs, so the
/// parent can refocus its own input.
Closed,
}
const CUSTOM_HOST_LABEL: &str = "Custom host…";
const DEFAULT_BADGE: &str = "Default";
const CONNECTED_BADGE: &str = "Connected";
const DISCONNECTED_BADGE: &str = "Disconnected";
const EDITOR_PLACEHOLDER: &str = "my-worker-host";
// ── Internal action plumbing ────────────────────────────────────────
/// Dispatched by the inner dropdown items and the inline cancel button.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InternalAction {
/// Pick a known host (warp, workspace default, or a recent slug).
SelectKnown(String),
/// Switch to custom-mode text input.
EnterCustomMode,
/// Exit custom mode without committing the editor contents.
CancelCustom,
}
// ── View ────────────────────────────────────────────────────────────
pub struct HostPicker {
/// The slug that would be sent to the server if dispatched now.
current_slug: String,
/// Admin-configured workspace default, when set.
default_host: Option<String>,
/// User's most-recent custom host, deduped against warp / default.
recent_host: Option<String>,
/// Currently connected self-hosted worker slugs.
connected_hosts: Vec<String>,
dropdown: ViewHandle<Dropdown<InternalAction>>,
editor: ViewHandle<EditorView>,
clear_mouse_state: MouseStateHandle,
is_custom_mode: bool,
/// Snapshot taken when the editor was opened so cancel can revert.
slug_before_edit: Option<String>,
}
impl HostPicker {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let (_styles, colors) = oc::picker_styles(Appearance::as_ref(ctx));
// Inner dropdown — styled to match the other orchestration pickers.
let dropdown = ctx.add_typed_action_view(|ctx_dropdown| {
let mut dropdown = Dropdown::<InternalAction>::new(ctx_dropdown);
dropdown.set_use_overlay_layer(false, ctx_dropdown);
dropdown.set_match_menu_width_to_top_bar(true, ctx_dropdown);
dropdown.set_main_axis_size(MainAxisSize::Max, ctx_dropdown);
dropdown.set_style(DropdownStyle::ActionButtonSecondary, ctx_dropdown);
dropdown.set_top_bar_height(ORCHESTRATION_PICKER_HEIGHT, ctx_dropdown);
dropdown.set_top_bar_max_width(f32::INFINITY);
dropdown.set_padding(colors.padding, ctx_dropdown);
dropdown.set_border_radius(colors.corner_radius, ctx_dropdown);
dropdown.set_background(colors.background, ctx_dropdown);
dropdown.set_border_width(ORCHESTRATION_PICKER_BORDER_WIDTH, ctx_dropdown);
dropdown.set_font_size(ORCHESTRATION_PICKER_FONT_SIZE, ctx_dropdown);
dropdown
});
ctx.subscribe_to_view(&dropdown, |me, _, event, ctx| {
match event {
DropdownEvent::ToggleExpanded => {
ctx.emit(HostPickerEvent::Opened);
}
DropdownEvent::Close => {
// Don't propagate Closed while transitioning into custom
// mode — the parent would refocus itself, blur the editor
// we just focused, and the resulting commit-on-blur would
// immediately revert us back out of custom mode.
if me.is_custom_mode {
return;
}
ctx.emit(HostPickerEvent::Closed);
ctx.notify();
}
}
});
let editor = ctx.add_typed_action_view(|ctx_editor| {
let appearance = Appearance::as_ref(ctx_editor);
let mut editor = EditorView::single_line(
SingleLineEditorOptions {
text: TextOptions::ui_text(Some(appearance.ui_font_size()), appearance),
propagate_and_no_op_vertical_navigation_keys:
PropagateAndNoOpNavigationKeys::Always,
select_all_on_focus: true,
..Default::default()
},
ctx_editor,
);
editor.set_placeholder_text(EDITOR_PLACEHOLDER, ctx_editor);
editor
});
ctx.subscribe_to_view(&editor, |me, _, event, ctx| {
me.handle_editor_event(event, ctx);
});
let mut me = Self {
current_slug: ORCHESTRATION_WARP_WORKER_HOST.to_string(),
default_host: None,
recent_host: None,
connected_hosts: Vec::new(),
dropdown,
editor,
clear_mouse_state: MouseStateHandle::default(),
is_custom_mode: false,
slug_before_edit: None,
};
me.repopulate_menu(ctx);
me.sync_dropdown_selection(ctx);
me
}
// ── Public API ──────────────────────────────────────────────────
/// Replaces the default, connected, and recent menu rows. Pass `None` to omit
/// default/recent rows.
pub fn set_options(
&mut self,
default_host: Option<String>,
recent_host: Option<String>,
connected_hosts: Vec<String>,
ctx: &mut ViewContext<Self>,
) {
self.default_host = default_host.filter(|s| !s.trim().is_empty());
self.recent_host = recent_host.filter(|s| !s.trim().is_empty());
self.connected_hosts = connected_hosts
.into_iter()
.filter(|s| !s.trim().is_empty())
.collect();
self.connected_hosts.sort();
self.connected_hosts.dedup();
self.repopulate_menu(ctx);
self.sync_dropdown_selection(ctx);
ctx.notify();
}
/// Pass `true` to paint the open menu in the overlay layer (avoids
/// being visually covered by sibling pickers below the host picker).
pub fn set_use_overlay_layer(&mut self, use_overlay_layer: bool, ctx: &mut ViewContext<Self>) {
self.dropdown.update(ctx, |dropdown, ctx_dropdown| {
dropdown.set_use_overlay_layer(use_overlay_layer, ctx_dropdown);
});
}
/// Anchors the open menu (e.g. flip upward to avoid covering siblings).
pub fn set_menu_position(
&mut self,
element_anchor: PositionedElementAnchor,
child_anchor: ChildAnchor,
ctx: &mut ViewContext<Self>,
) {
self.dropdown.update(ctx, |dropdown, ctx_dropdown| {
dropdown.set_menu_position(element_anchor, child_anchor, ctx_dropdown);
});
}
/// Sets the displayed slug. Unknown slugs switch the picker into custom
/// mode pre-filled with the slug. Empty input falls back to `"warp"`.
pub fn set_selected(&mut self, slug: &str, ctx: &mut ViewContext<Self>) {
let effective = normalize_slug(slug);
let is_known = self.is_known_option(&effective);
self.current_slug = effective.clone();
if is_known {
self.is_custom_mode = false;
self.sync_dropdown_selection(ctx);
} else {
self.enter_custom_mode_with_slug(&effective, ctx);
}
ctx.notify();
}
// ── Internals ───────────────────────────────────────────────────
fn is_known_option(&self, slug: &str) -> bool {
if slug.eq_ignore_ascii_case(ORCHESTRATION_WARP_WORKER_HOST) {
return true;
}
if self.default_host.as_deref() == Some(slug) {
return true;
}
if self.recent_host.as_deref() == Some(slug) {
return true;
}
if self.connected_hosts.iter().any(|host| host == slug) {
return true;
}
false
}
fn repopulate_menu(&mut self, ctx: &mut ViewContext<Self>) {
let items = build_menu_items(
self.default_host.as_deref(),
self.recent_host.as_deref(),
&self.connected_hosts,
);
self.dropdown.update(ctx, |dropdown, ctx_dropdown| {
dropdown.set_rich_items(items, ctx_dropdown);
});
}
fn sync_dropdown_selection(&mut self, ctx: &mut ViewContext<Self>) {
let action = InternalAction::SelectKnown(self.current_slug.clone());
self.dropdown.update(ctx, |dropdown, ctx_dropdown| {
dropdown.set_selected_by_action(action, ctx_dropdown);
});
}
fn enter_custom_mode_with_slug(&mut self, slug: &str, ctx: &mut ViewContext<Self>) {
self.is_custom_mode = true;
self.slug_before_edit = Some(self.current_slug.clone());
let initial = if slug.eq_ignore_ascii_case(ORCHESTRATION_WARP_WORKER_HOST) {
String::new()
} else {
slug.to_string()
};
self.editor.update(ctx, |editor, editor_ctx| {
editor.set_buffer_text_ignoring_undo(&initial, editor_ctx);
});
ctx.focus(&self.editor);
}
/// Commits the editor contents. Empty input reverts to the previous slug.
fn commit_custom(&mut self, ctx: &mut ViewContext<Self>) {
// Trim manually here — we must distinguish empty input (revert) from
// a literal "warp" entry (commit). `normalize_slug` collapses both
// into `"warp"`, so it's not safe to use on the commit path.
let raw = self.editor.as_ref(ctx).buffer_text(ctx).trim().to_string();
if raw.is_empty() {
self.cancel_custom(ctx);
return;
}
if raw.eq_ignore_ascii_case(ORCHESTRATION_WARP_WORKER_HOST) {
// Treat "warp" typed in custom mode as a normal warp selection.
self.current_slug = ORCHESTRATION_WARP_WORKER_HOST.to_string();
self.is_custom_mode = false;
self.slug_before_edit = None;
self.sync_dropdown_selection(ctx);
ctx.emit(HostPickerEvent::HostChanged {
slug: self.current_slug.clone(),
});
ctx.emit(HostPickerEvent::Closed);
ctx.notify();
return;
}
self.current_slug = raw.clone();
self.is_custom_mode = false;
self.slug_before_edit = None;
// Promote an unknown slug to the "recent" row so it stays visible
// in the list on the next paint.
if !self.is_known_option(&raw) {
self.recent_host = Some(raw.clone());
self.repopulate_menu(ctx);
}
self.sync_dropdown_selection(ctx);
ctx.emit(HostPickerEvent::HostChanged { slug: raw });
ctx.emit(HostPickerEvent::Closed);
ctx.notify();
}
fn cancel_custom(&mut self, ctx: &mut ViewContext<Self>) {
if !self.is_custom_mode {
return;
}
if let Some(prev) = self.slug_before_edit.take() {
self.current_slug = prev;
}
self.is_custom_mode = false;
self.sync_dropdown_selection(ctx);
ctx.emit(HostPickerEvent::Closed);
ctx.notify();
}
fn handle_editor_event(&mut self, event: &EditorEvent, ctx: &mut ViewContext<Self>) {
match event {
EditorEvent::Enter => self.commit_custom(ctx),
EditorEvent::Escape => self.cancel_custom(ctx),
EditorEvent::Blurred => {
if self.is_custom_mode {
self.commit_custom(ctx);
}
}
_ => {}
}
}
fn render_custom_mode(&self, appearance: &Appearance) -> Box<dyn Element> {
let theme = appearance.theme();
let background: Fill = theme.surface_overlay_1();
let border_color = theme.outline();
// Center the editor vertically — without this, the row's tight
// cross-axis constraint stretches it to fill the content height and
// the glyphs render flush to the top.
let centered_editor = Flex::column()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::Center)
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(ChildView::new(&self.editor).finish())
.finish();
let cancel_button = self.render_cancel_button(appearance);
let row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_main_axis_size(MainAxisSize::Max)
.with_child(Expanded::new(1.0, centered_editor).finish())
.with_child(cancel_button)
.finish();
// Mirror the dropdown's outer vertical margin so custom mode
// lines up with the other pickers instead of riding higher.
Container::new(
ConstrainedBox::new(
Container::new(row)
.with_horizontal_padding(12.)
.with_vertical_padding(6.)
.with_background(background)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
ORCHESTRATION_PICKER_RADIUS,
)))
.with_border(
Border::all(ORCHESTRATION_PICKER_BORDER_WIDTH)
.with_border_fill(border_color),
)
.finish(),
)
.with_height(ORCHESTRATION_PICKER_HEIGHT)
.finish(),
)
.with_margin_top(DROPDOWN_PADDING)
.with_margin_bottom(DROPDOWN_PADDING)
.finish()
}
fn render_cancel_button(&self, appearance: &Appearance) -> Box<dyn Element> {
let theme = appearance.theme();
let icon_fill = Fill::Solid(blended_colors::text_disabled(theme, theme.surface_1()));
let mouse_state = self.clear_mouse_state.clone();
Hoverable::new(mouse_state, move |_| {
ConstrainedBox::new(
Container::new(Icon::X.to_warpui_icon(icon_fill).finish())
.with_uniform_padding(2.)
.finish(),
)
.with_width(16.)
.with_height(16.)
.finish()
})
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(InternalAction::CancelCustom);
})
.with_cursor(Cursor::PointingHand)
.finish()
}
}
// ── Pure helpers (also exercised by unit tests) ─────────────────────
/// Trims `slug` and falls back to `"warp"` when empty.
fn normalize_slug(slug: &str) -> String {
let trimmed = slug.trim();
if trimmed.is_empty() {
ORCHESTRATION_WARP_WORKER_HOST.to_string()
} else {
trimmed.to_string()
}
}
/// Builds the menu items shown in list mode, in the order: workspace default
/// (badged "Default" if set), warp, connected worker hosts (badged
/// "Connected"), recent custom slug (badged "Disconnected" when it is not
/// currently connected), then a "Custom host…" entry.
pub(crate) fn build_menu_items(
default_host: Option<&str>,
recent_host: Option<&str>,
connected_hosts: &[String],
) -> Vec<MenuItem<DropdownAction>> {
let mut items: Vec<MenuItem<DropdownAction>> = Vec::new();
let mut known_slugs: Vec<String> = Vec::new();
if let Some(slug) = default_host {
items.push(menu_item_for_known(
slug,
Some(DEFAULT_BADGE),
InternalAction::SelectKnown(slug.to_string()),
));
known_slugs.push(slug.to_string());
}
items.push(menu_item_for_known(
ORCHESTRATION_WARP_WORKER_HOST,
None,
InternalAction::SelectKnown(ORCHESTRATION_WARP_WORKER_HOST.to_string()),
));
known_slugs.push(ORCHESTRATION_WARP_WORKER_HOST.to_string());
for slug in connected_hosts {
if slug.trim().is_empty()
|| known_slugs
.iter()
.any(|known| known.eq_ignore_ascii_case(slug))
{
continue;
}
items.push(menu_item_for_known(
slug,
Some(CONNECTED_BADGE),
InternalAction::SelectKnown(slug.to_string()),
));
known_slugs.push(slug.to_string());
}
if let Some(slug) = recent_host {
if !known_slugs
.iter()
.any(|known| known.eq_ignore_ascii_case(slug))
{
// Recent hosts render as plain slugs; only the workspace
// default carries a default badge. If the recent host is not in
// the connected set, keep it selectable but make the disconnected
// state explicit.
items.push(menu_item_for_known(
slug,
Some(DISCONNECTED_BADGE),
InternalAction::SelectKnown(slug.to_string()),
));
}
}
items.push(MenuItem::Item(
MenuItemFields::new(CUSTOM_HOST_LABEL).with_on_select_action(
DropdownAction::select_action_and_close(InternalAction::EnterCustomMode),
),
));
items
}
/// Returns the menu label corresponding to `slug`, including the "Default"
/// badge when it matches the workspace default.
#[cfg(test)]
pub(crate) fn menu_label_for(slug: &str, default_host: Option<&str>) -> String {
if default_host == Some(slug) {
format_known_label(slug, Some(DEFAULT_BADGE))
} else {
format_known_label(slug, None)
}
}
#[cfg(test)]
fn format_known_label(slug: &str, badge: Option<&str>) -> String {
match badge {
Some(badge) => format!("{slug} ({badge})"),
None => slug.to_string(),
}
}
fn menu_item_for_known(
slug: &str,
badge: Option<&str>,
action: InternalAction,
) -> MenuItem<DropdownAction> {
let mut fields = MenuItemFields::new(slug)
.with_on_select_action(DropdownAction::select_action_and_close(action));
if let Some(badge) = badge {
fields = fields.with_right_side_label(
badge,
Properties {
weight: Weight::Semibold,
..Default::default()
},
);
}
MenuItem::Item(fields)
}
// ── Entity / View impls ─────────────────────────────────────────────
impl Entity for HostPicker {
type Event = HostPickerEvent;
}
impl TypedActionView for HostPicker {
type Action = InternalAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
InternalAction::SelectKnown(slug) => {
let slug = slug.clone();
self.current_slug = slug.clone();
self.is_custom_mode = false;
self.slug_before_edit = None;
// The inner dropdown already updated its own selection;
// re-entering it here would trigger a circular update.
ctx.emit(HostPickerEvent::HostChanged { slug });
ctx.notify();
}
InternalAction::EnterCustomMode => {
let current = self.current_slug.clone();
self.enter_custom_mode_with_slug(&current, ctx);
ctx.notify();
}
InternalAction::CancelCustom => {
self.cancel_custom(ctx);
}
}
}
}
impl View for HostPicker {
fn ui_name() -> &'static str {
"HostPicker"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
if self.is_custom_mode {
self.render_custom_mode(appearance)
} else {
ChildView::new(&self.dropdown).finish()
}
}
}
#[cfg(test)]
#[path = "host_picker_tests.rs"]
mod tests;
@@ -0,0 +1,149 @@
//! Unit tests for the pure helpers in `host_picker.rs`. View-driven
//! behaviors (custom-mode commit, blur, etc.) are covered by manual smoke
//! testing.
use super::{
build_menu_items, menu_label_for, normalize_slug, DropdownAction, InternalAction, MenuItem,
ORCHESTRATION_WARP_WORKER_HOST,
};
/// Extracts the visible label text out of a `MenuItem::Item`, panicking
/// on the unreachable `Header` / `Separator` cases that our builder
/// doesn't emit.
fn item_label(item: &MenuItem<DropdownAction>) -> &str {
match item {
MenuItem::Item(fields) => fields.label(),
other => panic!("expected MenuItem::Item, got {other:?}"),
}
}
/// Extracts the on-select action from a `MenuItem::Item`.
fn item_action(item: &MenuItem<DropdownAction>) -> &DropdownAction {
match item {
MenuItem::Item(fields) => fields
.on_select_action()
.expect("test items always have a select action"),
other => panic!("expected MenuItem::Item, got {other:?}"),
}
}
#[test]
fn build_menu_items_with_no_defaults_shows_warp_and_custom() {
let items = build_menu_items(None, None, &[]);
assert_eq!(items.len(), 2, "expected warp + custom-host entries");
assert_eq!(item_label(&items[0]), ORCHESTRATION_WARP_WORKER_HOST);
assert_eq!(item_label(&items[1]), "Custom host\u{2026}");
}
#[test]
fn build_menu_items_promotes_default_to_top() {
// Workspace default sits above warp and gets the "Default" badge,
// matching the Oz webapp's HostSelector layout.
let items = build_menu_items(Some("my-corp"), None, &[]);
assert_eq!(items.len(), 3);
assert_eq!(item_label(&items[0]), "my-corp");
assert_eq!(item_label(&items[1]), ORCHESTRATION_WARP_WORKER_HOST);
assert_eq!(item_label(&items[2]), "Custom host\u{2026}");
}
#[test]
fn build_menu_items_adds_recent_after_warp() {
let items = build_menu_items(None, Some("other-host"), &[]);
assert_eq!(items.len(), 3);
assert_eq!(item_label(&items[0]), ORCHESTRATION_WARP_WORKER_HOST);
// Recent hosts render as plain slugs (no "(Recent)" suffix).
assert_eq!(item_label(&items[1]), "other-host");
assert_eq!(item_label(&items[2]), "Custom host\u{2026}");
}
#[test]
fn build_menu_items_dedups_recent_when_it_matches_default_or_warp() {
// Same as the workspace default → no duplicate "Recent" row.
let items = build_menu_items(Some("my-corp"), Some("my-corp"), &[]);
assert_eq!(items.len(), 3);
assert_eq!(item_label(&items[0]), "my-corp");
assert_eq!(item_label(&items[1]), ORCHESTRATION_WARP_WORKER_HOST);
assert_eq!(item_label(&items[2]), "Custom host\u{2026}");
// Recent == "warp" is also skipped (warp is already a row).
let items = build_menu_items(Some("my-corp"), Some("warp"), &[]);
assert_eq!(items.len(), 3, "warp recent should not double-add");
}
#[test]
fn build_menu_items_adds_connected_hosts_before_recent_and_dedups_known_hosts() {
let connected_hosts = vec![
"alpha".to_string(),
"warp".to_string(),
"my-corp".to_string(),
"alpha".to_string(),
"beta".to_string(),
];
let items = build_menu_items(Some("my-corp"), Some("beta"), &connected_hosts);
assert_eq!(items.len(), 5);
assert_eq!(item_label(&items[0]), "my-corp");
assert_eq!(item_label(&items[1]), ORCHESTRATION_WARP_WORKER_HOST);
assert_eq!(item_label(&items[2]), "alpha");
assert_eq!(item_label(&items[3]), "beta");
assert_eq!(item_label(&items[4]), "Custom host\u{2026}");
}
#[test]
fn build_menu_items_warp_entry_dispatches_select_known_warp() {
let items = build_menu_items(None, None, &[]);
match item_action(&items[0]) {
DropdownAction::SelectActionAndClose(action) => {
let action = action
.as_any()
.downcast_ref::<InternalAction>()
.expect("expected InternalAction");
match action {
InternalAction::SelectKnown(slug) => {
assert_eq!(slug, ORCHESTRATION_WARP_WORKER_HOST);
}
other => panic!("expected SelectKnown, got {other:?}"),
}
}
other => panic!("expected SelectActionAndClose(SelectKnown), got {other:?}"),
}
}
#[test]
fn build_menu_items_custom_entry_dispatches_enter_custom_mode() {
let items = build_menu_items(None, None, &[]);
let custom = items.last().expect("custom entry is always last");
match item_action(custom) {
DropdownAction::SelectActionAndClose(action) => {
assert_eq!(
action.as_any().downcast_ref::<InternalAction>(),
Some(&InternalAction::EnterCustomMode)
);
}
other => panic!("expected EnterCustomMode, got {other:?}"),
}
}
#[test]
fn menu_label_for_picks_default_badge_when_slug_matches_default() {
let label = menu_label_for("my-corp", Some("my-corp"));
assert_eq!(label, "my-corp (Default)");
}
#[test]
fn menu_label_for_returns_plain_slug_for_warp() {
let label = menu_label_for(ORCHESTRATION_WARP_WORKER_HOST, Some("my-corp"));
assert_eq!(label, ORCHESTRATION_WARP_WORKER_HOST);
}
#[test]
fn menu_label_for_returns_plain_slug_for_unknown_value() {
// A slug typed via custom mode that we haven't promoted to "recent" yet.
let label = menu_label_for("typed-once", Some("my-corp"));
assert_eq!(label, "typed-once");
}
#[test]
fn normalize_slug_trims_whitespace_and_falls_back_to_warp_when_empty() {
assert_eq!(normalize_slug(" my-corp "), "my-corp");
assert_eq!(normalize_slug(""), ORCHESTRATION_WARP_WORKER_HOST);
assert_eq!(normalize_slug(" "), ORCHESTRATION_WARP_WORKER_HOST);
}
@@ -1,26 +1,23 @@
use galaxy_core::ui::{appearance::Appearance, theme::color::internal_colors};
use galaxyui::{
elements::{
Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, DispatchEventResult,
EventHandler, Expanded, Flex, FormattedTextElement, Hoverable, MainAxisAlignment,
MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable, SizeConstraintCondition,
SizeConstraintSwitch, Text,
},
fonts::FamilyId,
platform::Cursor,
AppContext, Element, EventContext, SingletonEntity,
};
use pathfinder_color::ColorU;
use std::borrow::Cow;
use std::rc::Rc;
use crate::{
ai::blocklist::inline_action::inline_action_icons::icon_size,
ui_components::blended_colors,
view_components::compactible_action_button::{
render_compact_and_regular_button_rows, render_expansion_icon,
RenderCompactibleActionButton,
},
use pathfinder_color::ColorU;
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::theme::color::internal_colors;
use warpui::elements::{
Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, DispatchEventResult,
EventHandler, Expanded, Flex, FormattedTextElement, Hoverable, MainAxisAlignment, MainAxisSize,
MouseStateHandle, ParentElement, Radius, Shrinkable, SizeConstraintCondition,
SizeConstraintSwitch, Text,
};
use warpui::fonts::FamilyId;
use warpui::platform::Cursor;
use warpui::{AppContext, Element, EventContext, SingletonEntity};
use crate::ai::blocklist::inline_action::inline_action_icons::icon_size;
use crate::ui_components::blended_colors;
use crate::view_components::compactible_action_button::{
render_compact_and_regular_button_rows, render_expansion_icon, RenderCompactibleActionButton,
};
/// Same padding constants as the original for consistency
@@ -1,9 +1,10 @@
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::theme::AnsiColorIdentifier;
use galaxyui::{AppContext, SingletonEntity};
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
/// Returns the size for icons in the AI block, scaled to the user's current font size.
pub fn icon_size(app: &AppContext) -> f32 {
let appearance = Appearance::as_ref(app);
@@ -27,6 +28,15 @@ pub fn red_x_icon(appearance: &Appearance) -> galaxyui::elements::Icon {
)
}
/// Yellow warning triangle for terminal states that only partially succeeded
/// (e.g. some child agents launched and others failed).
pub fn warning_icon(appearance: &Appearance) -> galaxyui::elements::Icon {
galaxyui::elements::Icon::new(
Icon::Triangle.into(),
AnsiColorIdentifier::Yellow.to_ansi_color(&appearance.theme().terminal_colors().normal),
)
}
pub fn cancelled_icon(appearance: &Appearance) -> galaxyui::elements::Icon {
galaxyui::elements::Icon::new(
Icon::Cancelled.into(),
@@ -191,5 +191,5 @@ fn range_intersects_or_contains_point(
}
#[cfg(test)]
#[path = "malformed_line_heuristics_test.rs"]
#[path = "malformed_line_heuristics_tests.rs"]
mod tests;
@@ -1,10 +1,11 @@
use std::slice;
use ai::diff_validation::{DiffDelta, DiffType};
use super::{
changed_lines_intersect_terminal_range, has_malformed_terminal_correction_signal,
proposed_terminal_line_range,
};
use ai::diff_validation::{DiffDelta, DiffType};
#[test]
fn terminal_range_accounts_for_prior_insertions() {
+4 -1
View File
@@ -1,14 +1,17 @@
pub(crate) mod ask_user_question_view;
pub(super) mod aws_bedrock_credentials_error;
pub(crate) mod code_diff_view;
pub(crate) mod create_environment_modal;
pub(crate) mod create_or_edit_document;
pub(crate) mod host_picker;
pub(crate) mod inline_action_header;
pub(crate) mod inline_action_icons;
mod malformed_line_heuristics;
pub(crate) mod orchestration_controls;
pub(crate) mod requested_action;
pub(crate) mod requested_command;
pub(crate) mod requested_command_attribution;
pub(crate) mod requested_script;
pub(crate) mod run_agents_card_view;
pub(super) mod search_codebase;
pub(crate) mod search_results_common;
pub(crate) mod suggested_unit_tests;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,200 @@
use ai::agent::action::RunAgentsExecutionMode;
use ai::agent::orchestration_config::{OrchestrationConfig, OrchestrationExecutionMode};
use super::{
should_show_auth_secret_picker, should_show_harness_picker, AuthSecretSelection,
OrchestrationEditState,
};
fn remote_claude_state() -> OrchestrationEditState {
OrchestrationEditState::from_run_agents_fields(
"sonnet",
"claude",
&RunAgentsExecutionMode::Remote {
environment_id: "env-1".to_string(),
worker_host: "warp".to_string(),
computer_use_enabled: false,
},
)
}
fn local_config(harness_type: &str, model_id: &str) -> OrchestrationConfig {
OrchestrationConfig {
model_id: model_id.to_string(),
harness_type: harness_type.to_string(),
execution_mode: OrchestrationExecutionMode::Local,
}
}
#[test]
fn from_orchestration_config_preserves_local_claude() {
let state =
OrchestrationEditState::from_orchestration_config(&local_config("claude", "sonnet"));
assert_eq!(state.harness_type, "claude");
assert_eq!(state.model_id, "sonnet");
assert!(matches!(
state.execution_mode,
RunAgentsExecutionMode::Local
));
}
#[test]
fn harness_picker_stays_visible_for_local_mode() {
let state = OrchestrationEditState::from_run_agents_fields(
"auto",
"oz",
&RunAgentsExecutionMode::Local,
);
assert!(should_show_harness_picker(&state));
}
#[test]
fn harness_picker_stays_visible_for_remote_mode() {
let state = OrchestrationEditState::from_run_agents_fields(
"auto",
"oz",
&RunAgentsExecutionMode::Remote {
environment_id: "env-1".to_string(),
worker_host: "warp".to_string(),
computer_use_enabled: false,
},
);
assert!(should_show_harness_picker(&state));
}
#[test]
fn from_orchestration_config_preserves_remote_claude() {
let state = OrchestrationEditState::from_orchestration_config(&OrchestrationConfig {
model_id: "sonnet".to_string(),
harness_type: "claude".to_string(),
execution_mode: OrchestrationExecutionMode::Remote {
environment_id: "env-1".to_string(),
worker_host: "warp".to_string(),
},
});
assert_eq!(state.harness_type, "claude");
assert_eq!(state.model_id, "sonnet");
assert!(matches!(
state.execution_mode,
RunAgentsExecutionMode::Remote {
ref environment_id,
ref worker_host,
computer_use_enabled: false,
} if environment_id == "env-1" && worker_host == "warp"
));
}
#[test]
fn toggle_to_local_sanitizes_disabled_codex() {
let mut state = OrchestrationEditState::from_run_agents_fields(
"gpt-5",
"codex",
&RunAgentsExecutionMode::Remote {
environment_id: "env-1".to_string(),
worker_host: "warp".to_string(),
computer_use_enabled: false,
},
);
state.toggle_execution_mode_to_remote(false);
assert_eq!(state.harness_type, "oz");
assert_eq!(state.model_id, "");
assert!(matches!(
state.execution_mode,
RunAgentsExecutionMode::Local
));
}
#[test]
fn toggle_to_local_preserves_claude() {
let mut state = OrchestrationEditState::from_run_agents_fields(
"sonnet",
"claude",
&RunAgentsExecutionMode::Remote {
environment_id: "env-1".to_string(),
worker_host: "warp".to_string(),
computer_use_enabled: false,
},
);
state.toggle_execution_mode_to_remote(false);
assert_eq!(state.harness_type, "claude");
assert_eq!(state.model_id, "sonnet");
assert!(matches!(
state.execution_mode,
RunAgentsExecutionMode::Local
));
}
#[test]
fn accept_disabled_reason_allows_local_claude_product() {
let state = OrchestrationEditState::from_run_agents_fields(
"auto",
"claude",
&RunAgentsExecutionMode::Local,
);
assert_eq!(state.accept_disabled_reason(), None);
}
#[test]
fn resolve_from_config_preserves_local_claude() {
let mut state =
OrchestrationEditState::from_run_agents_fields("", "", &RunAgentsExecutionMode::Local);
state.resolve_from_config(&local_config("claude", "sonnet"));
assert_eq!(state.harness_type, "claude");
assert_eq!(state.model_id, "sonnet");
assert!(matches!(
state.execution_mode,
RunAgentsExecutionMode::Local
));
}
#[test]
fn resolve_from_config_sanitizes_disabled_local_codex() {
let mut state =
OrchestrationEditState::from_run_agents_fields("", "", &RunAgentsExecutionMode::Local);
state.resolve_from_config(&local_config("codex", "gpt-5"));
assert_eq!(state.harness_type, "oz");
assert_eq!(state.model_id, "");
assert!(matches!(
state.execution_mode,
RunAgentsExecutionMode::Local
));
}
#[test]
fn select_create_new_auth_secret_marks_creating_new_from_named() {
let mut state = remote_claude_state();
state.auth_secret_selection = AuthSecretSelection::Named("my-key".to_string());
assert_eq!(state.auth_secret_name(), Some("my-key"));
state.select_create_new_auth_secret();
// `CreatingNew` (distinct from `Unset`) blocks Accept and isn't re-seeded.
assert!(matches!(
state.auth_secret_selection,
AuthSecretSelection::CreatingNew
));
assert_eq!(state.auth_secret_name(), None);
assert!(should_show_auth_secret_picker(&state));
}
#[test]
fn select_create_new_auth_secret_marks_creating_new_from_inherit() {
let mut state = remote_claude_state();
state.auth_secret_selection = AuthSecretSelection::Inherit;
state.select_create_new_auth_secret();
assert!(matches!(
state.auth_secret_selection,
AuthSecretSelection::CreatingNew
));
}
@@ -9,46 +9,31 @@
//! into [`View`] components as well. If that's ever deemed necessary, see [`RequestedCommandView`]
//! for an example on how that transformation could be made.
use std::borrow::Cow;
use lazy_static::lazy_static;
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
use pathfinder_color::ColorU;
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::theme::color::internal_colors::neutral_2;
use galaxyui::elements::Align;
use galaxyui::elements::Clipped;
use galaxyui::elements::FormattedTextElement;
use galaxyui::fonts::FamilyId;
use galaxyui::{
elements::{
Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty, Flex,
Hoverable, MainAxisAlignment, MouseStateHandle, ParentElement, Radius, Shrinkable,
SizeConstraintCondition, SizeConstraintSwitch, Text, Wrap, WrapFill,
},
keymap::Keystroke,
platform::Cursor,
ui_components::components::{UiComponent, UiComponentStyles},
AppContext, Element, EventContext, SingletonEntity,
use galaxyui::elements::{
Align, Border, Clipped, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Flex,
FormattedTextElement, MainAxisAlignment, ParentElement, Radius, Shrinkable, Wrap, WrapFill,
};
use lazy_static::lazy_static;
use markdown_parser::FormattedText;
use markdown_parser::FormattedTextFragment;
use markdown_parser::FormattedTextLine;
use pathfinder_color::ColorU;
use std::borrow::Cow;
use std::rc::Rc;
use galaxyui::fonts::FamilyId;
use galaxyui::keymap::Keystroke;
use galaxyui::{AppContext, Element, SingletonEntity};
use super::inline_action_header::HeaderConfig;
use crate::ai::blocklist::block::view_impl::WithContentItemSpacing;
use crate::ai::blocklist::inline_action::inline_action_header;
use crate::ai::blocklist::inline_action::inline_action_header::INLINE_ACTION_VERTICAL_PADDING;
use crate::ai::blocklist::inline_action::inline_action_header::{
INLINE_ACTION_HEADER_VERTICAL_PADDING, INLINE_ACTION_HORIZONTAL_PADDING,
INLINE_ACTION_VERTICAL_PADDING,
};
use crate::ai::blocklist::inline_action::inline_action_icons::icon_size;
use crate::ui_components::blended_colors;
const REQUESTED_ACTION_CANCEL_LABEL: &str = "Cancel";
const REQUESTED_ACTION_RUN_LABEL: &str = "Run";
const KEYBOARD_SHORTCUT_MARGIN_RIGHT: f32 = 8.;
lazy_static! {
pub static ref ENTER_KEYSTROKE: Keystroke = Keystroke {
key: "enter".to_owned(),
@@ -232,126 +217,6 @@ impl RenderableAction {
}
}
/// Create the buttons representing Run and Cancel on the requested action.
/// Note that keyboard events aren't automatically propagated to the buttons.
#[allow(clippy::too_many_arguments)]
pub(super) fn render_header_buttons(
on_accept: impl Fn(&mut EventContext) + 'static,
on_cancel: impl Fn(&mut EventContext) + 'static,
run_keystroke: &Keystroke,
cancel_keystroke: &Keystroke,
run_button: &MouseStateHandle,
cancel_button: &MouseStateHandle,
should_show_accept_button: bool,
app: &AppContext,
) -> Box<dyn Element> {
const BUTTON_MARGIN_RIGHT: f32 = 16.;
let appearance = Appearance::as_ref(app);
let width_required_for_full_size_layout = approx_keystroke_button_width(
REQUESTED_ACTION_CANCEL_LABEL,
appearance.monospace_font_size(),
cancel_keystroke,
None,
app,
) + BUTTON_MARGIN_RIGHT
+ approx_keystroke_button_width(
REQUESTED_ACTION_RUN_LABEL,
appearance.monospace_font_size(),
run_keystroke,
None,
app,
)
+ INLINE_ACTION_HORIZONTAL_PADDING;
let compact_button_font_size = (appearance.monospace_font_size() - 2.).max(4.);
let compact_button_styles = UiComponentStyles {
font_size: Some(compact_button_font_size),
..Default::default()
};
let width_required_for_compact_layout = approx_keystroke_button_width(
REQUESTED_ACTION_CANCEL_LABEL,
compact_button_font_size,
cancel_keystroke,
Some(compact_button_styles),
app,
)
.max(approx_keystroke_button_width(
REQUESTED_ACTION_RUN_LABEL,
compact_button_font_size,
run_keystroke,
Some(compact_button_styles),
app,
));
let cancel_callback = Rc::new(on_cancel);
let cancel_clone = Rc::clone(&cancel_callback);
let accept_callback = Rc::new(on_accept);
let accept_clone = Rc::clone(&accept_callback);
let mut default_row = Flex::row().with_child(
Container::new(render_keyboard_shortcut_button(
REQUESTED_ACTION_CANCEL_LABEL,
Some(cancel_keystroke.clone()),
cancel_button.clone(),
cancel_callback,
None,
app,
))
.with_margin_right(BUTTON_MARGIN_RIGHT)
.finish(),
);
let mut size_constrained_column = Flex::column().with_child(render_keyboard_shortcut_button(
REQUESTED_ACTION_CANCEL_LABEL,
Some(cancel_keystroke.clone()),
cancel_button.clone(),
cancel_clone,
Some(compact_button_styles),
app,
));
if should_show_accept_button {
default_row.add_child(render_keyboard_shortcut_button(
REQUESTED_ACTION_RUN_LABEL,
Some(run_keystroke.clone()),
run_button.clone(),
accept_callback,
None,
app,
));
size_constrained_column.add_child(
Container::new(render_keyboard_shortcut_button(
REQUESTED_ACTION_RUN_LABEL,
Some(run_keystroke.clone()),
run_button.clone(),
accept_clone,
Some(compact_button_styles),
app,
))
.with_margin_top(8.)
.finish(),
);
}
SizeConstraintSwitch::new(
default_row.finish(),
vec![
(
SizeConstraintCondition::WidthLessThan(width_required_for_compact_layout),
Empty::new().finish(),
),
(
SizeConstraintCondition::WidthLessThan(width_required_for_full_size_layout),
size_constrained_column.finish(),
),
],
)
.finish()
}
pub fn render_requested_action_body_text(
text: Cow<str>,
font_family: FamilyId,
@@ -488,121 +353,3 @@ fn render_requested_action_row_for_element(
})
.finish()
}
pub fn render_keyboard_shortcut_button(
label: &'static str,
keystroke: Option<Keystroke>,
mouse_state: MouseStateHandle,
on_click: Rc<impl Fn(&mut EventContext) + 'static>,
style_overrides: Option<UiComponentStyles>,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
Hoverable::new(mouse_state, |mouse_state| {
let text_color = if mouse_state.is_hovered() {
blended_colors::accent(theme).into_solid()
} else {
blended_colors::text_main(theme, theme.surface_1())
};
let mut shortcut_styles = UiComponentStyles {
font_color: Some(text_color),
font_size: Some(appearance.monospace_font_size()),
background: Some(neutral_2(theme).into()),
..Default::default()
};
if let Some(style_overrides) = style_overrides {
shortcut_styles = shortcut_styles.merge(style_overrides)
}
let mut row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
if let Some(keystroke) = keystroke {
row.add_child(
Container::new(
appearance
.ui_builder()
.keyboard_shortcut(&keystroke)
.with_style(shortcut_styles)
.build()
.finish(),
)
.with_margin_right(KEYBOARD_SHORTCUT_MARGIN_RIGHT)
.finish(),
);
}
row.with_child(
Text::new_inline(
label,
appearance.ui_font_family(),
shortcut_styles
.font_size
.unwrap_or(appearance.monospace_font_size()),
)
.with_color(text_color)
.with_selectable(false)
.finish(),
)
.finish()
})
.on_click(move |ctx, _, _| on_click(ctx))
.with_cursor(Cursor::PointingHand)
.finish()
}
fn approx_keystroke_button_width(
label: &str,
label_font_size: f32,
keystroke: &Keystroke,
keystroke_style_overrides: Option<UiComponentStyles>,
app: &AppContext,
) -> f32 {
let appearance = Appearance::as_ref(app);
let approx_text_width = (label.len() as f32)
* app
.font_cache()
.em_width(appearance.ui_font_family(), label_font_size);
let keystroke_len_ems = {
let mut len = 0;
if keystroke.ctrl {
len += 2
}
if keystroke.alt {
len += 2
}
if keystroke.shift {
len += 2
}
if keystroke.cmd {
len += 2
}
if keystroke.meta {
len += 2
}
len += keystroke.key.len();
len
};
let mut keystroke_styles = appearance.ui_builder().default_keyboard_shortcut_styles();
if let Some(style_overrides) = keystroke_style_overrides {
keystroke_styles = keystroke_styles.merge(style_overrides);
}
let approx_keystroke_width = (keystroke_len_ems as f32)
* app.font_cache().em_width(
appearance.ui_font_family(),
keystroke_styles
.font_size
.unwrap_or(appearance.ui_font_size()),
)
+ keystroke_styles
.padding
.map(|padding| padding.right + padding.left)
.unwrap_or(0.)
+ keystroke_styles
.margin
.map(|margin| margin.right + margin.left)
.unwrap_or(0.);
approx_text_width + KEYBOARD_SHORTCUT_MARGIN_RIGHT + approx_keystroke_width
}
@@ -1,66 +1,63 @@
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::Icon;
use galaxy_editor::render::element::VerticalExpansionBehavior;
use galaxyui::elements::{ConstrainedBox, ScrollbarWidth};
use galaxyui::ui_components::components::UiComponent as _;
use galaxyui::{
elements::{
Align, Border, ChildView, Clipped, Container, CornerRadius, CrossAxisAlignment, Expanded,
Flex, MainAxisSize, MouseStateHandle, OffsetPositioning, ParentElement, Radius,
SelectableArea, SelectionHandle, Stack, Text,
},
keymap::{Context, EditableBinding, FixedBinding, Keystroke},
AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, UpdateView, View,
ViewContext, ViewHandle,
};
use lazy_static::lazy_static;
use parking_lot::FairMutex;
use pathfinder_geometry::vector::vec2f;
use settings::Setting as _;
use std::borrow::Cow;
use std::cmp::{Ordering, PartialEq};
use std::collections::HashMap;
use std::rc::Rc;
use std::sync::Arc;
use crate::ai::agent::{AIAgentActionResult, AIAgentActionType};
use galaxyui::{EntityId, EventContext};
use lazy_static::lazy_static;
use parking_lot::FairMutex;
use pathfinder_geometry::vector::vec2f;
use settings::Setting as _;
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::Icon;
use galaxy_editor::render::element::VerticalExpansionBehavior;
use galaxyui::elements::{
Align, Border, ChildView, Clipped, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
Expanded, Flex, MainAxisSize, MouseStateHandle, OffsetPositioning, ParentElement, Radius,
ScrollbarWidth, SelectableArea, SelectionHandle, Stack, Text,
};
use galaxyui::keymap::{Context, EditableBinding, FixedBinding, Keystroke};
use galaxyui::ui_components::components::UiComponent as _;
use galaxyui::{
AppContext, Element, Entity, EntityId, EventContext, ModelHandle, SingletonEntity,
TypedActionView, UpdateView, View, ViewContext, ViewHandle,
};
use crate::ai::agent::RequestCommandOutputResult;
use crate::ai::agent::{icons, AIAgentActionResultType};
use super::inline_action_icons::{self, icon_size};
use crate::ai::agent::conversation::ConversationStatus;
use crate::ai::agent::{
icons, AIAgentActionId, AIAgentActionResult, AIAgentActionResultType, AIAgentActionType,
AIAgentCitation, AIAgentOutputMessageType, CallMCPToolResult, RequestCommandOutputResult,
};
use crate::ai::blocklist::action_model::AIActionStatus;
use crate::ai::blocklist::block::cli_controller::{
LongRunningCommandControlState, UserTakeOverReason,
};
use crate::ai::blocklist::block::view_impl::output::action_icon;
use crate::ai::blocklist::inline_action::inline_action_header::RightClickConfig;
use crate::ai::blocklist::block::view_impl::{
render_autonomy_checkbox_setting_speedbump_footer, render_citation, render_citation_chips,
CONTENT_HORIZONTAL_PADDING, CONTENT_ITEM_VERTICAL_MARGIN,
};
use crate::ai::blocklist::block::{AIBlockAction, AutonomySettingSpeedbump};
use crate::ai::blocklist::inline_action::inline_action_header::{
ExpandedConfig, HeaderConfig, InteractionMode, RightClickConfig,
INLINE_ACTION_HORIZONTAL_PADDING,
};
use crate::ai::blocklist::model::{AIBlockModel, AIBlockModelHelper};
use crate::ai::blocklist::{
AIBlock, BlocklistAIActionEvent, BlocklistAIHistoryModel, ClientIdentifiers,
};
use crate::ai::{
agent::{AIAgentActionId, AIAgentCitation, AIAgentOutputMessageType, CallMCPToolResult},
blocklist::{
action_model::AIActionStatus,
block::{
view_impl::{
render_autonomy_checkbox_setting_speedbump_footer, render_citation,
render_citation_chips, CONTENT_HORIZONTAL_PADDING, CONTENT_ITEM_VERTICAL_MARGIN,
},
AIBlockAction, AutonomySettingSpeedbump,
},
inline_action::{
inline_action_header::INLINE_ACTION_HORIZONTAL_PADDING,
inline_action_header::{ExpandedConfig, HeaderConfig, InteractionMode},
},
BlocklistAIActionModel,
},
AIBlock, BlocklistAIActionEvent, BlocklistAIActionModel, BlocklistAIHistoryModel,
ClientIdentifiers,
};
use crate::cmd_or_ctrl_shift;
use crate::code::editor::view::{CodeEditorEvent, CodeEditorRenderOptions, CodeEditorView};
use crate::editor::InteractionState;
use crate::menu::{Event as MenuEvent, Menu, MenuItemFields, MenuVariant};
use crate::settings::InputModeSettings;
use crate::terminal::block_list_viewport::InputMode;
use crate::terminal::model::block::Block;
use crate::terminal::TerminalModel;
use crate::ui_components::blended_colors;
use crate::ui_components::json_tree::{JsonTreeState, PathSegment};
use crate::util::bindings::keybinding_name_to_keystroke;
use crate::view_components::action_button::{ButtonSize, KeystrokeSource, NakedTheme};
use crate::view_components::compactible_action_button::{
@@ -68,9 +65,6 @@ use crate::view_components::compactible_action_button::{
MEDIUM_SIZE_SWITCH_THRESHOLD, SMALL_SIZE_SWITCH_THRESHOLD,
};
use crate::view_components::compactible_split_action_button::CompactibleSplitActionButton;
use crate::{cmd_or_ctrl_shift, settings::InputModeSettings, ui_components::blended_colors};
use super::inline_action_icons::{self, icon_size};
/// The vertical padding applied to the requested command row's content body.
/// For horizontal padding, use [`INLINE_ACTION_HORIZONTAL_PADDING`] for consistency.
@@ -96,6 +90,7 @@ const VIEWING_MCP_TOOL_DETAIL_MESSAGE: &str = "Viewing MCP tool call detail";
const EDIT_COMMAND_ACTION_NAME: &str = "requested_command:edit";
const EDIT_MODE_OPEN_KEYMAP_CONTEXT: &str = "RequestedCommandViewEditModeOpen";
const REQUESTED_ACTION_BLOCKED_KEYMAP_CONTEXT: &str = "RequestedActionBlocked";
const SCROLLBAR_WIDTH: ScrollbarWidth = ScrollbarWidth::Auto;
const MAX_EDITOR_HEIGHT: f32 = 500.0;
@@ -126,32 +121,42 @@ pub fn init(app: &mut AppContext) {
FixedBinding::new(
"ctrl-c",
RequestedCommandViewAction::Reject,
id!(RequestedCommandView::ui_name()),
id!(RequestedCommandView::ui_name()) & id!(REQUESTED_ACTION_BLOCKED_KEYMAP_CONTEXT),
),
FixedBinding::new(
"enter",
RequestedCommandViewAction::Accept,
id!(RequestedCommandView::ui_name()) & !id!(EDIT_MODE_OPEN_KEYMAP_CONTEXT),
id!(RequestedCommandView::ui_name())
& id!(REQUESTED_ACTION_BLOCKED_KEYMAP_CONTEXT)
& !id!(EDIT_MODE_OPEN_KEYMAP_CONTEXT),
),
FixedBinding::new(
"numpadenter",
RequestedCommandViewAction::Accept,
id!(RequestedCommandView::ui_name()) & !id!(EDIT_MODE_OPEN_KEYMAP_CONTEXT),
id!(RequestedCommandView::ui_name())
& id!(REQUESTED_ACTION_BLOCKED_KEYMAP_CONTEXT)
& !id!(EDIT_MODE_OPEN_KEYMAP_CONTEXT),
),
FixedBinding::new(
"cmdorctrl-enter",
RequestedCommandViewAction::Accept,
id!(RequestedCommandView::ui_name()) & id!(EDIT_MODE_OPEN_KEYMAP_CONTEXT),
id!(RequestedCommandView::ui_name())
& id!(REQUESTED_ACTION_BLOCKED_KEYMAP_CONTEXT)
& id!(EDIT_MODE_OPEN_KEYMAP_CONTEXT),
),
FixedBinding::new(
"escape",
RequestedCommandViewAction::CloseEditMode,
id!(RequestedCommandView::ui_name()) & id!(EDIT_MODE_OPEN_KEYMAP_CONTEXT),
id!(RequestedCommandView::ui_name())
& id!(REQUESTED_ACTION_BLOCKED_KEYMAP_CONTEXT)
& id!(EDIT_MODE_OPEN_KEYMAP_CONTEXT),
),
FixedBinding::new(
"tab",
RequestedCommandViewAction::FocusEditor,
id!(RequestedCommandView::ui_name()) & id!(EDIT_MODE_OPEN_KEYMAP_CONTEXT),
id!(RequestedCommandView::ui_name())
& id!(REQUESTED_ACTION_BLOCKED_KEYMAP_CONTEXT)
& id!(EDIT_MODE_OPEN_KEYMAP_CONTEXT),
),
]);
@@ -162,10 +167,77 @@ pub fn init(app: &mut AppContext) {
)
.with_key_binding(cmd_or_ctrl_shift("e"))
.with_context_predicate(
id!(RequestedCommandView::ui_name()) & !id!(EDIT_MODE_OPEN_KEYMAP_CONTEXT),
id!(RequestedCommandView::ui_name())
& id!(REQUESTED_ACTION_BLOCKED_KEYMAP_CONTEXT)
& !id!(EDIT_MODE_OPEN_KEYMAP_CONTEXT),
)]);
}
/// Structured representation of an MCP tool call request, held so the
/// detail view can render it as a JSON tree rather than a flat string.
// Fields are consumed by the MCP tree-rendering layer.
#[allow(dead_code)]
pub struct McpRequest {
pub name: String,
pub args: serde_json::Value,
}
/// The normalized, renderable form of a `CallMCPToolResult`.
///
/// Converts the raw result (which may carry text content, structured JSON, an
/// error message, or a cancellation signal) into a form the tree-rendering
/// layer can act on without further conditionals.
// Consumed by the MCP tree-rendering layer.
#[allow(dead_code)]
pub(crate) enum McpRenderable {
Tree(serde_json::Value),
Error(String),
Cancelled,
}
/// Normalizes a `CallMCPToolResult` into a `McpRenderable` for display.
///
/// Prefers `structured_content` when present; otherwise tries to parse joined
/// text content as JSON; falls back to wrapping the raw text as a JSON
/// string value so the tree renderer always receives a `serde_json::Value`.
// Called by the MCP tree-rendering layer.
#[allow(dead_code)]
pub(crate) fn mcp_result_to_renderable(result: &CallMCPToolResult) -> McpRenderable {
match result {
CallMCPToolResult::Success { result } => {
if let Some(v) = &result.structured_content {
return McpRenderable::Tree(v.clone());
}
let text = result
.content
.iter()
.filter_map(|c| {
if let rmcp::model::RawContent::Text(t) = &c.raw {
Some(t.text.as_str())
} else {
None
}
})
.collect::<Vec<_>>()
.join("\n");
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) {
McpRenderable::Tree(v)
} else {
McpRenderable::Tree(serde_json::Value::String(text))
}
}
CallMCPToolResult::Error(e) => McpRenderable::Error(e.clone()),
CallMCPToolResult::Cancelled => McpRenderable::Cancelled,
}
}
/// Identifies which of the two JSON trees (request or response) an action targets.
#[derive(Debug, Clone)]
pub enum McpTree {
Request,
Response,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RequestedActionViewType {
Command,
@@ -206,6 +278,18 @@ pub enum RequestedCommandViewAction {
ToggleExpanded,
OpenActiveAgentProfileEditor,
SelectText,
/// Toggle the expanded/collapsed state of an object or array node in the
/// MCP request or response JSON tree.
ToggleJsonNode {
path: Vec<PathSegment>,
tree: McpTree,
},
/// Toggle the expanded/collapsed state of a long string value in the MCP
/// request or response JSON tree.
ToggleJsonString {
path: Vec<PathSegment>,
tree: McpTree,
},
}
pub struct RequestedCommandView {
@@ -251,6 +335,14 @@ pub struct RequestedCommandView {
// Selection support for MCP tool call detail text
mcp_content_selection_handle: SelectionHandle,
mcp_content_selected_text: Arc<std::sync::RwLock<Option<String>>>,
// Structured request data and per-tree expansion state for JSON tree rendering.
// `mcp_request` is populated from the stream as soon as the tool name
// and arguments are known. Separate states ensure request-tree paths start
// at depth 0 and are not confused with response-tree paths.
mcp_request: Option<McpRequest>,
mcp_request_tree_state: JsonTreeState,
mcp_response_tree_state: JsonTreeState,
}
impl RequestedCommandView {
@@ -485,6 +577,9 @@ impl RequestedCommandView {
ai_block_view_id,
mcp_content_selection_handle: SelectionHandle::default(),
mcp_content_selected_text: Arc::new(std::sync::RwLock::new(None)),
mcp_request: None,
mcp_request_tree_state: Default::default(),
mcp_response_tree_state: Default::default(),
}
}
@@ -626,6 +721,13 @@ impl RequestedCommandView {
format!("RequestedCommandView-{prefix}-accept-split")
}
fn is_waiting_for_user_confirmation(&self, app: &AppContext) -> bool {
self.action_model
.as_ref(app)
.get_action_status(&self.action_id)
.is_some_and(|status| status.is_blocked())
}
pub fn is_header_expanded(&self) -> bool {
self.is_header_expanded
}
@@ -979,6 +1081,14 @@ impl RequestedCommandView {
}
}
/// Stores the structured MCP tool request data for JSON tree rendering.
///
/// Called each time a stream update arrives so the view always reflects
/// the latest known arguments.
pub(crate) fn update_mcp_request(&mut self, name: String, args: serde_json::Value) {
self.mcp_request = Some(McpRequest { name, args });
}
/// Extracts the tool name from MCP tool command text, removing parameters.
/// For example, "tool_name(param1, param2)" becomes "tool_name".
fn extract_mcp_tool_name(&self, command_text: &str) -> String {
@@ -1088,6 +1198,11 @@ impl RequestedCommandView {
appearance.theme().surface_2(),
));
}
} else if requested_command_block.is_some_and(|block| block.finished()) {
// If a finished command block exists but there's no action status,
// treat the same as a finished command (normal text styling).
title = self.get_header_title_text().into();
font_override = Some(appearance.monospace_font_family());
} else {
// If there is no action status and response is not streaming, it was cancelled
// mid-flight.
@@ -1220,19 +1335,27 @@ impl RequestedCommandView {
));
}
Some(AIActionStatus::Finished(result)) => {
// Determine if command should be expandable based on whether it actually executed
let should_be_expandable = match &result.result {
AIAgentActionResultType::RequestCommandOutput(command_result) => {
match command_result {
// All completed commands are expandable (including interrupted ones)
RequestCommandOutputResult::Completed { .. } => true,
// Cancelled before execution are not expandable
RequestCommandOutputResult::CancelledBeforeExecution => false,
_ => result.result.is_successful() || result.result.is_failed(),
// Determine if command should be expandable based on whether it actually executed.
// If a finished command block exists for this action, the command definitely ran,
// so it should be expandable regardless of the action result type. This handles
// cases where the action result is stale (e.g. a LongRunningCommandSnapshot
// converted to CancelledBeforeExecution on restore, even though the command
// completed successfully).
let has_finished_command_block =
requested_command_block.is_some_and(|block| block.finished());
let should_be_expandable = has_finished_command_block
|| match &result.result {
AIAgentActionResultType::RequestCommandOutput(command_result) => {
match command_result {
// All completed commands are expandable (including interrupted ones)
RequestCommandOutputResult::Completed { .. } => true,
// Cancelled before execution are not expandable
RequestCommandOutputResult::CancelledBeforeExecution => false,
_ => result.result.is_successful() || result.result.is_failed(),
}
}
}
_ => result.result.is_successful() || result.result.is_failed(),
};
_ => result.result.is_successful() || result.result.is_failed(),
};
if should_be_expandable {
config = config.with_interaction_mode(InteractionMode::ManuallyExpandable(
@@ -1255,7 +1378,15 @@ impl RequestedCommandView {
));
}
}
_ => (),
_ => {
// Even without a known action status, if a finished command block exists
// for this action, the command ran and the header should be expandable.
if requested_command_block.is_some_and(|block| block.finished()) {
config = config.with_interaction_mode(InteractionMode::ManuallyExpandable(
self.get_expansion_config(requested_command_block, app),
));
}
}
};
config.render(app)
@@ -1491,23 +1622,26 @@ impl View for RequestedCommandView {
let should_remove_bottom_margin = is_rendered_above_expanded_command_block
|| ((self.action_type.is_requested_command() || self.action_type.is_mcp_tool())
&& is_last_output_message_in_output
&& BlocklistAIHistoryModel::as_ref(app)
&& (BlocklistAIHistoryModel::as_ref(app)
.conversation(&self.client_ids.conversation_id)
.is_some_and(|conversation| {
let mut exchanges = conversation.root_task_exchanges();
while let Some(exchange) = exchanges.next() {
if exchange.id == self.client_ids.client_exchange_id {
// If the next exchange doesn't contain a user query, don't render bottom margin for continuity.
return exchanges.next().is_some_and(|exchange| {
// Prevents an issue where the bottom margin is removed when the requested command is the last message and we cancel it.
// We want to keep the margin in this case so that there's visual separation between the cancelled command and footer.
conversation.status() != &ConversationStatus::Cancelled
// If the next exchange doesn't contain a user query, don't render bottom margin for continuity.
&& conversation
.root_task_exchanges()
.skip_while(|exchange| {
exchange.id != self.client_ids.client_exchange_id
})
.nth(1)
.is_some_and(|exchange| {
!exchange
.input
.iter()
.any(|input| input.user_query().is_some())
});
}
}
false
})
.any(|input| input.display_query().is_some())
})
}))
&& !is_input_pinned_to_top);
let container = Container::new(content.finish())
@@ -1555,8 +1689,11 @@ impl View for RequestedCommandView {
root_stack.finish()
}
fn keymap_context(&self, _app: &AppContext) -> Context {
fn keymap_context(&self, app: &AppContext) -> Context {
let mut context = Self::default_keymap_context();
if self.is_waiting_for_user_confirmation(app) {
context.set.insert(REQUESTED_ACTION_BLOCKED_KEYMAP_CONTEXT);
}
if self.is_editing {
context.set.insert(EDIT_MODE_OPEN_KEYMAP_CONTEXT);
@@ -1569,6 +1706,19 @@ impl TypedActionView for RequestedCommandView {
type Action = RequestedCommandViewAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
if matches!(
action,
RequestedCommandViewAction::Accept
| RequestedCommandViewAction::AcceptAndAutoExecute
| RequestedCommandViewAction::ToggleAcceptMenu
| RequestedCommandViewAction::Reject
| RequestedCommandViewAction::OpenEditMode
| RequestedCommandViewAction::CloseEditMode
| RequestedCommandViewAction::FocusEditor
) && !self.is_waiting_for_user_confirmation(ctx)
{
return;
}
match action {
RequestedCommandViewAction::Accept => {
self.commit_editor_contents(ctx);
@@ -1599,6 +1749,21 @@ impl TypedActionView for RequestedCommandView {
RequestedCommandViewAction::SelectText => {
ctx.emit(RequestedCommandViewEvent::TextSelected);
}
RequestedCommandViewAction::ToggleJsonNode { path, tree } => {
let depth = path.len();
match tree {
McpTree::Request => self.mcp_request_tree_state.toggle(path, depth),
McpTree::Response => self.mcp_response_tree_state.toggle(path, depth),
}
ctx.notify();
}
RequestedCommandViewAction::ToggleJsonString { path, tree } => {
match tree {
McpTree::Request => self.mcp_request_tree_state.toggle_string(path),
McpTree::Response => self.mcp_response_tree_state.toggle_string(path),
}
ctx.notify();
}
}
}
}
@@ -1642,5 +1807,5 @@ pub fn format_command_text(text: &str) -> String {
}
#[cfg(test)]
#[path = "requested_command_test.rs"]
#[path = "requested_command_tests.rs"]
mod tests;
@@ -1,18 +1,15 @@
//! Module to attribute AI-generated requested commands
//! to known documents (e.g. Warp Drive objects).
use galaxyui::AppContext;
use galaxyui::SingletonEntity;
use markdown_parser::{parse_markdown, FormattedTextLine};
use galaxyui::{AppContext, SingletonEntity};
use crate::env_vars::EnvVarCollection;
use crate::env_vars::EnvVarValue;
use crate::ai::agent::AIAgentCitation;
use crate::cloud_object::model::persistence::CloudModel;
use crate::env_vars::{EnvVarCollection, EnvVarCollectionExt, EnvVarExt, EnvVarValue};
use crate::notebooks::CloudNotebookModel;
use crate::terminal::shell::ShellType;
use crate::{
ai::agent::AIAgentCitation, cloud_object::model::persistence::CloudModel,
workflows::command_parser::command_matches_workflow,
};
use markdown_parser::{parse_markdown, FormattedTextLine};
use crate::workflows::command_parser::command_matches_workflow;
/// Returns true iff the `command` is directly copied from the `document`.
pub(crate) fn is_command_copied_from_document(
@@ -109,5 +106,5 @@ fn is_command_copied_from_env_var_collection(
}
#[cfg(test)]
#[path = "requested_command_attribution_test.rs"]
#[path = "requested_command_attribution_tests.rs"]
mod tests;
@@ -1,6 +1,6 @@
use crate::env_vars::{view::command_dialog::EnvVarSecretCommand, EnvVar};
use super::*;
use crate::env_vars::view::command_dialog::EnvVarSecretCommand;
use crate::env_vars::EnvVar;
#[test]
fn test_is_command_copied_from_notebook() {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,553 @@
use std::path::PathBuf;
use ai::agent::action::{RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest};
use ai::agent::action_result::{
RunAgentsAgentOutcome, RunAgentsAgentOutcomeKind, RunAgentsLaunchedExecutionMode,
RunAgentsResult,
};
use ai::skills::SkillReference;
use warp_util::local_or_remote_path::LocalOrRemotePath;
use super::RunAgentsEditState;
use crate::ai::blocklist::inline_action::orchestration_controls::OrchestrationEditState;
fn make_request(harness: &str, mode: RunAgentsExecutionMode) -> RunAgentsRequest {
make_request_with_skills(harness, mode, Vec::new())
}
fn make_request_with_skills(
harness: &str,
mode: RunAgentsExecutionMode,
skills: Vec<SkillReference>,
) -> RunAgentsRequest {
RunAgentsRequest {
summary: "summary".to_string(),
base_prompt: "base".to_string(),
skills,
model_id: "auto".to_string(),
harness_type: harness.to_string(),
execution_mode: mode,
agent_run_configs: vec![RunAgentsAgentRunConfig {
name: "child".to_string(),
prompt: "do work".to_string(),
title: "Child agent".to_string(),
}],
plan_id: String::new(),
harness_auth_secret_name: None,
}
}
fn make_edit_state_with_orch_fields(
harness: &str,
mode: RunAgentsExecutionMode,
) -> RunAgentsEditState {
let request = make_request(harness, mode);
RunAgentsEditState {
orch: OrchestrationEditState::from_run_agents_fields(
&request.model_id,
&request.harness_type,
&request.execution_mode,
),
agent_run_configs: request.agent_run_configs,
base_prompt: request.base_prompt,
summary: request.summary,
skills: request.skills,
plan_id: request.plan_id,
}
}
#[test]
fn local_to_cloud_initializes_remote_with_empty_environment() {
let mut state =
RunAgentsEditState::from_request(&make_request("oz", RunAgentsExecutionMode::Local));
assert!(matches!(
state.orch.execution_mode,
RunAgentsExecutionMode::Local
));
state.orch.toggle_execution_mode_to_remote(true);
let RunAgentsExecutionMode::Remote {
environment_id,
worker_host,
computer_use_enabled,
} = state.orch.execution_mode
else {
panic!("expected Remote after toggle");
};
assert_eq!(environment_id, "");
assert_eq!(worker_host, "warp");
assert!(!computer_use_enabled);
}
#[test]
fn cloud_to_local_drops_environment() {
let mut state = RunAgentsEditState::from_request(&make_request(
"oz",
RunAgentsExecutionMode::Remote {
environment_id: "env-1".to_string(),
worker_host: "warp".to_string(),
computer_use_enabled: false,
},
));
state.orch.toggle_execution_mode_to_remote(false);
assert!(matches!(
state.orch.execution_mode,
RunAgentsExecutionMode::Local
));
}
#[test]
fn local_to_cloud_resets_opencode_to_oz() {
let mut state =
RunAgentsEditState::from_request(&make_request("opencode", RunAgentsExecutionMode::Local));
state.orch.toggle_execution_mode_to_remote(true);
assert_eq!(state.orch.harness_type, "oz");
}
#[test]
fn cloud_without_env_no_longer_disables_accept() {
let state = RunAgentsEditState::from_request(&make_request(
"oz",
RunAgentsExecutionMode::Remote {
environment_id: String::new(),
worker_host: "warp".to_string(),
computer_use_enabled: false,
},
));
assert!(
state.orch.accept_disabled_reason().is_none(),
"Cloud without env should NOT disable Accept (soft recommendation only)"
);
}
#[test]
fn cloud_with_opencode_disables_accept() {
// Bypass the toggle helper to test the validation gate directly.
let state = RunAgentsEditState::from_request(&make_request(
"opencode",
RunAgentsExecutionMode::Remote {
environment_id: "env-1".to_string(),
worker_host: "warp".to_string(),
computer_use_enabled: false,
},
));
let reason = state.orch.accept_disabled_reason();
assert!(reason.is_some(), "Cloud + OpenCode should disable Accept");
assert!(reason.unwrap().contains("OpenCode"));
}
#[test]
fn local_with_any_harness_does_not_disable_accept() {
for harness in ["oz", "claude", "gemini", "opencode"] {
let state =
RunAgentsEditState::from_request(&make_request(harness, RunAgentsExecutionMode::Local));
assert!(
state.orch.accept_disabled_reason().is_none(),
"Local + {harness} should allow Accept"
);
}
}
#[test]
fn local_with_disabled_codex_disables_accept() {
let state = make_edit_state_with_orch_fields("codex", RunAgentsExecutionMode::Local);
assert_eq!(
state.orch.accept_disabled_reason(),
Some("Local Codex child agents are temporarily disabled.")
);
}
#[test]
fn from_request_sanitizes_disabled_local_harness_to_oz() {
let state =
RunAgentsEditState::from_request(&make_request("codex", RunAgentsExecutionMode::Local));
assert_eq!(state.orch.harness_type, "oz");
assert_eq!(state.orch.model_id, "");
assert!(state.orch.accept_disabled_reason().is_none());
}
#[test]
fn cloud_with_env_and_non_opencode_harness_allows_accept() {
for harness in ["oz", "claude", "gemini"] {
let state = RunAgentsEditState::from_request(&make_request(
harness,
RunAgentsExecutionMode::Remote {
environment_id: "env-1".to_string(),
worker_host: "warp".to_string(),
computer_use_enabled: false,
},
));
assert!(
state.orch.accept_disabled_reason().is_none(),
"Cloud + env + {harness} should allow Accept"
);
}
}
#[test]
fn set_environment_id_no_op_in_local_mode() {
let mut state =
RunAgentsEditState::from_request(&make_request("oz", RunAgentsExecutionMode::Local));
state.orch.set_environment_id("env-1".to_string());
assert!(matches!(
state.orch.execution_mode,
RunAgentsExecutionMode::Local
));
}
#[test]
fn set_environment_id_updates_remote() {
let mut state = RunAgentsEditState::from_request(&make_request(
"oz",
RunAgentsExecutionMode::Remote {
environment_id: "old".to_string(),
worker_host: "warp".to_string(),
computer_use_enabled: false,
},
));
state.orch.set_environment_id("new-env".to_string());
let RunAgentsExecutionMode::Remote { environment_id, .. } = state.orch.execution_mode else {
panic!("expected Remote");
};
assert_eq!(environment_id, "new-env");
}
#[test]
fn to_request_round_trips_request_fields() {
let mut req = make_request_with_skills(
"claude",
RunAgentsExecutionMode::Remote {
environment_id: "env-2".to_string(),
worker_host: "warp".to_string(),
computer_use_enabled: true,
},
vec![
SkillReference::BundledSkillId("writing-pr-descriptions".to_string()),
SkillReference::Path(LocalOrRemotePath::Local(PathBuf::from(
"/tmp/skill/SKILL.md",
))),
],
);
req.plan_id = "plan-1".to_string();
let state = RunAgentsEditState::from_request(&req);
let round_tripped = state.to_request();
assert_eq!(round_tripped.summary, req.summary);
assert_eq!(round_tripped.base_prompt, req.base_prompt);
assert_eq!(round_tripped.model_id, req.model_id);
assert_eq!(round_tripped.harness_type, req.harness_type);
assert_eq!(round_tripped.execution_mode, req.execution_mode);
assert_eq!(round_tripped.agent_run_configs, req.agent_run_configs);
assert_eq!(round_tripped.skills, req.skills);
assert_eq!(round_tripped.plan_id, req.plan_id);
}
mod format_terminal_state_tests {
use super::super::{format_terminal_state, StatusKind};
use super::*;
fn launched(name: &str, agent_id: &str) -> RunAgentsAgentOutcome {
RunAgentsAgentOutcome {
name: name.to_string(),
kind: RunAgentsAgentOutcomeKind::Launched {
agent_id: agent_id.to_string(),
},
}
}
fn failed(name: &str, error: &str) -> RunAgentsAgentOutcome {
RunAgentsAgentOutcome {
name: name.to_string(),
kind: RunAgentsAgentOutcomeKind::Failed {
error: error.to_string(),
},
}
}
fn launched_result(agents: Vec<RunAgentsAgentOutcome>) -> RunAgentsResult {
RunAgentsResult::Launched {
model_id: "auto".to_string(),
harness_type: "oz".to_string(),
execution_mode: RunAgentsLaunchedExecutionMode::Local,
agents,
}
}
#[test]
fn launched_singular_uses_singular_label() {
let result = launched_result(vec![launched("child", "a-1")]);
let (label, kind) = format_terminal_state(&result);
assert_eq!(label, "Spawned 1 agent");
assert!(matches!(kind, StatusKind::Success));
}
#[test]
fn launched_plural_uses_plural_label() {
let result = launched_result(vec![
launched("a", "a-1"),
launched("b", "a-2"),
launched("c", "a-3"),
]);
let (label, kind) = format_terminal_state(&result);
assert_eq!(label, "Spawned 3 agents");
assert!(matches!(kind, StatusKind::Success));
}
#[test]
fn launched_partial_uses_x_of_y_label_and_mixed_status() {
let result = launched_result(vec![
launched("a", "a-1"),
failed("b", "boom"),
launched("c", "a-3"),
]);
let (label, kind) = format_terminal_state(&result);
assert_eq!(label, "Spawned 2 of 3 agents");
assert!(matches!(kind, StatusKind::Mixed));
}
#[test]
fn all_failed_uses_failure_status_not_mixed() {
let result = launched_result(vec![
failed("a", "boom"),
failed("b", "boom"),
failed("c", "boom"),
]);
let (label, kind) = format_terminal_state(&result);
assert_eq!(label, "Failed to spawn 3 agents");
assert!(matches!(kind, StatusKind::Failure));
}
#[test]
fn single_failed_uses_singular_failure_label() {
let result = launched_result(vec![failed("a", "boom")]);
let (label, kind) = format_terminal_state(&result);
assert_eq!(label, "Failed to spawn agent");
assert!(matches!(kind, StatusKind::Failure));
}
#[test]
fn failure_with_error_includes_error_text() {
let (label, kind) = format_terminal_state(&RunAgentsResult::Failure {
error: "server rejected request".to_string(),
});
assert_eq!(
label,
"Failed to start orchestration: server rejected request"
);
assert!(matches!(kind, StatusKind::Failure));
}
#[test]
fn failure_with_empty_error_uses_short_label() {
let (label, kind) = format_terminal_state(&RunAgentsResult::Failure {
error: String::new(),
});
assert_eq!(label, "Failed to start orchestration");
assert!(matches!(kind, StatusKind::Failure));
}
#[test]
fn denied_with_reason_appends_reason() {
let (label, kind) = format_terminal_state(&RunAgentsResult::Denied {
reason: "disapproved".to_string(),
});
assert!(label.contains("disapproved"));
assert!(matches!(kind, StatusKind::Cancelled));
}
#[test]
fn denied_without_reason_uses_short_label() {
let (label, kind) = format_terminal_state(&RunAgentsResult::Denied {
reason: String::new(),
});
assert!(!label.contains("()"));
assert!(matches!(kind, StatusKind::Cancelled));
}
#[test]
fn cancelled_uses_cancelled_status() {
let (label, kind) = format_terminal_state(&RunAgentsResult::Cancelled);
assert_eq!(label, "Spawn agents cancelled");
assert!(matches!(kind, StatusKind::Cancelled));
}
}
mod override_from_approved_config_tests {
use ai::agent::orchestration_config::{OrchestrationConfig, OrchestrationExecutionMode};
use super::super::RunAgentsEditState;
fn local_config(model: &str, harness: &str) -> OrchestrationConfig {
OrchestrationConfig {
model_id: model.to_string(),
harness_type: harness.to_string(),
execution_mode: OrchestrationExecutionMode::Local,
}
}
fn remote_config(model: &str, harness: &str, env: &str) -> OrchestrationConfig {
OrchestrationConfig {
model_id: model.to_string(),
harness_type: harness.to_string(),
execution_mode: OrchestrationExecutionMode::Remote {
environment_id: env.to_string(),
worker_host: "warp".to_string(),
},
}
}
#[test]
fn overrides_model_and_harness_unconditionally() {
let mut state =
RunAgentsEditState::from_request(&make_request("oz", RunAgentsExecutionMode::Local));
assert_eq!(state.orch.model_id, "auto");
assert_eq!(state.orch.harness_type, "oz");
state
.orch
.override_from_approved_config(&local_config("claude-4-opus", "claude"));
assert_eq!(state.orch.model_id, "claude-4-opus");
assert_eq!(state.orch.harness_type, "claude");
}
#[test]
fn overrides_even_when_request_has_values() {
let mut state = RunAgentsEditState::from_request(&make_request(
"claude",
RunAgentsExecutionMode::Local,
));
state
.orch
.override_from_approved_config(&local_config("gpt-5", "codex"));
assert_eq!(state.orch.model_id, "gpt-5");
assert_eq!(state.orch.harness_type, "codex");
}
#[test]
fn overrides_local_to_remote() {
let mut state =
RunAgentsEditState::from_request(&make_request("oz", RunAgentsExecutionMode::Local));
state
.orch
.override_from_approved_config(&remote_config("auto", "oz", "env-1"));
let RunAgentsExecutionMode::Remote {
environment_id,
worker_host,
..
} = &state.orch.execution_mode
else {
panic!("expected Remote after override");
};
assert_eq!(environment_id, "env-1");
assert_eq!(worker_host, "warp");
}
#[test]
fn overrides_remote_to_local() {
let mut state = RunAgentsEditState::from_request(&make_request(
"oz",
RunAgentsExecutionMode::Remote {
environment_id: "env-1".to_string(),
worker_host: "warp".to_string(),
computer_use_enabled: true,
},
));
state
.orch
.override_from_approved_config(&local_config("auto", "oz"));
assert!(
matches!(state.orch.execution_mode, RunAgentsExecutionMode::Local),
"should be Local after override"
);
}
#[test]
fn preserves_computer_use_when_both_remote() {
let mut state = RunAgentsEditState::from_request(&make_request(
"oz",
RunAgentsExecutionMode::Remote {
environment_id: "old-env".to_string(),
worker_host: "warp".to_string(),
computer_use_enabled: true,
},
));
state
.orch
.override_from_approved_config(&remote_config("auto", "oz", "new-env"));
let RunAgentsExecutionMode::Remote {
environment_id,
computer_use_enabled,
..
} = &state.orch.execution_mode
else {
panic!("expected Remote");
};
assert_eq!(environment_id, "new-env", "env should come from config");
assert!(
*computer_use_enabled,
"computer_use_enabled should be preserved from original request"
);
}
#[test]
fn does_not_carry_computer_use_from_local_to_remote() {
let mut state =
RunAgentsEditState::from_request(&make_request("oz", RunAgentsExecutionMode::Local));
state
.orch
.override_from_approved_config(&remote_config("auto", "oz", "env-1"));
let RunAgentsExecutionMode::Remote {
computer_use_enabled,
..
} = &state.orch.execution_mode
else {
panic!("expected Remote");
};
assert!(
!*computer_use_enabled,
"computer_use_enabled should default to false when original was Local"
);
}
#[test]
fn approved_local_disabled_harness_reports_disabled_reason_after_override() {
let mut state =
RunAgentsEditState::from_request(&make_request("oz", RunAgentsExecutionMode::Local));
state
.orch
.override_from_approved_config(&local_config("auto", "codex"));
assert_eq!(
state.orch.accept_disabled_reason(),
Some("Local Codex child agents are temporarily disabled.")
);
}
}
#[test]
fn local_to_cloud_idempotent_when_already_remote() {
let mut state = RunAgentsEditState::from_request(&make_request(
"oz",
RunAgentsExecutionMode::Remote {
environment_id: "env-1".to_string(),
worker_host: "warp".to_string(),
computer_use_enabled: true,
},
));
state.orch.toggle_execution_mode_to_remote(true);
let RunAgentsExecutionMode::Remote {
environment_id,
computer_use_enabled,
..
} = state.orch.execution_mode
else {
panic!("expected Remote");
};
assert_eq!(
environment_id, "env-1",
"toggle to Remote when already Remote should not clobber env"
);
assert!(
computer_use_enabled,
"toggle to Remote when already Remote should not clobber computer_use"
);
}
@@ -2,47 +2,35 @@ use std::ops::Range;
use std::sync::{Arc, RwLock};
use galaxy_core::ui::appearance::Appearance;
use galaxyui::elements::{
Container, CornerRadius, CrossAxisAlignment, Element, Flex, FormattedTextElement,
MainAxisAlignment, ParentElement, Radius, SelectableArea, SelectionHandle, Shrinkable,
};
use galaxyui::ui_components::components::UiComponentStyles;
use galaxyui::{
elements::{
Container, CornerRadius, CrossAxisAlignment, Element, Flex, FormattedTextElement,
MainAxisAlignment, ParentElement, Radius, SelectableArea, SelectionHandle, Shrinkable,
},
AppContext, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
};
use super::search_results_common::{
render_collapsible_search_results, CollapsibleSearchResultsState,
};
use crate::ai::agent::icons::yellow_running_icon;
use crate::ai::agent::FileContext;
use crate::ai::blocklist::action_model::AIActionStatus;
use crate::ai::blocklist::block::find::FindState;
use crate::ai::blocklist::block::secret_redaction::SecretRedactionState;
use crate::ai::blocklist::block::view_impl::output::{
render_read_files_text, LinkActionConstructors, RenderContext, RenderReadFileArg,
};
use crate::ai::blocklist::block::view_impl::{FindContext, WithContentItemSpacing};
use crate::ai::blocklist::inline_action::inline_action_header::{
INLINE_ACTION_HEADER_VERTICAL_PADDING, INLINE_ACTION_HORIZONTAL_PADDING,
};
use crate::ai::blocklist::inline_action::inline_action_icons::cancelled_icon;
use crate::ai::blocklist::TextLocation;
use crate::ai::{
agent::icons::yellow_running_icon,
blocklist::inline_action::{
inline_action_header::{
INLINE_ACTION_HEADER_VERTICAL_PADDING, INLINE_ACTION_HORIZONTAL_PADDING,
},
inline_action_icons::cancelled_icon,
},
};
use crate::ai::{
agent::FileContext,
blocklist::{
action_model::AIActionStatus,
block::{
find::FindState,
secret_redaction::SecretRedactionState,
view_impl::{
output::{
render_read_files_text, LinkActionConstructors, RenderContext,
RenderReadFileArg,
},
FindContext, WithContentItemSpacing,
},
},
},
};
use crate::terminal::find::TerminalFindModel;
use crate::terminal::view::RichContentLink;
use crate::terminal::{find::TerminalFindModel, ShellLaunchData};
use crate::terminal::ShellLaunchData;
use crate::util::link_detection::{
detect_links, DetectedLinkType, DetectedLinksState, LinkLocation,
};
@@ -1,39 +1,37 @@
use std::sync::Arc;
use galaxy_core::{settings::ToggleableSetting, ui::appearance::Appearance};
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
use rand::distributions::Alphanumeric;
use rand::{thread_rng, Rng as _};
use galaxy_core::settings::ToggleableSetting;
use galaxy_core::ui::appearance::Appearance;
use galaxyui::elements::{
Align, ConstrainedBox, Container, CrossAxisAlignment, Expanded, Flex, FormattedTextElement,
HighlightedHyperlink, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement,
SavePosition, Shrinkable, SizeConstraintCondition, SizeConstraintSwitch, Text,
};
use galaxyui::platform::Cursor;
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use galaxyui::{
elements::{
Align, ConstrainedBox, Container, CrossAxisAlignment, Expanded, Flex, FormattedTextElement,
HighlightedHyperlink, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement,
SavePosition, Shrinkable, SizeConstraintCondition, SizeConstraintSwitch, Text,
},
platform::Cursor,
ui_components::components::{Coords, UiComponent, UiComponentStyles},
AppContext, Element, Entity, FocusContext, SingletonEntity, TypedActionView, View, ViewContext,
};
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
use rand::{distributions::Alphanumeric, thread_rng, Rng as _};
use crate::{
ai::{
agent::{AIAgentActionId, AIIdentifiers},
predict::prompt_suggestions::{
ACCEPT_PROMPT_SUGGESTION_KEYBINDING, REJECT_PROMPT_SUGGESTION_KEYSTROKE,
},
},
send_telemetry_from_ctx,
server::telemetry::ToggleCodeSuggestionsSettingSource,
settings::AISettings,
ui_components::{blended_colors, icons::Icon},
view_components::{
action_button::{ButtonSize, KeystrokeSource, NakedTheme, PrimaryTheme},
compactible_action_button::{
render_compact_and_regular_button_rows, CompactibleActionButton,
MEDIUM_SIZE_SWITCH_THRESHOLD,
},
},
TelemetryEvent,
use crate::ai::agent::{AIAgentActionId, AIIdentifiers};
use crate::ai::predict::prompt_suggestions::{
ACCEPT_PROMPT_SUGGESTION_KEYBINDING, REJECT_PROMPT_SUGGESTION_KEYSTROKE,
};
use crate::server::telemetry::ToggleCodeSuggestionsSettingSource;
use crate::settings::AISettings;
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
use crate::view_components::action_button::{
ButtonSize, KeystrokeSource, NakedTheme, PrimaryTheme,
};
use crate::view_components::compactible_action_button::{
render_compact_and_regular_button_rows, CompactibleActionButton, MEDIUM_SIZE_SWITCH_THRESHOLD,
};
use crate::{send_telemetry_from_ctx, TelemetryEvent};
const ACCEPT_LABEL: &str = "Generate tests";
const CANCEL_LABEL: &str = "Dismiss";