first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -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(¤t, 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;
|
||||
|
||||
+2
-1
@@ -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() {
|
||||
@@ -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;
|
||||
|
||||
+2
-2
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user