first pass of merging in warp (doesn't build)
This commit is contained in:
+189
-70
@@ -1,41 +1,35 @@
|
||||
use super::{
|
||||
common::{
|
||||
add_command_xray_overlay, add_input_suggestions_overlays, add_voltron_overlay,
|
||||
add_workflow_info_overlay, maybe_add_buy_credits_banner,
|
||||
wrap_input_with_terminal_padding_and_focus_handler,
|
||||
},
|
||||
Input, InputAction, InputDropTargetData,
|
||||
};
|
||||
use crate::{
|
||||
ai::blocklist::{
|
||||
agent_view::{
|
||||
agent_view_bg_fill,
|
||||
shortcuts::{render_agent_shortcuts_view, AgentShortcutsViewContext},
|
||||
AgentViewState,
|
||||
},
|
||||
InputType,
|
||||
},
|
||||
appearance::Appearance,
|
||||
context_chips::spacing::{self},
|
||||
features::FeatureFlag,
|
||||
settings::InputModeSettings,
|
||||
terminal::{settings::TerminalSettings, view::TerminalAction},
|
||||
BlocklistAIHistoryModel,
|
||||
};
|
||||
use galaxy_cli::agent::Harness;
|
||||
use galaxy_core::settings::Setting;
|
||||
use galaxy_core::ui::theme::color::internal_colors;
|
||||
use galaxyui::elements::Expanded;
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
Align, AnchorPair, Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
|
||||
DispatchEventResult, DropTarget, Element, EventHandler, Flex, Hoverable, MainAxisSize,
|
||||
OffsetPositioning, OffsetType, ParentElement, PositionedElementOffsetBounds,
|
||||
PositioningAxis, Radius, SavePosition, Stack, Text, XAxisAnchor, YAxisAnchor,
|
||||
},
|
||||
presenter::ChildView,
|
||||
AppContext, SingletonEntity as _,
|
||||
use galaxyui::elements::{
|
||||
Align, AnchorPair, Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
|
||||
DispatchEventResult, DropTarget, Element, Empty, EventHandler, Expanded, Flex, Hoverable,
|
||||
MainAxisSize, OffsetPositioning, OffsetType, ParentElement, PositionedElementOffsetBounds,
|
||||
PositioningAxis, Radius, SavePosition, Stack, XAxisAnchor, YAxisAnchor,
|
||||
};
|
||||
use pathfinder_color::ColorU;
|
||||
use galaxyui::presenter::ChildView;
|
||||
use galaxyui::{AppContext, SingletonEntity as _};
|
||||
|
||||
use super::common::{
|
||||
add_command_xray_overlay, add_input_suggestions_overlays, add_voltron_overlay,
|
||||
add_workflow_info_overlay, maybe_add_buy_credits_banner,
|
||||
wrap_input_with_terminal_padding_and_focus_handler,
|
||||
};
|
||||
use super::{Input, InputAction, InputDropTargetData};
|
||||
use crate::ai::blocklist::agent_view::shortcuts::{
|
||||
render_agent_shortcuts_view, AgentShortcutsViewContext,
|
||||
};
|
||||
use crate::ai::blocklist::agent_view::{agent_view_bg_fill, AgentViewState};
|
||||
use crate::ai::blocklist::InputType;
|
||||
use crate::ai::harness_availability::HarnessAvailabilityModel;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::context_chips::spacing::{self};
|
||||
use crate::editor::position_id_for_cursor;
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::settings::InputModeSettings;
|
||||
use crate::terminal::settings::TerminalSettings;
|
||||
use crate::terminal::view::TerminalAction;
|
||||
use crate::BlocklistAIHistoryModel;
|
||||
|
||||
pub(super) const CLOUD_MODE_V2_MAX_WIDTH: f32 = 720.;
|
||||
|
||||
@@ -67,10 +61,14 @@ impl Input {
|
||||
pub fn is_cloud_mode_input_v2_composing(&self, app: &AppContext) -> bool {
|
||||
FeatureFlag::CloudModeInputV2.is_enabled()
|
||||
&& FeatureFlag::CloudMode.is_enabled()
|
||||
&& self
|
||||
.ambient_agent_view_model
|
||||
.as_ref(app)
|
||||
.is_configuring_ambient_agent()
|
||||
&& self.ambient_agent_view_model().is_some_and(|model| {
|
||||
let view_model = model.as_ref(app);
|
||||
view_model.is_configuring_ambient_agent()
|
||||
// The handoff pane intentionally stays on the existing input UI even
|
||||
// when V2 is on — V2 is for fresh cloud-mode runs only, and handoff has
|
||||
// its own pre-spawn flow (submit interception).
|
||||
&& !view_model.is_local_to_cloud_handoff()
|
||||
})
|
||||
}
|
||||
|
||||
/// Renders the input when there is an active `AgentView`.
|
||||
@@ -119,24 +117,29 @@ impl Input {
|
||||
}
|
||||
|
||||
let show_harness_row = FeatureFlag::CloudMode.is_enabled()
|
||||
&& FeatureFlag::AgentHarness.is_enabled()
|
||||
&& HarnessAvailabilityModel::as_ref(app).should_show_harness_selector()
|
||||
&& self
|
||||
.ambient_agent_view_model
|
||||
.as_ref(app)
|
||||
.is_configuring_ambient_agent();
|
||||
.ambient_agent_view_model()
|
||||
.is_some_and(|ambient_agent_model| {
|
||||
ambient_agent_model
|
||||
.as_ref(app)
|
||||
.is_configuring_ambient_agent()
|
||||
});
|
||||
if show_harness_row {
|
||||
// Temporarily render the harness selector in the cloud mode UDI until we fully
|
||||
// implement the new designs.
|
||||
let harness_row = Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_child(ChildView::new(&self.harness_selector).finish())
|
||||
.finish();
|
||||
column.add_child(
|
||||
Container::new(harness_row)
|
||||
.with_padding_top(spacing::UDI_CHIP_MARGIN)
|
||||
.with_padding_bottom(4.)
|
||||
.finish(),
|
||||
);
|
||||
if let Some(harness_selector) = self.harness_selector() {
|
||||
// Temporarily render the harness selector in the cloud mode UDI until we fully
|
||||
// implement the new designs.
|
||||
let harness_row = Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_child(ChildView::new(harness_selector).finish())
|
||||
.finish();
|
||||
column.add_child(
|
||||
Container::new(harness_row)
|
||||
.with_padding_top(spacing::UDI_CHIP_MARGIN)
|
||||
.with_padding_bottom(4.)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let terminal_spacing = TerminalSettings::as_ref(app)
|
||||
@@ -211,7 +214,9 @@ impl Input {
|
||||
)
|
||||
.finish();
|
||||
|
||||
let border_color = if !self.ai_input_model.as_ref(app).is_ai_input_enabled()
|
||||
let border_color = if self.handoff_compose_state.as_ref(app).is_active() {
|
||||
appearance.theme().ansi_fg_magenta()
|
||||
} else if !self.ai_input_model.as_ref(app).is_ai_input_enabled()
|
||||
&& !self.suggestions_mode_model.as_ref(app).is_slash_commands()
|
||||
&& !self.slash_command_model.as_ref(app).state().is_detected_command()
|
||||
// If NLD, don't color the border if the input is empty, because the current
|
||||
@@ -259,7 +264,9 @@ impl Input {
|
||||
.is_profile_selector()
|
||||
{
|
||||
column.add_child(ChildView::new(&self.inline_profile_selector_view).finish());
|
||||
} else if self.suggestions_mode_model.as_ref(app).is_slash_commands() {
|
||||
} else if self.suggestions_mode_model.as_ref(app).is_slash_commands()
|
||||
&& !self.is_cloud_mode_input_v2_composing(app)
|
||||
{
|
||||
column.add_child(ChildView::new(&self.inline_slash_commands_view).finish());
|
||||
} else if self.suggestions_mode_model.as_ref(app).is_prompts_menu() {
|
||||
column.add_child(ChildView::new(&self.inline_prompts_menu_view).finish());
|
||||
@@ -324,7 +331,13 @@ impl Input {
|
||||
app,
|
||||
));
|
||||
}
|
||||
column.add_children([ChildView::new(&self.agent_status_view).finish(), input]);
|
||||
column.add_child(ChildView::new(&self.agent_status_view).finish());
|
||||
if let Some(panel) = self.queued_prompts_panel.as_ref() {
|
||||
if panel.as_ref(app).should_render(app) {
|
||||
column.add_child(ChildView::new(panel).finish());
|
||||
}
|
||||
}
|
||||
column.add_child(input);
|
||||
|
||||
let mut outer_stack = Stack::new().with_constrain_absolute_children();
|
||||
outer_stack.add_child(column.finish());
|
||||
@@ -369,6 +382,7 @@ impl Input {
|
||||
.on_left_mouse_down(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(TerminalAction::ClearSelectionsWhenShellMode);
|
||||
ctx.dispatch_typed_action(InputAction::FocusInputBox);
|
||||
ctx.dispatch_typed_action(InputAction::DismissCloudModeV2SlashCommandsMenu);
|
||||
DispatchEventResult::StopPropagation
|
||||
})
|
||||
.finish()
|
||||
@@ -401,14 +415,56 @@ impl Input {
|
||||
);
|
||||
}
|
||||
|
||||
if self.suggestions_mode_model.as_ref(app).is_slash_commands() {
|
||||
if let Some(view) = self.cloud_mode_v2_slash_commands_view.as_ref() {
|
||||
let cursor_position = position_id_for_cursor(self.editor.id());
|
||||
stack.add_positioned_overlay_child(
|
||||
ChildView::new(view).finish(),
|
||||
OffsetPositioning::from_axes(
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
&cursor_position,
|
||||
PositionedElementOffsetBounds::WindowByPosition,
|
||||
OffsetType::Pixel(0.),
|
||||
AnchorPair::new(XAxisAnchor::Left, XAxisAnchor::Left),
|
||||
),
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
&cursor_position,
|
||||
PositionedElementOffsetBounds::Unbounded,
|
||||
OffsetType::Pixel(4.),
|
||||
AnchorPair::new(YAxisAnchor::Bottom, YAxisAnchor::Top),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(selected_workflow_state) = self.workflows_state.selected_workflow_state.as_ref()
|
||||
{
|
||||
if selected_workflow_state.should_show_more_info_view {
|
||||
add_workflow_info_overlay(
|
||||
&mut stack,
|
||||
selected_workflow_state,
|
||||
self.size_info(app).pane_height_px().as_f32(),
|
||||
menu_positioning,
|
||||
let prompt_position = self.prompt_save_position_id();
|
||||
let workflows_info_view = Container::new(
|
||||
ChildView::new(&selected_workflow_state.more_info_view).finish(),
|
||||
)
|
||||
.finish();
|
||||
stack.add_positioned_overlay_child(
|
||||
ConstrainedBox::new(workflows_info_view)
|
||||
.with_max_width(CLOUD_MODE_V2_MAX_WIDTH)
|
||||
.with_max_height(self.size_info(app).pane_height_px().as_f32() * 0.35)
|
||||
.finish(),
|
||||
OffsetPositioning::from_axes(
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
&prompt_position,
|
||||
PositionedElementOffsetBounds::WindowByPosition,
|
||||
OffsetType::Pixel(0.),
|
||||
AnchorPair::new(XAxisAnchor::Left, XAxisAnchor::Left),
|
||||
),
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
&prompt_position,
|
||||
PositionedElementOffsetBounds::Unbounded,
|
||||
OffsetType::Pixel(0.),
|
||||
AnchorPair::new(YAxisAnchor::Top, YAxisAnchor::Bottom),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -458,6 +514,32 @@ impl Input {
|
||||
SavePosition::new(outer_stack.finish(), &self.save_position_id()).finish()
|
||||
}
|
||||
|
||||
pub(super) fn should_show_auth_secret_ftux(&self, app: &AppContext) -> bool {
|
||||
let Some(view_model) = self.ambient_agent_view_model() else {
|
||||
return false;
|
||||
};
|
||||
let vm = view_model.as_ref(app);
|
||||
let harness = vm.selected_harness();
|
||||
if harness == Harness::Oz {
|
||||
return false;
|
||||
}
|
||||
// Skip FTUX for harnesses that have no auth secret types defined.
|
||||
if crate::ai::auth_secret_types::auth_secret_types_for_harness(harness).is_empty() {
|
||||
return false;
|
||||
}
|
||||
if let Some(ftux_view) = self.auth_secret_ftux_view() {
|
||||
if ftux_view.as_ref(app).has_creation_state() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if crate::ai::cloud_agent_settings::CloudAgentSettings::as_ref(app)
|
||||
.is_harness_auth_ftux_completed(harness)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
vm.selected_harness_auth_secret_name().is_none()
|
||||
}
|
||||
|
||||
fn render_cloud_mode_v2_content(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
@@ -468,8 +550,20 @@ impl Input {
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_spacing(CLOUD_MODE_V2_TOP_ROW_GAP);
|
||||
|
||||
column.add_child(self.render_cloud_mode_v2_top_row());
|
||||
column.add_child(self.render_cloud_mode_v2_input_container(appearance, app));
|
||||
column.add_child(self.render_cloud_mode_v2_top_row(app));
|
||||
|
||||
if let Some(panel) = self.queued_prompts_panel.as_ref() {
|
||||
if panel.as_ref(app).should_render(app) {
|
||||
column.add_child(ChildView::new(panel).finish());
|
||||
}
|
||||
}
|
||||
|
||||
if self.should_show_auth_secret_ftux(app) {
|
||||
column.add_child(self.render_auth_secret_ftux_content());
|
||||
} else {
|
||||
column.add_child(self.render_cloud_mode_v2_input_container(appearance, app));
|
||||
}
|
||||
|
||||
Align::new(
|
||||
ConstrainedBox::new(column.finish())
|
||||
.with_max_width(CLOUD_MODE_V2_MAX_WIDTH)
|
||||
@@ -478,6 +572,13 @@ impl Input {
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_auth_secret_ftux_content(&self) -> Box<dyn Element> {
|
||||
match self.auth_secret_ftux_view() {
|
||||
Some(view) => ChildView::new(view).finish(),
|
||||
None => Empty::new().finish(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_cloud_mode_v2_history_menu(&self, app: &AppContext) -> Option<Box<dyn Element>> {
|
||||
if !self
|
||||
.suggestions_mode_model
|
||||
@@ -490,16 +591,31 @@ impl Input {
|
||||
Some(ChildView::new(view).finish())
|
||||
}
|
||||
|
||||
fn render_cloud_mode_v2_top_row(&self) -> Box<dyn Element> {
|
||||
fn render_cloud_mode_v2_top_row(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let mut row = Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_spacing(CLOUD_MODE_V2_TOP_ROW_INNER_GAP);
|
||||
|
||||
if let Some(host) = self.host_selector.as_ref() {
|
||||
row.add_child(ChildView::new(host).finish());
|
||||
// Only show the host selector when a default host is configured.
|
||||
if let Some(host) = self.host_selector() {
|
||||
if host.as_ref(app).has_default_host() {
|
||||
row.add_child(ChildView::new(host).finish());
|
||||
}
|
||||
}
|
||||
if let Some(harness_selector) = self.harness_selector() {
|
||||
row.add_child(ChildView::new(harness_selector).finish());
|
||||
}
|
||||
|
||||
if let Some(auth_secret_selector) = self.auth_secret_selector() {
|
||||
let harness = self
|
||||
.ambient_agent_view_model()
|
||||
.map(|m| m.as_ref(app).selected_harness())
|
||||
.unwrap_or(warp_cli::agent::Harness::Oz);
|
||||
if harness != warp_cli::agent::Harness::Oz && !self.should_show_auth_secret_ftux(app) {
|
||||
row.add_child(ChildView::new(auth_secret_selector).finish());
|
||||
}
|
||||
}
|
||||
row.add_child(ChildView::new(&self.harness_selector).finish());
|
||||
|
||||
row.finish()
|
||||
}
|
||||
@@ -571,7 +687,10 @@ impl Input {
|
||||
}
|
||||
|
||||
pub(super) fn render_ambient_agent_status_footer(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let ambient_agent_model = self.ambient_agent_view_model.as_ref(app);
|
||||
let Some(ambient_agent_model) = self.ambient_agent_view_model() else {
|
||||
return Empty::new().finish();
|
||||
};
|
||||
let ambient_agent_model = ambient_agent_model.as_ref(app);
|
||||
let mut stack = Stack::new().with_constrain_absolute_children();
|
||||
|
||||
// Don't render status bar when agent has failed or is waiting for session
|
||||
|
||||
@@ -12,7 +12,7 @@ pub struct InputBufferModel {
|
||||
impl InputBufferModel {
|
||||
pub fn new(editor: &ViewHandle<EditorView>, ctx: &mut ModelContext<Self>) -> Self {
|
||||
let editor_clone = editor.downgrade();
|
||||
ctx.subscribe_to_view(editor, move |me, event, ctx| match event {
|
||||
ctx.subscribe_to_view(editor, move |me, _, event, ctx| match event {
|
||||
// This is intended to be the set of Editor view events that exhaustively
|
||||
// capture any changes to editor contents or cursor position.
|
||||
editor::Event::Edited(..)
|
||||
|
||||
@@ -1,38 +1,29 @@
|
||||
use crate::{
|
||||
ai::blocklist::InputType,
|
||||
appearance::Appearance,
|
||||
context_chips::spacing,
|
||||
features::FeatureFlag,
|
||||
settings::{AppEditorSettings, InputModeSettings},
|
||||
terminal::{
|
||||
block_list_settings::BlockListSettings,
|
||||
block_list_viewport::InputMode,
|
||||
input::{
|
||||
common::{
|
||||
add_command_xray_overlay, add_input_suggestions_overlays, add_vim_status_to_stack,
|
||||
add_voltron_overlay, add_workflow_info_overlay,
|
||||
should_show_terminal_input_message_bar,
|
||||
wrap_input_with_terminal_padding_and_focus_handler,
|
||||
},
|
||||
get_input_box_top_border_width, InputDropTargetData,
|
||||
},
|
||||
settings::{SpacingMode, TerminalSettings},
|
||||
view::TerminalAction,
|
||||
warpify::render::{render_subshell_flag, render_subshell_flag_pole},
|
||||
},
|
||||
};
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
Border, ChildAnchor, ChildView, Clipped, Container, DropTarget, Element, Empty, Flex,
|
||||
Hoverable, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds,
|
||||
SavePosition, Stack,
|
||||
},
|
||||
AppContext, SingletonEntity,
|
||||
};
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use settings::Setting;
|
||||
use galaxyui::elements::{
|
||||
Border, ChildAnchor, ChildView, Clipped, Container, DropTarget, Element, Empty, Flex,
|
||||
Hoverable, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, SavePosition,
|
||||
Stack,
|
||||
};
|
||||
use galaxyui::{AppContext, SingletonEntity};
|
||||
|
||||
use super::{should_render_prompt_using_editor_decorator_elements, Input, SubshellRenderState};
|
||||
use crate::ai::blocklist::InputType;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::context_chips::spacing;
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::settings::{AppEditorSettings, InputModeSettings};
|
||||
use crate::terminal::block_list_settings::BlockListSettings;
|
||||
use crate::terminal::block_list_viewport::InputMode;
|
||||
use crate::terminal::input::common::{
|
||||
add_command_xray_overlay, add_input_suggestions_overlays, add_vim_status_to_stack,
|
||||
add_voltron_overlay, add_workflow_info_overlay, should_show_terminal_input_message_bar,
|
||||
wrap_input_with_terminal_padding_and_focus_handler,
|
||||
};
|
||||
use crate::terminal::input::{get_input_box_top_border_width, InputDropTargetData};
|
||||
use crate::terminal::settings::{SpacingMode, TerminalSettings};
|
||||
use crate::terminal::view::TerminalAction;
|
||||
use crate::terminal::warpify::render::{render_subshell_flag, render_subshell_flag_pole};
|
||||
|
||||
impl Input {
|
||||
/// Renders the classic input. This is used when the user has 'Honor PS1' enabled in settings,
|
||||
|
||||
@@ -1,28 +1,27 @@
|
||||
use galaxy_core::ui::color::contrast::MinimumAllowedContrast;
|
||||
use galaxy_core::ui::color::ContrastingColor;
|
||||
use galaxy_core::ui::theme::color::internal_colors;
|
||||
use warpui::elements::{
|
||||
Border, Clipped, ConstrainedBox, Container, DispatchEventResult, DropTarget, Element,
|
||||
EventHandler, Flex, Hoverable, ParentElement, SavePosition, Stack,
|
||||
};
|
||||
use warpui::presenter::ChildView;
|
||||
use warpui::{AppContext, SingletonEntity as _, ViewContext};
|
||||
|
||||
use super::common::{
|
||||
add_input_suggestions_overlays, wrap_input_with_terminal_padding_and_focus_handler,
|
||||
};
|
||||
use super::{
|
||||
common::{add_input_suggestions_overlays, wrap_input_with_terminal_padding_and_focus_handler},
|
||||
Input, InputAction, InputDropTargetData, CLI_AGENT_RICH_INPUT_EDITOR_BOTTOM_PADDING,
|
||||
CLI_AGENT_RICH_INPUT_EDITOR_MAX_HEIGHT, CLI_AGENT_RICH_INPUT_EDITOR_TOP_PADDING,
|
||||
TERMINAL_VIEW_PADDING_LEFT,
|
||||
};
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
context_chips::spacing,
|
||||
editor::TextColors,
|
||||
features::FeatureFlag,
|
||||
terminal::{cli_agent_sessions::CLIAgentSessionsModel, view::TerminalAction},
|
||||
};
|
||||
use galaxy_core::ui::{
|
||||
color::{contrast::MinimumAllowedContrast, ContrastingColor},
|
||||
theme::color::internal_colors,
|
||||
};
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
Border, Clipped, ConstrainedBox, Container, DispatchEventResult, DropTarget, Element,
|
||||
EventHandler, Flex, Hoverable, ParentElement, SavePosition, Stack,
|
||||
},
|
||||
presenter::ChildView,
|
||||
AppContext, SingletonEntity as _, ViewContext,
|
||||
};
|
||||
use crate::appearance::Appearance;
|
||||
use crate::context_chips::spacing;
|
||||
use crate::editor::{EnterAction, EnterSettings, TextColors};
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel;
|
||||
use crate::terminal::view::TerminalAction;
|
||||
|
||||
impl Input {
|
||||
/// Renders the CLI rich input (editor + CLI agent footer).
|
||||
@@ -193,4 +192,41 @@ impl Input {
|
||||
editor.set_text_colors(text_colors, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
/// Configures the editor's enter-key behaviour for the CLI agent rich input.
|
||||
///
|
||||
/// When rich input is **open**, `enter` is always `Emit` so `input_enter`
|
||||
/// runs first and handles inline-menu acceptance before any newline or
|
||||
/// submit logic. `ctrl_enter` is `Emit` only when the toggle is ON
|
||||
/// (submit on Ctrl+Enter); when the toggle is OFF it is
|
||||
/// `InsertNewLineIfMultiLine` to restore baseline newline insertion.
|
||||
///
|
||||
/// When rich input is **closed**, `EnterSettings::default()` is restored.
|
||||
pub(super) fn update_cli_agent_enter_settings(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let rich_input_open =
|
||||
CLIAgentSessionsModel::as_ref(ctx).is_input_open(self.terminal_view_id);
|
||||
|
||||
let settings = if rich_input_open {
|
||||
let submit_on_ctrl_enter =
|
||||
*crate::settings::AISettings::as_ref(ctx).submit_on_ctrl_enter;
|
||||
EnterSettings {
|
||||
// Always Emit so input_enter handles menus before submit/newline.
|
||||
enter: EnterAction::Emit,
|
||||
// Toggle ON → Emit (submit path in input_ctrl_enter).
|
||||
// Toggle OFF → InsertNewLineIfMultiLine (baseline newline).
|
||||
ctrl_enter: if submit_on_ctrl_enter {
|
||||
EnterAction::Emit
|
||||
} else {
|
||||
EnterAction::InsertNewLineIfMultiLine
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
EnterSettings::default()
|
||||
};
|
||||
|
||||
self.editor.update(ctx, |editor, _ctx| {
|
||||
editor.set_enter_settings(settings);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,28 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use pathfinder_color::ColorU;
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_core::ui::theme::color::internal_colors;
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
use galaxyui::elements::{
|
||||
Align, Border, ConstrainedBox, Container, CornerRadius, DropShadow, Radius, Text,
|
||||
Border, ChildView, ConstrainedBox, Container, CornerRadius, DropShadow, Radius,
|
||||
};
|
||||
use galaxyui::{
|
||||
AppContext, Element, Entity, EntityId, ModelHandle, SingletonEntity, View, ViewContext,
|
||||
ViewHandle,
|
||||
};
|
||||
use pathfinder_color::ColorU;
|
||||
|
||||
use crate::ai::blocklist::agent_view::AgentViewController;
|
||||
use crate::search::data_source::QueryFilter;
|
||||
use crate::terminal::input::buffer_model::InputBufferModel;
|
||||
use crate::search::data_source::{Query, QueryFilter};
|
||||
use crate::search::mixer::SearchMixer;
|
||||
use crate::terminal::input::buffer_model::{InputBufferModel, InputBufferUpdateEvent};
|
||||
use crate::terminal::input::inline_history::{
|
||||
AcceptHistoryItem, HistoryTab, InlineHistoryMenuEvent, InlineHistoryMenuView,
|
||||
AcceptHistoryItem, InlineHistoryMenuDataSource, InlineHistoryMenuEvent,
|
||||
};
|
||||
use crate::terminal::input::inline_menu::styles as inline_menu_styles;
|
||||
use crate::terminal::input::inline_menu::{InlineMenuPositioner, InlineMenuTabConfig};
|
||||
use crate::terminal::input::suggestions_mode_model::InputSuggestionsModeModel;
|
||||
use crate::terminal::input::inline_menu::{InlineMenuEvent, InlineMenuPositioner, InlineMenuView};
|
||||
use crate::terminal::input::suggestions_mode_model::{
|
||||
InputSuggestionsModeEvent, InputSuggestionsModeModel,
|
||||
};
|
||||
use crate::terminal::input::InputSuggestionsMode;
|
||||
use crate::terminal::model::session::active_session::ActiveSession;
|
||||
|
||||
const MENU_MAX_HEIGHT: f32 = 168.;
|
||||
@@ -37,7 +39,11 @@ const DROP_SHADOW_COLOR: ColorU = ColorU {
|
||||
};
|
||||
|
||||
pub struct CloudModeV2HistoryMenuView {
|
||||
inner: ViewHandle<InlineHistoryMenuView>,
|
||||
menu_view: ViewHandle<InlineMenuView<AcceptHistoryItem>>,
|
||||
mixer: ModelHandle<SearchMixer<AcceptHistoryItem>>,
|
||||
buffer_model: ModelHandle<InputBufferModel>,
|
||||
suggestions_mode_model: ModelHandle<InputSuggestionsModeModel>,
|
||||
pending_initial_buffer_sync: bool,
|
||||
}
|
||||
|
||||
impl CloudModeV2HistoryMenuView {
|
||||
@@ -51,46 +57,125 @@ impl CloudModeV2HistoryMenuView {
|
||||
buffer_model: ModelHandle<InputBufferModel>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let tab_configs = vec![InlineMenuTabConfig {
|
||||
id: HistoryTab::Prompts,
|
||||
label: "Prompts".to_string(),
|
||||
filters: HashSet::from([QueryFilter::PromptHistory]),
|
||||
}];
|
||||
let inner = ctx.add_view(|ctx| {
|
||||
InlineHistoryMenuView::new_with_tab_configs(
|
||||
let data_source = ctx.add_model(|_| {
|
||||
InlineHistoryMenuDataSource::new(
|
||||
terminal_view_id,
|
||||
active_session,
|
||||
input_suggestions_model,
|
||||
agent_view_controller,
|
||||
positioner,
|
||||
buffer_model,
|
||||
tab_configs,
|
||||
ctx,
|
||||
agent_view_controller.clone(),
|
||||
)
|
||||
});
|
||||
|
||||
ctx.subscribe_to_view(&inner, |_, _, event, ctx| {
|
||||
ctx.emit(event.clone());
|
||||
ctx.notify();
|
||||
let mixer = ctx.add_model(|ctx| {
|
||||
let mut mixer = SearchMixer::<AcceptHistoryItem>::new();
|
||||
mixer.add_sync_source(data_source, [QueryFilter::PromptHistory]);
|
||||
mixer.run_query(prompts_query(""), ctx);
|
||||
mixer
|
||||
});
|
||||
|
||||
Self { inner }
|
||||
let menu_view = ctx.add_typed_action_view(|ctx| {
|
||||
InlineMenuView::new(
|
||||
mixer.clone(),
|
||||
positioner.clone(),
|
||||
input_suggestions_model,
|
||||
agent_view_controller,
|
||||
ctx,
|
||||
)
|
||||
.with_compact_layout()
|
||||
.with_dismiss_on_row_click()
|
||||
});
|
||||
|
||||
ctx.subscribe_to_view(&menu_view, |me, _, event, ctx| match event {
|
||||
InlineMenuEvent::AcceptedItem {
|
||||
item: AcceptHistoryItem::AIPrompt { query_text },
|
||||
..
|
||||
} => {
|
||||
ctx.emit(InlineHistoryMenuEvent::AcceptAIPrompt {
|
||||
query_text: query_text.clone(),
|
||||
});
|
||||
}
|
||||
InlineMenuEvent::SelectedItem {
|
||||
item: AcceptHistoryItem::AIPrompt { query_text },
|
||||
} => {
|
||||
ctx.emit(InlineHistoryMenuEvent::SelectAIPrompt {
|
||||
query_text: query_text.clone(),
|
||||
});
|
||||
}
|
||||
InlineMenuEvent::Dismissed => {
|
||||
me.suggestions_mode_model.update(ctx, |model, ctx| {
|
||||
model.set_mode(InputSuggestionsMode::Closed, ctx);
|
||||
});
|
||||
}
|
||||
InlineMenuEvent::NoResults => {
|
||||
ctx.emit(InlineHistoryMenuEvent::NoResults);
|
||||
}
|
||||
InlineMenuEvent::AcceptedItem { .. }
|
||||
| InlineMenuEvent::SelectedItem { .. }
|
||||
| InlineMenuEvent::TabChanged => {}
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(input_suggestions_model, |me, model, event, ctx| {
|
||||
let InputSuggestionsModeEvent::ModeChanged { .. } = event;
|
||||
if model.as_ref(ctx).is_inline_history_menu() {
|
||||
me.open_with_current_buffer(ctx);
|
||||
}
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(&buffer_model, |me, _, _: &InputBufferUpdateEvent, ctx| {
|
||||
if !me
|
||||
.suggestions_mode_model
|
||||
.as_ref(ctx)
|
||||
.is_inline_history_menu()
|
||||
{
|
||||
return;
|
||||
}
|
||||
if !me.pending_initial_buffer_sync {
|
||||
return;
|
||||
}
|
||||
me.pending_initial_buffer_sync = false;
|
||||
me.open_with_current_buffer(ctx);
|
||||
});
|
||||
|
||||
Self {
|
||||
menu_view,
|
||||
mixer,
|
||||
buffer_model,
|
||||
suggestions_mode_model: input_suggestions_model.clone(),
|
||||
pending_initial_buffer_sync: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select_up(&self, ctx: &mut ViewContext<Self>) {
|
||||
self.inner.update(ctx, |v, ctx| v.select_up(ctx));
|
||||
self.menu_view.update(ctx, |v, ctx| v.select_up(ctx));
|
||||
}
|
||||
|
||||
pub fn select_down(&self, ctx: &mut ViewContext<Self>) {
|
||||
self.inner.update(ctx, |v, ctx| v.select_down(ctx));
|
||||
// Mirror the legacy `InlineHistoryMenuView::select_down` behavior:
|
||||
// pressing Down past the last item (or with no results) closes the
|
||||
// history menu rather than wrapping back to the first item.
|
||||
let should_close = self.menu_view.read(ctx, |v, _| {
|
||||
let result_count = v.result_count();
|
||||
let is_last_item_selected =
|
||||
result_count > 0 && v.selected_idx().is_some_and(|idx| idx == result_count - 1);
|
||||
is_last_item_selected || result_count == 0
|
||||
});
|
||||
if should_close {
|
||||
ctx.emit(InlineHistoryMenuEvent::Close);
|
||||
} else {
|
||||
self.menu_view.update(ctx, |v, ctx| v.select_down(ctx));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn accept_selected(&self, ctx: &mut ViewContext<Self>) {
|
||||
self.inner.update(ctx, |v, ctx| v.accept_selected_item(ctx));
|
||||
self.menu_view
|
||||
.update(ctx, |v, ctx| v.accept_selected_item(false, ctx));
|
||||
}
|
||||
|
||||
pub fn arm_initial_buffer_sync(&mut self, _ctx: &mut ViewContext<Self>) {
|
||||
self.pending_initial_buffer_sync = true;
|
||||
}
|
||||
|
||||
pub fn has_selection(&self, app: &AppContext) -> bool {
|
||||
self.inner
|
||||
self.menu_view
|
||||
.as_ref(app)
|
||||
.model()
|
||||
.as_ref(app)
|
||||
@@ -100,14 +185,35 @@ impl CloudModeV2HistoryMenuView {
|
||||
|
||||
/// Returns the currently selected AI prompt's query text, if any.
|
||||
///
|
||||
/// The cloud-mode V2 menu is restricted to `AcceptHistoryItem::AIPrompt`
|
||||
/// items via its tab filters, so we only ever expect prompt selections.
|
||||
/// The cloud-mode v2 menu is restricted to `AcceptHistoryItem::AIPrompt`
|
||||
/// items via its data source filter, so we only ever expect prompt
|
||||
/// selections; the other arms are unreachable but matched defensively.
|
||||
pub fn selected_query_text(&self, app: &AppContext) -> Option<String> {
|
||||
match self.inner.as_ref(app).model().as_ref(app).selected_item()? {
|
||||
match self
|
||||
.menu_view
|
||||
.as_ref(app)
|
||||
.model()
|
||||
.as_ref(app)
|
||||
.selected_item()?
|
||||
{
|
||||
AcceptHistoryItem::AIPrompt { query_text } => Some(query_text.clone()),
|
||||
AcceptHistoryItem::Command { .. } | AcceptHistoryItem::Conversation { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn open_with_current_buffer(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let text = self.buffer_model.as_ref(ctx).current_value().to_owned();
|
||||
self.mixer.update(ctx, |mixer, ctx| {
|
||||
mixer.run_query(prompts_query(&text), ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn prompts_query(text: &str) -> Query {
|
||||
Query {
|
||||
text: text.to_owned(),
|
||||
filters: HashSet::from([QueryFilter::PromptHistory]),
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for CloudModeV2HistoryMenuView {
|
||||
@@ -120,40 +226,16 @@ impl View for CloudModeV2HistoryMenuView {
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let row_count = self.inner.as_ref(app).result_count(app);
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let border_color = internal_colors::neutral_4(theme);
|
||||
let background = internal_colors::neutral_1(theme);
|
||||
|
||||
let item_height = appearance.monospace_font_size() + 8.;
|
||||
let visible_row_count = row_count.max(1) as f32;
|
||||
let content_height = (item_height * visible_row_count
|
||||
+ 2. * inline_menu_styles::CONTENT_VERTICAL_PADDING)
|
||||
.min(MENU_MAX_HEIGHT);
|
||||
|
||||
let content: Box<dyn Element> = if row_count == 0 {
|
||||
let no_results_text = Text::new(
|
||||
"No results".to_string(),
|
||||
appearance.ui_font_family(),
|
||||
inline_menu_styles::font_size(appearance),
|
||||
)
|
||||
.with_color(
|
||||
theme
|
||||
.disabled_text_color(Fill::Solid(background))
|
||||
.into_solid(),
|
||||
)
|
||||
.finish();
|
||||
Align::new(no_results_text).finish()
|
||||
} else {
|
||||
self.inner.as_ref(app).render_results_only(app)
|
||||
};
|
||||
|
||||
let constrained = ConstrainedBox::new(content)
|
||||
.with_height(content_height)
|
||||
let menu_with_height = ConstrainedBox::new(ChildView::new(&self.menu_view).finish())
|
||||
.with_max_height(MENU_MAX_HEIGHT)
|
||||
.finish();
|
||||
|
||||
let padded = Container::new(constrained)
|
||||
let padded = Container::new(menu_with_height)
|
||||
.with_padding_top(MENU_VERTICAL_PADDING)
|
||||
.with_padding_bottom(MENU_VERTICAL_PADDING)
|
||||
.finish();
|
||||
|
||||
@@ -1,37 +1,30 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
ai::{
|
||||
llms::{is_using_api_key_for_provider, LLMPreferences},
|
||||
AIRequestUsageModel, BuyCreditsBannerDisplayState,
|
||||
},
|
||||
appearance::Appearance,
|
||||
settings::{AISettings, InputSettings},
|
||||
terminal::{
|
||||
buy_credits_banner::BuyCreditsBanner,
|
||||
input::{Input, InputAction, InputSuggestionsMode, MenuPositioning},
|
||||
model::TerminalModel,
|
||||
view::{TerminalAction, PADDING_LEFT},
|
||||
},
|
||||
ui_components::icons::Icon,
|
||||
workspaces::user_workspaces::UserWorkspaces,
|
||||
};
|
||||
use galaxy_completer::completer::Description;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
AnchorPair, Border, ChildAnchor, ConstrainedBox, Container, CornerRadius,
|
||||
CrossAxisAlignment, DispatchEventResult, Element, EventHandler, Flex, OffsetPositioning,
|
||||
OffsetType, ParentAnchor, ParentElement, ParentOffsetBounds, PositionedElementOffsetBounds,
|
||||
PositioningAxis, Radius, Shrinkable, Stack, Text, XAxisAnchor,
|
||||
},
|
||||
fonts::Weight,
|
||||
presenter::ChildView,
|
||||
ui_components::components::{UiComponent, UiComponentStyles},
|
||||
AppContext, EntityId, SingletonEntity, ViewHandle,
|
||||
};
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use vim::vim::{VimMode, VimState};
|
||||
use galaxy_completer::completer::Description;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxyui::elements::{
|
||||
AnchorPair, Border, ChildAnchor, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
|
||||
DispatchEventResult, Element, EventHandler, Flex, OffsetPositioning, OffsetType, ParentAnchor,
|
||||
ParentElement, ParentOffsetBounds, PositionedElementOffsetBounds, PositioningAxis, Radius,
|
||||
Shrinkable, Stack, Text, XAxisAnchor,
|
||||
};
|
||||
use galaxyui::fonts::Weight;
|
||||
use galaxyui::presenter::ChildView;
|
||||
use galaxyui::ui_components::components::{UiComponent, UiComponentStyles};
|
||||
use galaxyui::{AppContext, EntityId, SingletonEntity, ViewHandle};
|
||||
|
||||
use crate::ai::llms::{is_using_api_key_for_provider, LLMPreferences};
|
||||
use crate::ai::{AIRequestUsageModel, BuyCreditsBannerDisplayState};
|
||||
use crate::appearance::Appearance;
|
||||
use crate::settings::{AISettings, InputSettings};
|
||||
use crate::terminal::buy_credits_banner::BuyCreditsBanner;
|
||||
use crate::terminal::input::{Input, InputAction, InputSuggestionsMode, MenuPositioning};
|
||||
use crate::terminal::model::TerminalModel;
|
||||
use crate::terminal::view::{TerminalAction, PADDING_LEFT};
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
|
||||
/// Whether the terminal input message bar should be shown.
|
||||
///
|
||||
@@ -210,7 +203,7 @@ pub(super) fn add_voltron_overlay(
|
||||
);
|
||||
}
|
||||
|
||||
/// Renders the appropriate input suggestions overlay over the input, bsaed on the current input
|
||||
/// Renders the appropriate input suggestions overlay over the input, based on the current input
|
||||
/// suggestions mode (if any).
|
||||
pub(super) fn add_input_suggestions_overlays(
|
||||
input: &Input,
|
||||
@@ -524,7 +517,6 @@ fn add_buy_credits_banner_overlay(
|
||||
buy_credits_banner: &ViewHandle<BuyCreditsBanner>,
|
||||
is_input_at_top: bool,
|
||||
) {
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
|
||||
let (parent_anchor, child_anchor, y_offset) = if is_input_at_top {
|
||||
(ParentAnchor::BottomLeft, ChildAnchor::TopLeft, 8.)
|
||||
|
||||
@@ -3,15 +3,20 @@
|
||||
use galaxyui::{AppContext, Entity, ModelHandle};
|
||||
use itertools::Itertools;
|
||||
use ordered_float::OrderedFloat;
|
||||
use galaxyui::{AppContext, Entity, ModelHandle, SingletonEntity};
|
||||
|
||||
use crate::ai::agent_conversations_model::{
|
||||
AgentConversationEntry, AgentConversationEntryId, AgentManagementFilters,
|
||||
};
|
||||
use crate::ai::blocklist::agent_view::AgentViewController;
|
||||
use crate::ai::conversation_navigation::ConversationNavigationData;
|
||||
use crate::search::data_source::{Query, QueryFilter, QueryResult};
|
||||
use crate::search::mixer::DataSourceRunErrorWrapper;
|
||||
use crate::search::SyncDataSource;
|
||||
use crate::terminal::input::conversations::search_item::ConversationSearchItem;
|
||||
use crate::terminal::input::conversations::AcceptConversation;
|
||||
use crate::terminal::model::session::active_session::ActiveSession;
|
||||
use crate::workspace::RestoreConversationLayout;
|
||||
use crate::AgentConversationsModel;
|
||||
|
||||
pub struct ConversationMenuDataSource {
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
@@ -28,6 +33,16 @@ impl ConversationMenuDataSource {
|
||||
active_session,
|
||||
}
|
||||
}
|
||||
|
||||
fn entries(&self, app: &AppContext) -> Vec<AgentConversationEntry> {
|
||||
AgentConversationsModel::as_ref(app)
|
||||
.get_entries(&AgentManagementFilters::default(), app)
|
||||
.into_iter()
|
||||
.filter(|entry: &AgentConversationEntry| {
|
||||
entry.has_open_action(Some(RestoreConversationLayout::ActivePane), app)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl SyncDataSource for ConversationMenuDataSource {
|
||||
@@ -38,14 +53,14 @@ impl SyncDataSource for ConversationMenuDataSource {
|
||||
query: &Query,
|
||||
app: &AppContext,
|
||||
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
|
||||
let conversation_navigation_data = ConversationNavigationData::all_conversations(app);
|
||||
let conversation_entries = self.entries(app);
|
||||
let query_text = query.text.trim().to_lowercase();
|
||||
|
||||
let active_conversation_id = self
|
||||
let active_item_id = self
|
||||
.agent_view_controller
|
||||
.as_ref(app)
|
||||
.agent_view_state()
|
||||
.active_conversation_id();
|
||||
.active_conversation_id()
|
||||
.map(AgentConversationEntryId::Conversation);
|
||||
|
||||
let filter_by_cwd = query
|
||||
.filters
|
||||
@@ -63,16 +78,17 @@ impl SyncDataSource for ConversationMenuDataSource {
|
||||
// whose most recent directory (falling back to initial directory) matches
|
||||
// the session's current working directory. If we can't determine the
|
||||
// session CWD, leave the results unfiltered.
|
||||
let matches_directory = |data: &ConversationNavigationData| -> bool {
|
||||
let matches_directory = |entry: &AgentConversationEntry| -> bool {
|
||||
if !filter_by_cwd {
|
||||
return true;
|
||||
}
|
||||
let Some(session_pwd) = session_pwd.as_deref() else {
|
||||
return true;
|
||||
};
|
||||
data.latest_working_directory
|
||||
entry
|
||||
.display
|
||||
.working_directory
|
||||
.as_deref()
|
||||
.or(data.initial_working_directory.as_deref())
|
||||
.is_some_and(|dir| {
|
||||
dir.trim_end_matches(std::path::MAIN_SEPARATOR)
|
||||
== session_pwd.trim_end_matches(std::path::MAIN_SEPARATOR)
|
||||
@@ -85,31 +101,31 @@ impl SyncDataSource for ConversationMenuDataSource {
|
||||
|
||||
// In the zero state, sort conversations in the active pane above all other conversations.
|
||||
// Within each segment, sort to reverse chronological order.
|
||||
Ok(conversation_navigation_data
|
||||
Ok(conversation_entries
|
||||
.into_iter()
|
||||
// Don't show the currently open conversation, that's redundant.
|
||||
.filter(|data| Some(data.id()) != active_conversation_id)
|
||||
.filter(|data| matches_directory(data))
|
||||
.sorted_by(|a, b| b.last_updated.cmp(&a.last_updated))
|
||||
.filter(|entry| Some(entry.id) != active_item_id)
|
||||
.filter(|entry| matches_directory(entry))
|
||||
.sorted_by(|a, b| b.display.last_updated.cmp(&a.display.last_updated))
|
||||
.take(DEFAULT_RESULT_COUNT)
|
||||
.map(|navigation_data| {
|
||||
QueryResult::from(ConversationSearchItem::new(navigation_data, app))
|
||||
.map(|conversation_entry| {
|
||||
QueryResult::from(ConversationSearchItem::new(conversation_entry))
|
||||
})
|
||||
.rev()
|
||||
.collect())
|
||||
} else {
|
||||
let mut search_results = conversation_navigation_data
|
||||
let mut search_results = conversation_entries
|
||||
.into_iter()
|
||||
.filter_map(|navigation_data| {
|
||||
if Some(navigation_data.id()) == active_conversation_id {
|
||||
.filter_map(|entry| {
|
||||
if Some(entry.id) == active_item_id {
|
||||
// Don't show the currently open conversation, that's redundant.
|
||||
return None;
|
||||
}
|
||||
if !matches_directory(&navigation_data) {
|
||||
if !matches_directory(&entry) {
|
||||
return None;
|
||||
}
|
||||
let match_result = fuzzy_match::match_indices_case_insensitive(
|
||||
&navigation_data.title,
|
||||
&entry.display.title,
|
||||
&query_text,
|
||||
)?;
|
||||
|
||||
@@ -119,7 +135,7 @@ impl SyncDataSource for ConversationMenuDataSource {
|
||||
}
|
||||
|
||||
Some(QueryResult::from(
|
||||
ConversationSearchItem::new(navigation_data, app)
|
||||
ConversationSearchItem::new(entry)
|
||||
.with_name_match_result(Some(match_result.clone()))
|
||||
.with_score(OrderedFloat(match_result.score as f64)),
|
||||
))
|
||||
|
||||
@@ -4,14 +4,14 @@ mod data_source;
|
||||
mod search_item;
|
||||
mod view;
|
||||
|
||||
pub use view::{InlineConversationMenuEvent, InlineConversationMenuView};
|
||||
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxyui::{keymap::Keystroke, SingletonEntity};
|
||||
use pathfinder_color::ColorU;
|
||||
pub use view::{InlineConversationMenuEvent, InlineConversationMenuView};
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxyui::keymap::Keystroke;
|
||||
use galaxyui::SingletonEntity;
|
||||
|
||||
use crate::ai::active_agent_views_model::{ActiveAgentViewsModel, ConversationOrTaskId};
|
||||
use crate::ai::conversation_navigation::ConversationNavigationData;
|
||||
use crate::ai::agent_conversations_model::AgentConversationEntryId;
|
||||
use crate::terminal::input::inline_menu::{
|
||||
default_navigation_message_items, InlineMenuAction, InlineMenuMessageArgs, InlineMenuRowAction,
|
||||
InlineMenuType,
|
||||
@@ -30,7 +30,7 @@ pub enum InlineConversationMenuTab {
|
||||
/// Action emitted when enter is hit on a conversation the inline conversation menu.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AcceptConversation {
|
||||
pub navigation_data: ConversationNavigationData,
|
||||
pub item_id: AgentConversationEntryId,
|
||||
}
|
||||
|
||||
impl InlineMenuAction for AcceptConversation {
|
||||
@@ -45,11 +45,9 @@ impl InlineMenuAction for AcceptConversation {
|
||||
let mut items = Vec::new();
|
||||
|
||||
if let Some(item) = inline_menu_model.selected_item() {
|
||||
let data = &item.navigation_data;
|
||||
|
||||
let active_ids =
|
||||
ActiveAgentViewsModel::as_ref(app).get_all_active_conversation_ids(app);
|
||||
let is_active = active_ids.contains(&ConversationOrTaskId::ConversationId(data.id));
|
||||
let is_active = active_ids.contains(&ConversationOrTaskId::from(item.item_id));
|
||||
|
||||
let text = if is_active {
|
||||
" go to conversation"
|
||||
@@ -57,7 +55,7 @@ impl InlineMenuAction for AcceptConversation {
|
||||
" continue in this pane"
|
||||
};
|
||||
|
||||
let navigation_data = data.clone();
|
||||
let item_id = item.item_id;
|
||||
items.push(MessageItem::clickable(
|
||||
vec![
|
||||
MessageItem::keystroke(Keystroke {
|
||||
@@ -68,9 +66,7 @@ impl InlineMenuAction for AcceptConversation {
|
||||
],
|
||||
move |ctx| {
|
||||
ctx.dispatch_typed_action(InlineMenuRowAction::Accept {
|
||||
item: AcceptConversation {
|
||||
navigation_data: navigation_data.clone(),
|
||||
},
|
||||
item: AcceptConversation { item_id },
|
||||
cmd_or_ctrl_enter: false,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -12,11 +12,9 @@ use galaxyui::text_layout::ClipConfig;
|
||||
use galaxyui::{AppContext, Element, SingletonEntity};
|
||||
use ordered_float::OrderedFloat;
|
||||
|
||||
use crate::ai::active_agent_views_model::{ActiveAgentViewsModel, ConversationOrTaskId};
|
||||
use crate::ai::agent::conversation::ConversationStatus;
|
||||
use crate::ai::blocklist::BlocklistAIHistoryModel;
|
||||
use crate::ai::conversation_navigation::ConversationNavigationData;
|
||||
use crate::ai::conversation_status_ui::{render_status_element, STATUS_ELEMENT_PADDING};
|
||||
use crate::ai::active_agent_views_model::ActiveAgentViewsModel;
|
||||
use crate::ai::agent_conversations_model::AgentConversationEntry;
|
||||
use crate::ai::conversation_status_ui::render_status_element;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::search::{ItemHighlightState, SearchItem};
|
||||
use crate::terminal::input::conversations::AcceptConversation;
|
||||
@@ -26,24 +24,17 @@ use crate::util::time_format::format_approx_duration_from_now_utc;
|
||||
/// Search item for rendering a conversation in the inline conversation menu.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct ConversationSearchItem {
|
||||
navigation_data: ConversationNavigationData,
|
||||
entry: AgentConversationEntry,
|
||||
name_match_result: Option<FuzzyMatchResult>,
|
||||
score: OrderedFloat<f64>,
|
||||
conversation_status: Option<ConversationStatus>,
|
||||
}
|
||||
|
||||
impl ConversationSearchItem {
|
||||
pub fn new(navigation_data: ConversationNavigationData, app: &AppContext) -> Self {
|
||||
let history_model = BlocklistAIHistoryModel::as_ref(app);
|
||||
let conversation_status = history_model
|
||||
.conversation(&navigation_data.id)
|
||||
.map(|conversation| conversation.status().clone());
|
||||
|
||||
pub fn new(entry: AgentConversationEntry) -> Self {
|
||||
Self {
|
||||
navigation_data,
|
||||
entry,
|
||||
name_match_result: None,
|
||||
score: OrderedFloat(f64::MIN),
|
||||
conversation_status,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,26 +58,7 @@ impl SearchItem for ConversationSearchItem {
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let icon_size = inline_styles::font_size(appearance);
|
||||
let icon = match &self.conversation_status {
|
||||
Some(conversation_status) => {
|
||||
render_status_element(conversation_status, icon_size, appearance)
|
||||
}
|
||||
None => {
|
||||
let icon_color = appearance
|
||||
.theme()
|
||||
.sub_text_color(appearance.theme().background());
|
||||
Container::new(
|
||||
ConstrainedBox::new(Icon::History.to_galaxyui_icon(icon_color).finish())
|
||||
.with_width(icon_size)
|
||||
.with_height(icon_size)
|
||||
.finish(),
|
||||
)
|
||||
.with_uniform_padding(STATUS_ELEMENT_PADDING)
|
||||
.with_background(coloru_with_opacity(icon_color.into(), 10))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||
.finish()
|
||||
}
|
||||
};
|
||||
let icon = render_status_element(&self.entry.display.status, icon_size, appearance);
|
||||
|
||||
Container::new(icon)
|
||||
.with_margin_right(inline_styles::ICON_MARGIN)
|
||||
@@ -107,15 +79,18 @@ impl SearchItem for ConversationSearchItem {
|
||||
let primary_text_color = inline_styles::primary_text_color(theme, background_color.into());
|
||||
let secondary_text_color = theme.disabled_text_color(background_color.into());
|
||||
|
||||
let open_conversation_ids =
|
||||
ActiveAgentViewsModel::as_ref(app).get_all_open_conversation_ids(app);
|
||||
let is_active = open_conversation_ids.contains(&ConversationOrTaskId::ConversationId(
|
||||
self.navigation_data.id,
|
||||
));
|
||||
let active_agent_views = ActiveAgentViewsModel::as_ref(app);
|
||||
let open_terminal_view_id =
|
||||
active_agent_views.get_terminal_view_id_for_entry(&self.entry, app);
|
||||
let focused_terminal_view_id = app
|
||||
.windows()
|
||||
.active_window()
|
||||
.and_then(|window_id| active_agent_views.get_focused_terminal_view_id(window_id));
|
||||
|
||||
let secondary_suffix = " open in different pane";
|
||||
let title = &self.navigation_data.title;
|
||||
let should_show_suffix = is_active && !self.navigation_data.is_in_active_pane;
|
||||
let title = &self.entry.display.title;
|
||||
let should_show_suffix = open_terminal_view_id
|
||||
.is_some_and(|terminal_view_id| Some(terminal_view_id) != focused_terminal_view_id);
|
||||
let full_text = if should_show_suffix {
|
||||
format!("{title}{secondary_suffix}")
|
||||
} else {
|
||||
@@ -157,7 +132,7 @@ impl SearchItem for ConversationSearchItem {
|
||||
// We want the timestamp 'column' to have fixed width so clipping is consistent,
|
||||
// limit the timestamp width to about 10 chars.
|
||||
let timestamp = Text::new_inline(
|
||||
format_approx_duration_from_now_utc(self.navigation_data.last_updated.to_utc()),
|
||||
format_approx_duration_from_now_utc(self.entry.display.last_updated),
|
||||
appearance.ui_font_family(),
|
||||
font_size,
|
||||
)
|
||||
@@ -191,7 +166,7 @@ impl SearchItem for ConversationSearchItem {
|
||||
|
||||
fn accept_result(&self) -> Self::Action {
|
||||
AcceptConversation {
|
||||
navigation_data: self.navigation_data.clone(),
|
||||
item_id: self.entry.id,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,6 +175,6 @@ impl SearchItem for ConversationSearchItem {
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
format!("Conversation: {}", self.navigation_data.title)
|
||||
format!("Conversation: {}", self.entry.display.title)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ use galaxyui::elements::ChildView;
|
||||
use galaxyui::{Element, Entity, ModelHandle, SingletonEntity, View, ViewContext, ViewHandle};
|
||||
|
||||
use crate::ai::active_agent_views_model::ActiveAgentViewsModel;
|
||||
use crate::ai::agent_conversations_model::AgentConversationEntryId;
|
||||
use crate::ai::blocklist::agent_view::AgentViewController;
|
||||
use crate::ai::conversation_navigation::ConversationNavigationData;
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::search::data_source::{Query, QueryFilter};
|
||||
use crate::search::mixer::SearchMixer;
|
||||
@@ -27,9 +27,7 @@ use crate::terminal::model::session::active_session::ActiveSession;
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum InlineConversationMenuEvent {
|
||||
/// User 'accepted' a conversation (hit enter).
|
||||
NavigateToConversation {
|
||||
conversation_navigation_data: Box<ConversationNavigationData>,
|
||||
},
|
||||
NavigateToConversation { item_id: AgentConversationEntryId },
|
||||
/// User dismissed the menu (escape or click).
|
||||
Dismissed,
|
||||
}
|
||||
@@ -104,7 +102,7 @@ impl InlineConversationMenuView {
|
||||
ctx.subscribe_to_view(&menu_view, |me, _, event, ctx| match event {
|
||||
InlineMenuEvent::AcceptedItem { item, .. } => {
|
||||
ctx.emit(InlineConversationMenuEvent::NavigateToConversation {
|
||||
conversation_navigation_data: Box::new(item.navigation_data.clone()),
|
||||
item_id: item.item_id,
|
||||
});
|
||||
}
|
||||
InlineMenuEvent::SelectedItem { .. } | InlineMenuEvent::NoResults => (),
|
||||
|
||||
@@ -1,27 +1,23 @@
|
||||
//! Warp input editor logic related to decorating the input's text, such as
|
||||
//! applying syntax highlighting and error underlining.
|
||||
|
||||
use std::{collections::HashMap, ops::Range};
|
||||
use std::collections::HashMap;
|
||||
use std::ops::Range;
|
||||
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxyui::{AppContext, SingletonEntity, ViewContext};
|
||||
use settings::Setting as _;
|
||||
use string_offset::{ByteOffset, CharOffset};
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
completer::{EmptyCompletionContext, SessionContext},
|
||||
editor::TextStyleOperation,
|
||||
settings::InputSettings,
|
||||
themes::theme::{AnsiColorIdentifier, AnsiColors},
|
||||
};
|
||||
pub use galaxy_completer::completer::SuggestionTypeName;
|
||||
pub use galaxy_completer::util::parse_current_commands_and_tokens;
|
||||
pub use galaxy_completer::{ParsedTokenData, ParsedTokensSnapshot};
|
||||
|
||||
use super::Input;
|
||||
|
||||
pub use galaxy_completer::{
|
||||
completer::SuggestionTypeName, util::parse_current_commands_and_tokens, ParsedTokenData,
|
||||
ParsedTokensSnapshot,
|
||||
};
|
||||
use crate::appearance::Appearance;
|
||||
use crate::completer::{EmptyCompletionContext, SessionContext};
|
||||
use crate::editor::TextStyleOperation;
|
||||
use crate::settings::InputSettings;
|
||||
use crate::themes::theme::{AnsiColorIdentifier, AnsiColors};
|
||||
|
||||
/// Options to enable/disable command decoration and/or AI input background tasks spawned on input
|
||||
/// edits.
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
use galaxyui::{text_layout::TextStyle, App};
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
terminal::{
|
||||
input::{
|
||||
decorations::InputBackgroundJobOptions,
|
||||
tests::{
|
||||
add_window_with_bootstrapped_terminal, initialize_app,
|
||||
simulate_directory_for_completion,
|
||||
},
|
||||
},
|
||||
model::session::SessionInfo,
|
||||
},
|
||||
themes::theme::AnsiColorIdentifier,
|
||||
};
|
||||
use galaxy_completer::completer::SuggestionTypeName;
|
||||
use galaxyui::text_layout::TextStyle;
|
||||
use galaxyui::App;
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::terminal::input::decorations::InputBackgroundJobOptions;
|
||||
use crate::terminal::input::tests::{
|
||||
add_window_with_bootstrapped_terminal, initialize_app, simulate_directory_for_completion,
|
||||
};
|
||||
use crate::terminal::model::session::SessionInfo;
|
||||
use crate::themes::theme::AnsiColorIdentifier;
|
||||
|
||||
#[test]
|
||||
fn test_decorations_with_multibyte_chars() {
|
||||
@@ -69,7 +63,7 @@ fn test_decorations_with_multibyte_chars() {
|
||||
let future_handle = input
|
||||
.decorations_future_handle
|
||||
.take()
|
||||
.expect("should have spanwed decoration task");
|
||||
.expect("should have spawned decoration task");
|
||||
ctx.await_spawned_future(future_handle.future_id())
|
||||
})
|
||||
.await;
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
//! Tracks the `&` prefix mode drafting state in the local input while the user
|
||||
//! writes a cloud handoff prompt, before a cloud pane/model exists.
|
||||
|
||||
use warpui::{Entity, ModelContext};
|
||||
|
||||
use crate::ai::ambient_agents::telemetry::HandoffEntryPoint;
|
||||
use crate::server::ids::SyncId;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum HandoffComposeStateEvent {
|
||||
ActiveChanged,
|
||||
EnvironmentSelected,
|
||||
}
|
||||
|
||||
/// Transient state owned by the local input while drafting a cloud handoff
|
||||
/// prompt (the `&` prefix mode), before a cloud pane exists.
|
||||
#[derive(Default)]
|
||||
pub struct HandoffComposeState {
|
||||
active: bool,
|
||||
selected_environment_id: Option<SyncId>,
|
||||
has_explicit_environment_selection: bool,
|
||||
entry_point: HandoffEntryPoint,
|
||||
}
|
||||
|
||||
impl HandoffComposeState {
|
||||
pub(crate) fn is_active(&self) -> bool {
|
||||
self.active
|
||||
}
|
||||
|
||||
pub(crate) fn activate(
|
||||
&mut self,
|
||||
entry_point: HandoffEntryPoint,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.active = true;
|
||||
self.has_explicit_environment_selection = false;
|
||||
self.entry_point = entry_point;
|
||||
ctx.emit(HandoffComposeStateEvent::ActiveChanged);
|
||||
}
|
||||
|
||||
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
||||
pub(crate) fn entry_point(&self) -> HandoffEntryPoint {
|
||||
self.entry_point
|
||||
}
|
||||
|
||||
pub(crate) fn exit(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
if !self.active && !self.has_explicit_environment_selection {
|
||||
return;
|
||||
}
|
||||
|
||||
self.active = false;
|
||||
self.has_explicit_environment_selection = false;
|
||||
ctx.emit(HandoffComposeStateEvent::ActiveChanged);
|
||||
}
|
||||
|
||||
pub(crate) fn selected_environment_id(&self) -> Option<&SyncId> {
|
||||
self.selected_environment_id.as_ref()
|
||||
}
|
||||
|
||||
pub(crate) fn set_environment_id(
|
||||
&mut self,
|
||||
environment_id: Option<SyncId>,
|
||||
is_explicit: bool,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
// Async/implicit updates (e.g. pwd-based overlap resolution) must not
|
||||
// overwrite an environment the user already picked explicitly.
|
||||
if !is_explicit && self.has_explicit_environment_selection {
|
||||
return;
|
||||
}
|
||||
|
||||
// No-op when the value is unchanged, unless this is the first explicit
|
||||
// selection (which needs to promote `has_explicit_environment_selection`).
|
||||
if self.selected_environment_id == environment_id
|
||||
&& (!is_explicit || self.has_explicit_environment_selection)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
self.selected_environment_id = environment_id;
|
||||
if is_explicit {
|
||||
self.has_explicit_environment_selection = true;
|
||||
}
|
||||
ctx.emit(HandoffComposeStateEvent::EnvironmentSelected);
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_default_environment_id(
|
||||
&mut self,
|
||||
environment_id: SyncId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if self.selected_environment_id.is_none() {
|
||||
self.set_environment_id(Some(environment_id), false, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for HandoffComposeState {
|
||||
type Event = HandoffComposeStateEvent;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "handoff_compose_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,37 @@
|
||||
use warpui::App;
|
||||
|
||||
use super::HandoffComposeState;
|
||||
use crate::ai::ambient_agents::telemetry::HandoffEntryPoint;
|
||||
use crate::server::ids::{ClientId, SyncId};
|
||||
|
||||
#[test]
|
||||
fn preserves_explicit_environment_selection() {
|
||||
App::test((), |mut app| async move {
|
||||
let state = app.add_model(|_| HandoffComposeState::default());
|
||||
let default_environment_id = SyncId::ClientId(ClientId::new());
|
||||
let explicit_environment_id = SyncId::ClientId(ClientId::new());
|
||||
|
||||
state.update(&mut app, |state, ctx| {
|
||||
state.activate(HandoffEntryPoint::Ampersand, ctx);
|
||||
state.ensure_default_environment_id(default_environment_id, ctx);
|
||||
});
|
||||
state.read(&app, |state, _| {
|
||||
assert_eq!(
|
||||
state.selected_environment_id(),
|
||||
Some(&default_environment_id)
|
||||
);
|
||||
});
|
||||
|
||||
// Explicit selection should stick even when ensure_default tries to overwrite.
|
||||
state.update(&mut app, |state, ctx| {
|
||||
state.set_environment_id(Some(explicit_environment_id), true, ctx);
|
||||
state.ensure_default_environment_id(default_environment_id, ctx);
|
||||
});
|
||||
state.read(&app, |state, _| {
|
||||
assert_eq!(
|
||||
state.selected_environment_id(),
|
||||
Some(&explicit_environment_id)
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -6,6 +6,11 @@
|
||||
//! - Commands are deduplicated, keeping the most recent occurrence
|
||||
//! - The result is that current session items appear at the bottom (closer to input)
|
||||
|
||||
use chrono::{DateTime, Local};
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use ordered_float::OrderedFloat;
|
||||
use warpui::{AppContext, Entity, EntityId, ModelHandle, SingletonEntity};
|
||||
|
||||
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus};
|
||||
use crate::ai::blocklist::agent_view::AgentViewController;
|
||||
use crate::ai::blocklist::BlocklistAIHistoryModel;
|
||||
@@ -13,18 +18,13 @@ use crate::input_suggestions::{HistoryInputSuggestion, HistoryOrder};
|
||||
use crate::search::data_source::{Query, QueryFilter, QueryResult};
|
||||
use crate::search::mixer::DataSourceRunErrorWrapper;
|
||||
use crate::search::SyncDataSource;
|
||||
use crate::terminal::history::UpArrowHistoryConfig;
|
||||
use crate::terminal::history::{History, LinkedWorkflowData};
|
||||
use crate::terminal::history::{History, LinkedWorkflowData, UpArrowHistoryConfig};
|
||||
use crate::terminal::input::inline_history::search_item::InlineHistoryItem;
|
||||
use crate::terminal::input::inline_menu::{
|
||||
InlineMenuAction, InlineMenuClickBehavior, InlineMenuType,
|
||||
};
|
||||
use crate::terminal::model::session::active_session::ActiveSession;
|
||||
use crate::terminal::model::session::SessionId;
|
||||
use chrono::{DateTime, Local};
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use galaxyui::{AppContext, Entity, EntityId, ModelHandle, SingletonEntity};
|
||||
use ordered_float::OrderedFloat;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum AcceptHistoryItem {
|
||||
@@ -155,7 +155,7 @@ impl InlineHistoryMenuDataSource {
|
||||
let mut conversation_entries: Vec<MenuEntry> = Vec::new();
|
||||
let history_model = BlocklistAIHistoryModel::handle(app).as_ref(app);
|
||||
for conversation in
|
||||
history_model.all_live_conversations_for_terminal_view(self.terminal_view_id)
|
||||
history_model.all_live_conversations_for_terminal_surface(self.terminal_view_id)
|
||||
{
|
||||
if conversation.is_entirely_passive() || conversation.exchange_count() == 0 {
|
||||
continue;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
use chrono::{Local, TimeZone as _};
|
||||
|
||||
use crate::input_suggestions::HistoryOrder;
|
||||
|
||||
use super::{interleave_conversations, MenuEntry, MenuItem};
|
||||
use crate::input_suggestions::HistoryOrder;
|
||||
|
||||
#[test]
|
||||
fn interleave_conversations_only_inserts_into_current_session_segment() {
|
||||
|
||||
@@ -6,5 +6,5 @@ mod data_source;
|
||||
mod search_item;
|
||||
mod view;
|
||||
|
||||
pub use data_source::AcceptHistoryItem;
|
||||
pub use data_source::{AcceptHistoryItem, InlineHistoryMenuDataSource};
|
||||
pub use view::{HistoryTab, InlineHistoryMenuEvent, InlineHistoryMenuView};
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus};
|
||||
use crate::ai::conversation_status_ui::{render_status_element, STATUS_ELEMENT_PADDING};
|
||||
use crate::appearance::Appearance;
|
||||
use crate::search::{ItemHighlightState, SearchItem};
|
||||
use crate::terminal::history::LinkedWorkflowData;
|
||||
use crate::terminal::input::inline_history::data_source::AcceptHistoryItem;
|
||||
use crate::terminal::input::inline_menu::styles as inline_styles;
|
||||
use crate::util::time_format::format_approx_duration_from_now_utc;
|
||||
use chrono::{DateTime, Local};
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use galaxy_core::ui::color::coloru_with_opacity;
|
||||
@@ -19,6 +11,15 @@ use galaxyui::text_layout::ClipConfig;
|
||||
use galaxyui::{AppContext, Element, SingletonEntity};
|
||||
use ordered_float::OrderedFloat;
|
||||
|
||||
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus};
|
||||
use crate::ai::conversation_status_ui::{render_status_element, STATUS_ELEMENT_PADDING};
|
||||
use crate::appearance::Appearance;
|
||||
use crate::search::{ItemHighlightState, SearchItem};
|
||||
use crate::terminal::history::LinkedWorkflowData;
|
||||
use crate::terminal::input::inline_history::data_source::AcceptHistoryItem;
|
||||
use crate::terminal::input::inline_menu::styles as inline_styles;
|
||||
use crate::util::time_format::format_approx_duration_from_now_utc;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InlineHistoryItem {
|
||||
item_type: HistoryItemType,
|
||||
|
||||
@@ -15,7 +15,7 @@ use crate::search::data_source::{Query, QueryFilter};
|
||||
use crate::search::mixer::{SearchMixer, SearchMixerEvent};
|
||||
use crate::settings_view::SettingsSection;
|
||||
use crate::terminal::history::LinkedWorkflowData;
|
||||
use crate::terminal::input::buffer_model::InputBufferModel;
|
||||
use crate::terminal::input::buffer_model::{InputBufferModel, InputBufferUpdateEvent};
|
||||
use crate::terminal::input::inline_history::data_source::{
|
||||
AcceptHistoryItem, InlineHistoryMenuDataSource,
|
||||
};
|
||||
@@ -162,6 +162,7 @@ pub struct InlineHistoryMenuView {
|
||||
buffer_model: ModelHandle<InputBufferModel>,
|
||||
pending_tab_switch_selection: Option<HistoryItemIdentity>,
|
||||
caller_supplied_tabs: bool,
|
||||
pending_initial_buffer_sync: bool,
|
||||
}
|
||||
|
||||
impl InlineHistoryMenuView {
|
||||
@@ -311,6 +312,24 @@ impl InlineHistoryMenuView {
|
||||
}
|
||||
});
|
||||
|
||||
let suggestions_mode_model_for_buffer = input_suggestions_model.clone();
|
||||
ctx.subscribe_to_model(
|
||||
&buffer_model,
|
||||
move |me, _, _: &InputBufferUpdateEvent, ctx| {
|
||||
if !suggestions_mode_model_for_buffer
|
||||
.as_ref(ctx)
|
||||
.is_inline_history_menu()
|
||||
{
|
||||
return;
|
||||
}
|
||||
if !me.pending_initial_buffer_sync {
|
||||
return;
|
||||
}
|
||||
me.pending_initial_buffer_sync = false;
|
||||
me.open_with_current_buffer(ctx);
|
||||
},
|
||||
);
|
||||
|
||||
let suggestions_mode_model = input_suggestions_model.clone();
|
||||
ctx.subscribe_to_model(
|
||||
&agent_view_controller,
|
||||
@@ -425,6 +444,7 @@ impl InlineHistoryMenuView {
|
||||
buffer_model,
|
||||
pending_tab_switch_selection: None,
|
||||
caller_supplied_tabs,
|
||||
pending_initial_buffer_sync: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -472,6 +492,10 @@ impl InlineHistoryMenuView {
|
||||
.update(ctx, |v, ctx| v.accept_selected_item(false, ctx));
|
||||
}
|
||||
|
||||
pub fn arm_initial_buffer_sync(&mut self) {
|
||||
self.pending_initial_buffer_sync = true;
|
||||
}
|
||||
|
||||
fn open_with_current_buffer(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let query_text = self.buffer_model.as_ref(ctx).current_value().to_owned();
|
||||
let filters = self.model.as_ref(ctx).active_tab_filters();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use galaxyui::{keymap::Keystroke, AppContext};
|
||||
use galaxyui::keymap::Keystroke;
|
||||
use galaxyui::AppContext;
|
||||
|
||||
use crate::editor::{SELECT_DOWN_ACTION_NAME, SELECT_UP_ACTION_NAME};
|
||||
use crate::terminal::input::inline_menu::{
|
||||
|
||||
@@ -6,18 +6,18 @@ pub(crate) mod positioning;
|
||||
pub mod styles;
|
||||
mod view;
|
||||
|
||||
use super::{InputSuggestionsMode, UserQueryMenuAction};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use message_bar::{InlineMenuMessageArgs, InlineMenuMessageBarArgs};
|
||||
pub use message_provider::{default_navigation_message_items, InlineMenuMessageProvider};
|
||||
pub use model::{InlineMenuModel, InlineMenuModelEvent, InlineMenuTabConfig};
|
||||
pub use positioning::InlineMenuPositioner;
|
||||
use serde::{Deserialize, Serialize};
|
||||
pub use view::{
|
||||
DetailsRenderConfig, InlineMenuAction, InlineMenuClickBehavior, InlineMenuEvent,
|
||||
InlineMenuHeaderConfig, InlineMenuRowAction, InlineMenuView,
|
||||
InlineMenuHeaderConfig, InlineMenuRowAction, InlineMenuView, QueryResultRendererExt,
|
||||
};
|
||||
|
||||
use super::{InputSuggestionsMode, UserQueryMenuAction};
|
||||
|
||||
/// Identifies a specific inline menu type.
|
||||
#[derive(
|
||||
Debug,
|
||||
|
||||
@@ -3,6 +3,7 @@ use galaxyui::elements::MouseStateHandle;
|
||||
use galaxyui::{Entity, ModelContext};
|
||||
use std::collections::HashSet;
|
||||
|
||||
|
||||
use crate::search::data_source::QueryFilter;
|
||||
use crate::terminal::input::inline_menu::view::InlineMenuAction;
|
||||
|
||||
|
||||
@@ -1,32 +1,22 @@
|
||||
use super::styles::{HEADER_BORDER, HEADER_ROW_HEIGHT};
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxyui::{
|
||||
units::{IntoPixels, Pixels},
|
||||
AppContext, Entity, ModelContext, ModelHandle, SingletonEntity, WindowId,
|
||||
};
|
||||
use settings::Setting as _;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::settings::InputSettings;
|
||||
use crate::terminal::input::{
|
||||
inline_menu::{
|
||||
message_bar::INLINE_MENU_BORDER_WIDTH,
|
||||
styles::{CONTENT_BORDER_WIDTH, CONTENT_VERTICAL_PADDING},
|
||||
view::QUERY_RESULT_RENDERER_STYLES,
|
||||
InlineMenuType,
|
||||
},
|
||||
message_bar::common::standard_message_bar_height,
|
||||
};
|
||||
use settings::Setting as _;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxyui::units::{IntoPixels, Pixels};
|
||||
use galaxyui::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity, WindowId};
|
||||
|
||||
use crate::{
|
||||
ai::blocklist::agent_view::AgentViewController,
|
||||
appearance::Appearance,
|
||||
settings::InputModeSettings,
|
||||
terminal::{
|
||||
block_list_viewport::InputMode, element_size_at_last_frame,
|
||||
input::suggestions_mode_model::InputSuggestionsModeModel, SizeInfo,
|
||||
},
|
||||
};
|
||||
use super::styles::{HEADER_BORDER, HEADER_ROW_HEIGHT};
|
||||
use crate::ai::blocklist::agent_view::AgentViewController;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::settings::{InputModeSettings, InputSettings};
|
||||
use crate::terminal::block_list_viewport::InputMode;
|
||||
use crate::terminal::input::inline_menu::message_bar::INLINE_MENU_BORDER_WIDTH;
|
||||
use crate::terminal::input::inline_menu::styles::{CONTENT_BORDER_WIDTH, CONTENT_VERTICAL_PADDING};
|
||||
use crate::terminal::input::inline_menu::view::QUERY_RESULT_RENDERER_STYLES;
|
||||
use crate::terminal::input::inline_menu::InlineMenuType;
|
||||
use crate::terminal::input::message_bar::common::standard_message_bar_height;
|
||||
use crate::terminal::input::suggestions_mode_model::InputSuggestionsModeModel;
|
||||
use crate::terminal::{element_size_at_last_frame, SizeInfo};
|
||||
|
||||
const DEFAULT_VISIBLE_RESULT_COUNT: f32 = 9.;
|
||||
const MIN_VISIBLE_RESULT_COUNT: f32 = 3.;
|
||||
@@ -75,7 +65,7 @@ impl InlineMenuPositioner {
|
||||
.inline_menu_custom_content_heights
|
||||
.value()
|
||||
.clone();
|
||||
ctx.subscribe_to_model(suggestions_mode_model, |me, _, ctx| {
|
||||
ctx.subscribe_to_model(suggestions_mode_model, |me, _, _, ctx| {
|
||||
let suggestions_mode_model = me.suggestions_mode_model.as_ref(ctx);
|
||||
if suggestions_mode_model.is_inline_menu_open() {
|
||||
if me.agent_view_controller.as_ref(ctx).is_active() {
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
//! Generic inline menu view for rendering search results with selection and navigation.
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use itertools::Itertools;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_core::ui::color::blend::Blend;
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
use galaxy_core::ui::Icon;
|
||||
use galaxyui::color::ColorU;
|
||||
use galaxyui::elements::drag_resize::drag_resize_handle;
|
||||
use galaxyui::elements::{
|
||||
drag_resize::drag_resize_handle, ChildAnchor, Clipped, DispatchEventResult, DragResizeElement,
|
||||
DragResizeHandle, EventHandler, Expanded, Hoverable, MainAxisAlignment, MainAxisSize,
|
||||
MouseInBehavior, MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement,
|
||||
ParentOffsetBounds, ResizeEndFn, Scrollable, ScrollableElement, ScrollbarWidth,
|
||||
SizeConstraintCondition, SizeConstraintSwitch, Stack, UniformList, UniformListState,
|
||||
ChildAnchor, Clipped, DispatchEventResult, DragResizeElement, DragResizeHandle, EventHandler,
|
||||
Expanded, Hoverable, MainAxisAlignment, MainAxisSize, MouseInBehavior, MouseStateHandle,
|
||||
OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, ResizeEndFn,
|
||||
ScrollStateHandle, Scrollable, ScrollableElement, ScrollbarWidth, SizeConstraintCondition,
|
||||
SizeConstraintSwitch, Stack, UniformList, UniformListState,
|
||||
};
|
||||
use galaxyui::fonts::Weight;
|
||||
use galaxyui::platform::Cursor;
|
||||
@@ -23,13 +26,10 @@ use galaxyui::prelude::{
|
||||
use galaxyui::scene::{Border, CornerRadius, Radius};
|
||||
use galaxyui::ui_components::button::ButtonVariant;
|
||||
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
|
||||
use galaxyui::{elements::ScrollStateHandle, ModelHandle, View};
|
||||
use galaxyui::{
|
||||
Action, AppContext, Element, Entity, SingletonEntity, TypedActionView, ViewContext, ViewHandle,
|
||||
WeakViewHandle,
|
||||
Action, AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View,
|
||||
ViewContext, ViewHandle, WeakViewHandle,
|
||||
};
|
||||
use itertools::Itertools;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
|
||||
use crate::ai::blocklist::agent_view::{
|
||||
agent_view_bg_color, AgentViewController, AgentViewControllerEvent,
|
||||
@@ -43,10 +43,10 @@ use crate::terminal::input::inline_menu::message_bar::{
|
||||
InlineMenuMessageBar, InlineMenuMessageBarArgs,
|
||||
};
|
||||
use crate::terminal::input::inline_menu::model::{InlineMenuModel, InlineMenuTabConfig};
|
||||
use crate::terminal::input::inline_menu::styles as inline_styles;
|
||||
use crate::terminal::input::inline_menu::positioning::Updated as PositionerUpdated;
|
||||
use crate::terminal::input::inline_menu::{
|
||||
default_navigation_message_items, positioning::Updated as PositionerUpdated,
|
||||
InlineMenuMessageArgs, InlineMenuPositioner, InlineMenuType,
|
||||
default_navigation_message_items, styles as inline_styles, InlineMenuMessageArgs,
|
||||
InlineMenuPositioner, InlineMenuType,
|
||||
};
|
||||
use crate::terminal::input::message_bar::Message;
|
||||
use crate::terminal::input::suggestions_mode_model::{
|
||||
@@ -110,15 +110,30 @@ pub(super) static QUERY_RESULT_RENDERER_STYLES: LazyLock<QueryResultRendererStyl
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
impl<A: InlineMenuAction> QueryResultRenderer<A> {
|
||||
pub fn render_inline(
|
||||
pub trait QueryResultRendererExt {
|
||||
fn render_inline(
|
||||
&self,
|
||||
result_index: usize,
|
||||
is_selected: bool,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element>;
|
||||
|
||||
fn render_inline_with_highlight_state(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
is_static_separator: bool,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element>;
|
||||
}
|
||||
|
||||
impl<A: InlineMenuAction> QueryResultRendererExt for QueryResultRenderer<A> {
|
||||
fn render_inline(
|
||||
&self,
|
||||
result_index: usize,
|
||||
is_selected: bool,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
use galaxyui::elements::{DispatchEventResult, EventHandler, Hoverable};
|
||||
use galaxyui::platform::Cursor;
|
||||
|
||||
if self.search_result.is_static_separator() {
|
||||
return self.render_inline_with_highlight_state(ItemHighlightState::Default, true, app);
|
||||
@@ -310,6 +325,8 @@ pub struct InlineMenuView<A: InlineMenuAction, T: 'static + Send + Sync = ()> {
|
||||
banner_fn: Option<BannerFn>,
|
||||
resize_handle: DragResizeHandle,
|
||||
drag_indicator_mouse_state: MouseStateHandle,
|
||||
compact_layout: bool,
|
||||
dismiss_on_row_click: bool,
|
||||
}
|
||||
|
||||
impl<A: InlineMenuAction> InlineMenuView<A> {
|
||||
@@ -423,6 +440,7 @@ impl<A: InlineMenuAction, T: 'static + Send + Sync> InlineMenuView<A, T> {
|
||||
|
||||
let results = me.mixer.as_ref(ctx).results();
|
||||
|
||||
let dismiss_on_row_click = me.dismiss_on_row_click;
|
||||
me.result_renderers = results
|
||||
.clone()
|
||||
.into_iter()
|
||||
@@ -444,6 +462,9 @@ impl<A: InlineMenuAction, T: 'static + Send + Sync> InlineMenuView<A, T> {
|
||||
}
|
||||
};
|
||||
ctx.dispatch_typed_action(action);
|
||||
if dismiss_on_row_click {
|
||||
ctx.dispatch_typed_action(InlineMenuRowAction::<A>::Dismiss);
|
||||
}
|
||||
},
|
||||
*QUERY_RESULT_RENDERER_STYLES,
|
||||
)
|
||||
@@ -499,6 +520,8 @@ impl<A: InlineMenuAction, T: 'static + Send + Sync> InlineMenuView<A, T> {
|
||||
banner_fn: None,
|
||||
resize_handle: drag_resize_handle(),
|
||||
drag_indicator_mouse_state: MouseStateHandle::default(),
|
||||
compact_layout: false,
|
||||
dismiss_on_row_click: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,6 +530,16 @@ impl<A: InlineMenuAction, T: 'static + Send + Sync> InlineMenuView<A, T> {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_compact_layout(mut self) -> Self {
|
||||
self.compact_layout = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_dismiss_on_row_click(mut self) -> Self {
|
||||
self.dismiss_on_row_click = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_banner_fn(
|
||||
mut self,
|
||||
banner_fn: impl Fn(&AppContext) -> Option<Box<dyn Element>> + 'static,
|
||||
@@ -847,7 +880,8 @@ impl<A: InlineMenuAction, T: 'static + Send + Sync> InlineMenuView<A, T> {
|
||||
)
|
||||
.finish();
|
||||
|
||||
Some(header)
|
||||
// Clip so trailing controls don't paint past the pane in a narrow split pane.
|
||||
Some(Clipped::new(header).finish())
|
||||
}
|
||||
|
||||
pub fn render_results_only(
|
||||
@@ -930,8 +964,11 @@ impl<A: InlineMenuAction, T: 'static + Send + Sync> InlineMenuView<A, T> {
|
||||
.positioner
|
||||
.as_ref(app)
|
||||
.should_render_results_in_reverse(app);
|
||||
let horizontal_padding =
|
||||
*terminal::view::PADDING_LEFT - QUERY_RESULT_RENDERER_STYLES.result_horizontal_padding;
|
||||
let horizontal_padding = if self.compact_layout {
|
||||
0.
|
||||
} else {
|
||||
*terminal::view::PADDING_LEFT - QUERY_RESULT_RENDERER_STYLES.result_horizontal_padding
|
||||
};
|
||||
let results = self.render_results_only(should_reverse, horizontal_padding, app);
|
||||
|
||||
if let Some(banner) = self.banner_fn.as_ref().and_then(|f| f(app)) {
|
||||
@@ -1101,6 +1138,10 @@ impl<A: InlineMenuAction, T: 'static + Send + Sync> View for InlineMenuView<A, T
|
||||
}
|
||||
}
|
||||
|
||||
if self.compact_layout {
|
||||
return Clipped::new(content).finish();
|
||||
}
|
||||
|
||||
let aligned_content = if is_rendering_below_input {
|
||||
content
|
||||
} else {
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
use crate::ai::blocklist::agent_view::agent_view_bg_color;
|
||||
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
|
||||
use pathfinder_color::ColorU;
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
use galaxy_core::ui::Icon;
|
||||
use galaxyui::elements::{
|
||||
Border, CacheOption, Clipped, Container, CornerRadius, Element, Hoverable, Image,
|
||||
ParentElement, Radius,
|
||||
Border, CacheOption, Clipped, Container, CornerRadius, Element, FormattedTextElement,
|
||||
Hoverable, Image, ParentElement, Radius, Wrap, WrapFill, DEFAULT_UI_LINE_HEIGHT_RATIO,
|
||||
};
|
||||
use galaxyui::platform::Cursor;
|
||||
use galaxyui::prelude::{Align, ConstrainedBox, CrossAxisAlignment, Flex, Text};
|
||||
use galaxyui::prelude::{Align, ConstrainedBox, CrossAxisAlignment, Flex, MainAxisSize, Text};
|
||||
use galaxyui::ui_components::keyboard_shortcut::keystroke_to_keys;
|
||||
use galaxyui::{AppContext, SingletonEntity};
|
||||
use pathfinder_color::ColorU;
|
||||
|
||||
use crate::ai::blocklist::agent_view::agent_view_bg_color;
|
||||
use crate::ai::blocklist::agent_view::shortcuts::render_keystroke_with_color_overrides;
|
||||
use crate::terminal;
|
||||
use crate::terminal::input::message_bar::{ChipHorizontalAlignment, Message, MessageItem};
|
||||
@@ -29,7 +30,7 @@ pub fn render_standard_message_bar(
|
||||
right_element: Option<Box<dyn Element>>,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
use galaxyui::prelude::{MainAxisAlignment, MainAxisSize};
|
||||
use galaxyui::prelude::MainAxisAlignment;
|
||||
|
||||
let (left_items, right_chips): (Vec<_>, Vec<_>) = message.items.into_iter().partition(|item| {
|
||||
!matches!(
|
||||
@@ -81,6 +82,51 @@ pub fn render_standard_message_bar(
|
||||
.with_height(standard_message_bar_height(app))
|
||||
.finish()
|
||||
}
|
||||
/// Renders a standard message bar variant for inline text and hyperlinks that need to soft-wrap.
|
||||
/// `render_standard_message_bar` intentionally remains fixed-height and single-line for existing
|
||||
/// hint/status bars.
|
||||
pub fn render_wrapping_standard_message_bar(
|
||||
icon: Icon,
|
||||
icon_color: ColorU,
|
||||
text_color: ColorU,
|
||||
fragments: Vec<FormattedTextFragment>,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let font_size = styles::font_size(app);
|
||||
let icon = ConstrainedBox::new(icon.to_warpui_icon(Fill::Solid(icon_color)).finish())
|
||||
.with_height(font_size)
|
||||
.with_width(font_size)
|
||||
.finish();
|
||||
let text = FormattedTextElement::new(
|
||||
FormattedText::new([FormattedTextLine::Line(fragments)]),
|
||||
font_size,
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_family(),
|
||||
text_color,
|
||||
Default::default(),
|
||||
)
|
||||
.with_line_height_ratio(DEFAULT_UI_LINE_HEIGHT_RATIO)
|
||||
.with_hyperlink_font_color(theme.accent().into())
|
||||
.register_default_click_handlers(|url, _ctx, app| {
|
||||
app.open_url(&url.url);
|
||||
})
|
||||
.finish();
|
||||
|
||||
Container::new(
|
||||
Wrap::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_spacing(4.)
|
||||
.with_child(icon)
|
||||
.with_child(WrapFill::new(0., text).finish())
|
||||
.finish(),
|
||||
)
|
||||
.with_horizontal_padding(*terminal::view::PADDING_LEFT)
|
||||
.with_vertical_padding(styles::VERTICAL_PADDING)
|
||||
.finish()
|
||||
}
|
||||
|
||||
pub fn render_standard_message(message: Message, app: &AppContext) -> Box<dyn Element> {
|
||||
render_message_bar_items(&message.items, app)
|
||||
@@ -92,7 +138,9 @@ fn render_message_bar_items(items: &[MessageItem], app: &AppContext) -> Box<dyn
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let default_font_color = styles::default_font_color(app);
|
||||
|
||||
let mut row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
let mut row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_constrain_horizontal_bounds_to_parent(true);
|
||||
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
let mut child: Box<dyn Element> = match item {
|
||||
@@ -468,11 +516,7 @@ pub fn disableable_message_item_color_overrides(
|
||||
}
|
||||
|
||||
pub mod styles {
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxyui::{AppContext, SingletonEntity};
|
||||
use pathfinder_color::ColorU;
|
||||
|
||||
use crate::ui_components::blended_colors;
|
||||
|
||||
pub fn font_size(app: &AppContext) -> f32 {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
@@ -8,18 +8,23 @@ use galaxyui::elements::{
|
||||
MouseStateHandle, Radius, Text,
|
||||
};
|
||||
use galaxyui::fonts::{Properties, Style, Weight};
|
||||
use galaxyui::platform::Cursor;
|
||||
use galaxyui::keymap::Keystroke;
|
||||
use galaxyui::platform::{Cursor, OperatingSystem};
|
||||
use galaxyui::text_layout::ClipConfig;
|
||||
use galaxyui::ui_components::button::ButtonVariant;
|
||||
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
|
||||
use galaxyui::{AppContext, Element, Entity, EntityId, SingletonEntity as _};
|
||||
use itertools::Itertools;
|
||||
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
|
||||
use ordered_float::OrderedFloat;
|
||||
use galaxyui::{AppContext, Element, Entity, EntityId, ModelHandle, SingletonEntity as _};
|
||||
|
||||
use super::model_spec_scores::{
|
||||
render_model_spec_header, render_model_spec_scores, CostRow, CostRowTooltip,
|
||||
ModelSpecScoresLayout, CUSTOM_MODEL_ROUTER_DESCRIPTION, CUSTOM_MODEL_ROUTER_TITLE,
|
||||
MODEL_SPECS_DESCRIPTION, MODEL_SPECS_TITLE, REASONING_LEVEL_DESCRIPTION, REASONING_LEVEL_TITLE,
|
||||
};
|
||||
use crate::ai::custom_model_routers::is_custom_router_id;
|
||||
use crate::ai::execution_profiles::model_menu_items::is_auto;
|
||||
use crate::ai::llms::{
|
||||
is_using_api_key_for_provider, DisableReason, LLMId, LLMInfo, LLMPreferences, LLMProvider,
|
||||
LLMSpec,
|
||||
is_using_api_key_for_provider, should_show_bedrock_icon_for_model, DisableReason, LLMId,
|
||||
LLMInfo, LLMPreferences, LLMProvider, LLMSpec,
|
||||
};
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::features::FeatureFlag;
|
||||
@@ -29,19 +34,15 @@ use crate::search::result_renderer::ItemHighlightState;
|
||||
use crate::search::{SearchItem, SyncDataSource};
|
||||
use crate::settings_view::SettingsSection;
|
||||
use crate::terminal::input::inline_menu::{
|
||||
default_navigation_message_items, InlineMenuAction, InlineMenuMessageArgs, InlineMenuType,
|
||||
default_navigation_message_items, styles as inline_styles, DetailsRenderConfig,
|
||||
InlineMenuAction, InlineMenuMessageArgs, InlineMenuType,
|
||||
};
|
||||
use crate::terminal::input::inline_menu::{styles as inline_styles, DetailsRenderConfig};
|
||||
use crate::terminal::input::message_bar::{Message, MessageItem};
|
||||
use crate::terminal::view::ambient_agent::AmbientAgentViewModel;
|
||||
use crate::workspace::WorkspaceAction;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use galaxyui::keymap::Keystroke;
|
||||
use galaxyui::platform::OperatingSystem;
|
||||
|
||||
use super::model_spec_scores::{
|
||||
render_model_spec_header, render_model_spec_scores, CostRow, ModelSpecScoresLayout,
|
||||
MODEL_SPECS_DESCRIPTION, MODEL_SPECS_TITLE, REASONING_LEVEL_DESCRIPTION, REASONING_LEVEL_TITLE,
|
||||
};
|
||||
const AUTO_BEDROCK_TOOLTIP: &str = "Warp uses Bedrock when the model Auto selects supports it; otherwise it may use Warp-hosted inference.";
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AcceptModel {
|
||||
@@ -130,11 +131,56 @@ fn model_specs_width(app: &AppContext) -> f32 {
|
||||
|
||||
pub struct ModelSelectorDataSource {
|
||||
terminal_view_id: EntityId,
|
||||
ambient_agent_view_model: Option<ModelHandle<AmbientAgentViewModel>>,
|
||||
}
|
||||
|
||||
impl ModelSelectorDataSource {
|
||||
pub fn new(terminal_view_id: EntityId) -> Self {
|
||||
Self { terminal_view_id }
|
||||
pub fn new(
|
||||
terminal_view_id: EntityId,
|
||||
ambient_agent_view_model: Option<ModelHandle<AmbientAgentViewModel>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
terminal_view_id,
|
||||
ambient_agent_view_model,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether a model should appear in the inline picker.
|
||||
/// Custom-endpoint models are suppressed in Oz cloud agent panes because
|
||||
/// they cannot route through Warp's cloud inference infrastructure.
|
||||
pub(crate) fn include_model_in_picker(is_cloud_pane: bool, is_custom_endpoint: bool) -> bool {
|
||||
!is_cloud_pane || !is_custom_endpoint
|
||||
}
|
||||
|
||||
fn order_model_choices<'a>(
|
||||
llm_preferences: &LLMPreferences,
|
||||
choices: Vec<&'a LLMInfo>,
|
||||
) -> Vec<&'a LLMInfo> {
|
||||
let mut auto_choices = Vec::new();
|
||||
let mut custom_router_choices = Vec::new();
|
||||
let mut custom_choices = Vec::new();
|
||||
let mut other_choices = Vec::new();
|
||||
|
||||
for llm in choices {
|
||||
// Check custom router before is_auto because custom router ids contain
|
||||
// "auto" and would otherwise land in auto_choices.
|
||||
if is_custom_router_id(llm.id.as_str()) {
|
||||
custom_router_choices.push(llm);
|
||||
} else if is_auto(llm) {
|
||||
auto_choices.push(llm);
|
||||
} else if llm_preferences.custom_llm_info_for_id(&llm.id).is_some() {
|
||||
custom_choices.push(llm);
|
||||
} else {
|
||||
other_choices.push(llm);
|
||||
}
|
||||
}
|
||||
|
||||
auto_choices
|
||||
.into_iter()
|
||||
.chain(custom_router_choices)
|
||||
.chain(custom_choices)
|
||||
.chain(other_choices)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,13 +207,25 @@ impl SyncDataSource for ModelSelectorDataSource {
|
||||
.clone()
|
||||
};
|
||||
|
||||
let choices: Vec<&LLMInfo> = if is_full_terminal {
|
||||
llm_preferences.get_cli_agent_llm_choices().collect_vec()
|
||||
let is_cloud_pane = self.ambient_agent_view_model.is_some();
|
||||
let choices = if is_full_terminal {
|
||||
llm_preferences
|
||||
.get_cli_agent_llm_choices(app)
|
||||
.filter(|llm| {
|
||||
let is_custom = llm_preferences.custom_llm_info_for_id(&llm.id).is_some();
|
||||
Self::include_model_in_picker(is_cloud_pane, is_custom)
|
||||
})
|
||||
.collect_vec()
|
||||
} else {
|
||||
llm_preferences
|
||||
.get_base_llm_choices_for_agent_mode()
|
||||
.get_base_llm_choices_for_agent_mode(app)
|
||||
.filter(|llm| {
|
||||
let is_custom = llm_preferences.custom_llm_info_for_id(&llm.id).is_some();
|
||||
Self::include_model_in_picker(is_cloud_pane, is_custom)
|
||||
})
|
||||
.collect_vec()
|
||||
};
|
||||
let choices = Self::order_model_choices(llm_preferences, choices);
|
||||
|
||||
let query_text = query.text.trim().to_lowercase();
|
||||
|
||||
@@ -210,13 +268,21 @@ struct ModelSearchItem {
|
||||
id: LLMId,
|
||||
provider: LLMProvider,
|
||||
spec: Option<LLMSpec>,
|
||||
provider_icon: Option<Icon>,
|
||||
leading_icon: Icon,
|
||||
credential_icon: Option<Icon>,
|
||||
display_text: String,
|
||||
is_selected: bool,
|
||||
is_custom_endpoint: bool,
|
||||
is_custom_router: bool,
|
||||
/// Source/routing description for custom model routers (from `LLMInfo.description`).
|
||||
description: Option<String>,
|
||||
disable_reason: Option<DisableReason>,
|
||||
is_auto: bool,
|
||||
is_using_bedrock: bool,
|
||||
name_match_result: Option<FuzzyMatchResult>,
|
||||
score: OrderedFloat<f64>,
|
||||
manage_api_key_mouse_state: MouseStateHandle,
|
||||
cost_row_tooltip_mouse_state: MouseStateHandle,
|
||||
reasoning_level: Option<String>,
|
||||
discount_percentage: Option<f32>,
|
||||
}
|
||||
@@ -232,17 +298,44 @@ impl ModelSearchItem {
|
||||
} else {
|
||||
llm.disable_reason.clone()
|
||||
};
|
||||
let is_custom_endpoint = LLMPreferences::as_ref(app)
|
||||
.custom_llm_info_for_id(&llm.id)
|
||||
.is_some();
|
||||
let is_custom_router = is_custom_router_id(llm.id.as_str());
|
||||
let is_auto = is_auto(llm);
|
||||
let is_using_bedrock = should_show_bedrock_icon_for_model(llm, app);
|
||||
let is_using_api_key =
|
||||
is_custom_endpoint || is_using_api_key_for_provider(&llm.provider, app);
|
||||
let leading_icon = if is_using_bedrock {
|
||||
Icon::Aws
|
||||
} else if is_custom_router {
|
||||
Icon::Dataflow
|
||||
} else {
|
||||
llm.provider.icon().unwrap_or(Icon::Oz)
|
||||
};
|
||||
let credential_icon = if !is_using_bedrock && is_using_api_key {
|
||||
Some(Icon::Key)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Self {
|
||||
id: llm.id.clone(),
|
||||
provider: llm.provider.clone(),
|
||||
spec: llm.spec.clone(),
|
||||
provider_icon: llm.provider.icon(),
|
||||
leading_icon,
|
||||
credential_icon,
|
||||
display_text: llm.display_name.clone(),
|
||||
is_selected: &llm.id == active_llm_id,
|
||||
is_custom_endpoint,
|
||||
is_custom_router,
|
||||
description: llm.description.clone(),
|
||||
disable_reason,
|
||||
is_auto,
|
||||
is_using_bedrock,
|
||||
name_match_result: None,
|
||||
score: OrderedFloat(f64::MIN),
|
||||
manage_api_key_mouse_state: Default::default(),
|
||||
cost_row_tooltip_mouse_state: Default::default(),
|
||||
reasoning_level: llm.reasoning_level(),
|
||||
discount_percentage: llm.discount_percentage,
|
||||
}
|
||||
@@ -270,11 +363,7 @@ impl SearchItem for ModelSearchItem {
|
||||
let icon_size = inline_styles::font_size(appearance);
|
||||
let icon_color = inline_styles::icon_color(appearance);
|
||||
|
||||
let icon = self
|
||||
.provider_icon
|
||||
.unwrap_or(Icon::Oz)
|
||||
.to_galaxyui_icon(icon_color)
|
||||
.finish();
|
||||
let icon = self.leading_icon.to_galaxyui_icon(icon_color).finish();
|
||||
|
||||
Container::new(
|
||||
ConstrainedBox::new(icon)
|
||||
@@ -329,14 +418,17 @@ impl SearchItem for ModelSearchItem {
|
||||
let mut row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(text.finish());
|
||||
|
||||
if is_using_api_key_for_provider(&self.provider, app) {
|
||||
let key_icon =
|
||||
ConstrainedBox::new(Icon::Key.to_galaxyui_icon(secondary_text_color).finish())
|
||||
if let Some(icon) = self.credential_icon {
|
||||
let credential_icon =
|
||||
ConstrainedBox::new(icon.to_galaxyui_icon(secondary_text_color).finish())
|
||||
.with_width(font_size)
|
||||
.with_height(font_size)
|
||||
.finish();
|
||||
row = row.with_child(Container::new(key_icon).with_margin_left(6.).finish());
|
||||
row = row.with_child(
|
||||
Container::new(credential_icon)
|
||||
.with_margin_left(6.)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
if self.is_selected {
|
||||
@@ -379,7 +471,7 @@ impl SearchItem for ModelSearchItem {
|
||||
|
||||
if should_show_discount_chip(
|
||||
self.discount_percentage,
|
||||
is_using_api_key_for_provider(&self.provider, app),
|
||||
is_using_api_key_for_provider(&self.provider, app) || self.is_using_bedrock,
|
||||
) {
|
||||
let discount_percentage = self.discount_percentage.unwrap_or(0.);
|
||||
let chip = Container::new(
|
||||
@@ -412,11 +504,35 @@ impl SearchItem for ModelSearchItem {
|
||||
}
|
||||
|
||||
fn render_details(&self, app: &AppContext) -> Option<Box<dyn Element>> {
|
||||
use galaxyui::elements::{Flex, ParentElement as _};
|
||||
|
||||
let appearance = crate::appearance::Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
// Custom auto models get an informational blurb instead of spec bars.
|
||||
if self.is_custom_router {
|
||||
let header = render_model_spec_header(
|
||||
CUSTOM_MODEL_ROUTER_TITLE,
|
||||
CUSTOM_MODEL_ROUTER_DESCRIPTION,
|
||||
app,
|
||||
);
|
||||
let source_text = Text::new(
|
||||
self.description.as_deref().unwrap_or("").to_string(),
|
||||
appearance.ui_font_family(),
|
||||
inline_styles::font_size(appearance),
|
||||
)
|
||||
.with_color(theme.disabled_ui_text_color().into())
|
||||
.finish();
|
||||
let column = Flex::column()
|
||||
.with_child(Container::new(header).with_margin_bottom(12.).finish())
|
||||
.with_child(source_text)
|
||||
.finish();
|
||||
return Some(
|
||||
ConstrainedBox::new(column)
|
||||
.with_width(model_specs_width(app))
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
let (title, description) = if self.reasoning_level.is_some() {
|
||||
(REASONING_LEVEL_TITLE, REASONING_LEVEL_DESCRIPTION)
|
||||
} else {
|
||||
@@ -424,8 +540,15 @@ impl SearchItem for ModelSearchItem {
|
||||
};
|
||||
let header = render_model_spec_header(title, description, app);
|
||||
|
||||
let is_using_api_key = is_using_api_key_for_provider(&self.provider, app);
|
||||
let cost_row = if is_using_api_key {
|
||||
let is_using_api_key =
|
||||
self.is_custom_endpoint || is_using_api_key_for_provider(&self.provider, app);
|
||||
let cost_row = if self.is_using_bedrock || is_using_api_key {
|
||||
let search_query = if self.is_using_bedrock {
|
||||
"bedrock"
|
||||
} else {
|
||||
"api"
|
||||
}
|
||||
.to_string();
|
||||
let manage_button = appearance
|
||||
.ui_builder()
|
||||
.button(
|
||||
@@ -445,15 +568,29 @@ impl SearchItem for ModelSearchItem {
|
||||
})
|
||||
.with_cursor(Some(Cursor::PointingHand))
|
||||
.build()
|
||||
.on_click(|ctx, _, _| {
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(WorkspaceAction::ShowSettingsPageWithSearch {
|
||||
search_query: "api".to_string(),
|
||||
search_query: search_query.clone(),
|
||||
section: Some(SettingsSection::WarpAgent),
|
||||
});
|
||||
})
|
||||
.finish();
|
||||
|
||||
CostRow::BilledToApi {
|
||||
CostRow::BilledToProvider {
|
||||
label: if self.is_using_bedrock && self.is_auto {
|
||||
"Inference may use Bedrock"
|
||||
} else if self.is_using_bedrock {
|
||||
"Inference via Bedrock"
|
||||
} else {
|
||||
"Inference via API key"
|
||||
},
|
||||
tooltip: if self.is_using_bedrock && self.is_auto {
|
||||
Some(CostRowTooltip {
|
||||
text: AUTO_BEDROCK_TOOLTIP,
|
||||
mouse_state: self.cost_row_tooltip_mouse_state.clone(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
},
|
||||
manage_button: Container::new(manage_button).finish(),
|
||||
}
|
||||
} else {
|
||||
@@ -493,7 +630,7 @@ impl SearchItem for ModelSearchItem {
|
||||
|
||||
// Show a BYOK option when the user's tier supports it and the provider
|
||||
// is one that accepts user-supplied API keys.
|
||||
let byok_available = UserWorkspaces::as_ref(app).is_byo_api_key_enabled()
|
||||
let byok_available = UserWorkspaces::as_ref(app).is_byo_api_key_enabled(app)
|
||||
&& matches!(
|
||||
self.provider,
|
||||
LLMProvider::OpenAI | LLMProvider::Anthropic | LLMProvider::Google
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use galaxy_core::ui::theme::color::internal_colors;
|
||||
use galaxyui::elements::{
|
||||
Border, ConstrainedBox, Container, CornerRadius, Expanded, Flex, MainAxisAlignment,
|
||||
MainAxisSize, ParentElement as _, Percentage, Radius, Rect, Stack, Text,
|
||||
Border, ChildAnchor, ConstrainedBox, Container, CornerRadius, Expanded, Flex, Hoverable,
|
||||
Icon as WarpUiIcon, MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning,
|
||||
ParentAnchor, ParentElement as _, ParentOffsetBounds, Percentage, Radius, Rect, Stack, Text,
|
||||
};
|
||||
use galaxyui::prelude::{Align, CrossAxisAlignment};
|
||||
use galaxyui::text_layout::ClipConfig;
|
||||
use galaxyui::ui_components::components::UiComponent;
|
||||
use galaxyui::{AppContext, Element, SingletonEntity as _};
|
||||
use pathfinder_color::ColorU;
|
||||
|
||||
use crate::ai::llms::LLMSpec;
|
||||
use crate::appearance::Appearance;
|
||||
@@ -21,9 +24,22 @@ pub const MODEL_SPECS_DESCRIPTION: &str = "Galaxy's benchmarks for how well a mo
|
||||
pub const REASONING_LEVEL_TITLE: &str = "Reasoning level";
|
||||
pub const REASONING_LEVEL_DESCRIPTION: &str = "Increased reasoning levels consume more credits and have higher latency, but higher performance for complicated tasks.";
|
||||
|
||||
pub const CUSTOM_MODEL_ROUTER_TITLE: &str = "Custom Model Router";
|
||||
pub const CUSTOM_MODEL_ROUTER_DESCRIPTION: &str = "Routes each request to a concrete model based on your routing rules, rather than using a single fixed model.";
|
||||
|
||||
pub enum CostRow {
|
||||
Bar { value: Option<f32> },
|
||||
BilledToApi { manage_button: Box<dyn Element> },
|
||||
Bar {
|
||||
value: Option<f32>,
|
||||
},
|
||||
BilledToProvider {
|
||||
label: &'static str,
|
||||
tooltip: Option<CostRowTooltip>,
|
||||
manage_button: Box<dyn Element>,
|
||||
},
|
||||
}
|
||||
pub struct CostRowTooltip {
|
||||
pub text: &'static str,
|
||||
pub mouse_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
pub struct ModelSpecScoresLayout {
|
||||
@@ -41,6 +57,7 @@ pub fn render_model_spec_scores(
|
||||
ScoreRowKind::Bar {
|
||||
value: spec.as_ref().map(|spec| spec.quality),
|
||||
},
|
||||
None,
|
||||
layout.bg_bar_color,
|
||||
app,
|
||||
)];
|
||||
@@ -50,6 +67,7 @@ pub fn render_model_spec_scores(
|
||||
ScoreRowKind::Bar {
|
||||
value: spec.as_ref().map(|spec| spec.speed),
|
||||
},
|
||||
None,
|
||||
layout.bg_bar_color,
|
||||
app,
|
||||
));
|
||||
@@ -59,14 +77,23 @@ pub fn render_model_spec_scores(
|
||||
rows.push(render_score_row(
|
||||
"Cost",
|
||||
ScoreRowKind::Bar { value },
|
||||
None,
|
||||
layout.bg_bar_color,
|
||||
app,
|
||||
));
|
||||
}
|
||||
CostRow::BilledToApi { manage_button } => {
|
||||
CostRow::BilledToProvider {
|
||||
label,
|
||||
tooltip,
|
||||
manage_button,
|
||||
} => {
|
||||
rows.push(render_score_row(
|
||||
"Cost",
|
||||
ScoreRowKind::BilledToApi { manage_button },
|
||||
ScoreRowKind::BilledToProvider {
|
||||
label,
|
||||
manage_button,
|
||||
},
|
||||
tooltip,
|
||||
layout.bg_bar_color,
|
||||
app,
|
||||
));
|
||||
@@ -80,13 +107,19 @@ pub fn render_model_spec_scores(
|
||||
}
|
||||
|
||||
enum ScoreRowKind {
|
||||
Bar { value: Option<f32> },
|
||||
BilledToApi { manage_button: Box<dyn Element> },
|
||||
Bar {
|
||||
value: Option<f32>,
|
||||
},
|
||||
BilledToProvider {
|
||||
label: &'static str,
|
||||
manage_button: Box<dyn Element>,
|
||||
},
|
||||
}
|
||||
|
||||
fn render_score_row(
|
||||
name: &str,
|
||||
kind: ScoreRowKind,
|
||||
label_tooltip: Option<CostRowTooltip>,
|
||||
bg_bar_color: ColorU,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
@@ -101,23 +134,9 @@ fn render_score_row(
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
) * 8.;
|
||||
let label = ConstrainedBox::new(
|
||||
Text::new(
|
||||
name.to_string(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(
|
||||
inline_styles::primary_text_color(
|
||||
theme,
|
||||
inline_styles::menu_background_color(app).into(),
|
||||
)
|
||||
.into_solid(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_width(label_width)
|
||||
.finish();
|
||||
let label = ConstrainedBox::new(render_row_label(name, label_tooltip, appearance, app))
|
||||
.with_width(label_width)
|
||||
.finish();
|
||||
|
||||
let bar_height = app.font_cache().line_height(
|
||||
appearance.monospace_font_size(),
|
||||
@@ -184,24 +203,16 @@ fn render_score_row(
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
ScoreRowKind::BilledToApi { manage_button } => Expanded::new(
|
||||
ScoreRowKind::BilledToProvider {
|
||||
label,
|
||||
manage_button,
|
||||
} => Expanded::new(
|
||||
1.,
|
||||
Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(
|
||||
Container::new(
|
||||
Text::new(
|
||||
"Billed to API".to_string(),
|
||||
appearance.ui_font_family(),
|
||||
14.,
|
||||
)
|
||||
.with_color(theme.disabled_ui_text_color().into())
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(render_provider_label(label, appearance))
|
||||
.with_child(manage_button)
|
||||
.finish(),
|
||||
)
|
||||
@@ -216,6 +227,81 @@ fn render_score_row(
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_row_label(
|
||||
label: &str,
|
||||
tooltip: Option<CostRowTooltip>,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let label = Text::new(
|
||||
label.to_string(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(
|
||||
inline_styles::primary_text_color(
|
||||
appearance.theme(),
|
||||
inline_styles::menu_background_color(app).into(),
|
||||
)
|
||||
.into_solid(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let Some(tooltip) = tooltip else {
|
||||
return label;
|
||||
};
|
||||
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(label)
|
||||
.with_child(
|
||||
Container::new(render_info_tooltip(tooltip, appearance))
|
||||
.with_margin_left(4.)
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_provider_label(label: &'static str, appearance: &Appearance) -> Box<dyn Element> {
|
||||
Container::new(
|
||||
Text::new(label.to_string(), appearance.ui_font_family(), 14.)
|
||||
.with_color(appearance.theme().disabled_ui_text_color().into())
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_info_tooltip(tooltip: CostRowTooltip, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let icon_color = appearance.theme().disabled_ui_text_color();
|
||||
let ui_builder = appearance.ui_builder();
|
||||
let tooltip_text = tooltip.text.to_string();
|
||||
Hoverable::new(tooltip.mouse_state, move |state| {
|
||||
let info_icon = Container::new(
|
||||
ConstrainedBox::new(WarpUiIcon::new("bundled/svg/info.svg", icon_color).finish())
|
||||
.with_width(13.)
|
||||
.with_height(13.)
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let mut stack = Stack::new().with_child(info_icon);
|
||||
if state.is_hovered() {
|
||||
let tooltip = ui_builder.tool_tip(tooltip_text.clone()).build();
|
||||
stack.add_positioned_child(
|
||||
tooltip.finish(),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., -3.),
|
||||
ParentOffsetBounds::Unbounded,
|
||||
ParentAnchor::TopMiddle,
|
||||
ChildAnchor::BottomMiddle,
|
||||
),
|
||||
);
|
||||
}
|
||||
stack.finish()
|
||||
})
|
||||
.finish()
|
||||
}
|
||||
|
||||
pub fn render_model_spec_header(
|
||||
title: &str,
|
||||
description: &str,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use ai::api_keys::{ApiKeyManager, ApiKeyManagerEvent};
|
||||
use pathfinder_color::ColorU;
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_core::ui::theme::color::internal_colors;
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
@@ -9,7 +11,6 @@ use galaxyui::{
|
||||
AppContext, Element, Entity, EntityId, ModelHandle, SingletonEntity as _, View, ViewContext,
|
||||
ViewHandle,
|
||||
};
|
||||
use pathfinder_color::ColorU;
|
||||
|
||||
use crate::ai::blocklist::agent_view::AgentViewController;
|
||||
use crate::ai::blocklist::block::cli_controller::{CLISubagentController, CLISubagentEvent};
|
||||
@@ -28,11 +29,11 @@ use crate::terminal::input::models::data_source::{AcceptModel, ModelSelectorData
|
||||
use crate::terminal::input::suggestions_mode_model::{
|
||||
InputSuggestionsModeEvent, InputSuggestionsModeModel,
|
||||
};
|
||||
use crate::terminal::view::ambient_agent::AmbientAgentViewModel;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::view_components::action_button::{ActionButton, ActionButtonTheme, ButtonSize};
|
||||
use crate::view_components::alert::{Alert, AlertConfig};
|
||||
use crate::workspace::WorkspaceAction;
|
||||
use ai::api_keys::{ApiKeyManager, ApiKeyManagerEvent};
|
||||
|
||||
struct ManageDefaultsTheme;
|
||||
|
||||
@@ -105,11 +106,18 @@ pub struct InlineModelSelectorView {
|
||||
/// Controls whether or not we should filter the contents of the menu
|
||||
/// based on the contents of the input.
|
||||
filter_results_by_input: bool,
|
||||
/// True when the selector was opened from the model chip with a pre-existing
|
||||
/// prompt that we cleared so the input could be used to search models. The
|
||||
/// prompt is stashed in the suggestions-mode buffer snapshot and restored
|
||||
/// when the selector closes.
|
||||
prompt_parked_for_search: bool,
|
||||
}
|
||||
|
||||
impl InlineModelSelectorView {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
terminal_view_id: EntityId,
|
||||
ambient_agent_view_model: Option<ModelHandle<AmbientAgentViewModel>>,
|
||||
suggestions_mode_model: ModelHandle<InputSuggestionsModeModel>,
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
input_buffer_model: &ModelHandle<InputBufferModel>,
|
||||
@@ -117,7 +125,9 @@ impl InlineModelSelectorView {
|
||||
positioner: &ModelHandle<InlineMenuPositioner>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let data_source = ctx.add_model(|_| ModelSelectorDataSource::new(terminal_view_id));
|
||||
let data_source = ctx.add_model(|_| {
|
||||
ModelSelectorDataSource::new(terminal_view_id, ambient_agent_view_model)
|
||||
});
|
||||
|
||||
let tab_configs = TAB_CONFIGS.clone();
|
||||
let initial_filters = tab_configs
|
||||
@@ -251,7 +261,8 @@ impl InlineModelSelectorView {
|
||||
if model.as_ref(ctx).is_inline_model_selector() {
|
||||
me.rerun_query(ctx);
|
||||
} else if model.as_ref(ctx).is_closed() {
|
||||
me.filter_results_by_input = true;
|
||||
me.set_filter_results_by_input(true);
|
||||
me.set_prompt_parked_for_search(false);
|
||||
me.mixer.update(ctx, |mixer, ctx| {
|
||||
mixer.reset_results(ctx);
|
||||
});
|
||||
@@ -331,11 +342,11 @@ impl InlineModelSelectorView {
|
||||
&BlocklistAIHistoryModel::handle(ctx),
|
||||
move |me, _, event, ctx| {
|
||||
if let BlocklistAIHistoryEvent::UpdatedConversationStatus {
|
||||
terminal_view_id: event_terminal_view_id,
|
||||
terminal_surface_id: event_terminal_surface_id,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
if *event_terminal_view_id == terminal_view_id {
|
||||
if *event_terminal_surface_id == terminal_view_id {
|
||||
me.menu_view.update(ctx, |_, ctx| ctx.notify());
|
||||
}
|
||||
}
|
||||
@@ -397,6 +408,7 @@ impl InlineModelSelectorView {
|
||||
terminal_view_id,
|
||||
selection_before_tab_switch: None,
|
||||
filter_results_by_input: true,
|
||||
prompt_parked_for_search: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,6 +462,14 @@ impl InlineModelSelectorView {
|
||||
self.filter_results_by_input = filter;
|
||||
}
|
||||
|
||||
pub fn prompt_parked_for_search(&self) -> bool {
|
||||
self.prompt_parked_for_search
|
||||
}
|
||||
|
||||
pub fn set_prompt_parked_for_search(&mut self, parked: bool) {
|
||||
self.prompt_parked_for_search = parked;
|
||||
}
|
||||
|
||||
pub fn set_active_tab(&self, tab: InlineModelSelectorTab, ctx: &mut ViewContext<Self>) {
|
||||
let index = self
|
||||
.menu_view
|
||||
|
||||
@@ -3,9 +3,8 @@ mod data_source;
|
||||
mod search_item;
|
||||
mod view;
|
||||
|
||||
pub use view::{InlinePlanMenuEvent, InlinePlanMenuView};
|
||||
|
||||
use ai::document::AIDocumentId;
|
||||
pub use view::{InlinePlanMenuEvent, InlinePlanMenuView};
|
||||
use galaxyui::keymap::Keystroke;
|
||||
|
||||
use crate::ai::document::ai_document_model::AIDocumentVersion;
|
||||
|
||||
@@ -14,9 +14,9 @@ use crate::search::mixer::DataSourceRunErrorWrapper;
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
use crate::search::{SearchItem, SyncDataSource};
|
||||
use crate::server::ids::SyncId;
|
||||
use crate::terminal::input::inline_menu::styles as inline_styles;
|
||||
use crate::terminal::input::inline_menu::{
|
||||
default_navigation_message_items, InlineMenuAction, InlineMenuMessageArgs, InlineMenuType,
|
||||
default_navigation_message_items, styles as inline_styles, InlineMenuAction,
|
||||
InlineMenuMessageArgs, InlineMenuType,
|
||||
};
|
||||
use crate::terminal::input::message_bar::Message;
|
||||
use crate::workflows::CloudWorkflow;
|
||||
@@ -113,9 +113,7 @@ impl SyncDataSource for PromptsMenuDataSource {
|
||||
.collect()
|
||||
})
|
||||
.map_err(|e| {
|
||||
Box::new(DataSourceSearchError {
|
||||
message: e.to_string(),
|
||||
}) as DataSourceRunErrorWrapper
|
||||
Box::new(DataSourceSearchError::new(e.to_string())) as DataSourceRunErrorWrapper
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,9 @@ mod data_source;
|
||||
mod search_item;
|
||||
mod view;
|
||||
|
||||
pub use view::{InlineReposMenuEvent, InlineReposMenuView};
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub use view::{InlineReposMenuEvent, InlineReposMenuView};
|
||||
use galaxyui::keymap::Keystroke;
|
||||
|
||||
use crate::terminal::input::inline_menu::{
|
||||
|
||||
@@ -55,7 +55,6 @@ impl InlineReposMenuView {
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
mixer.run_query(repos_query(""), ctx);
|
||||
mixer
|
||||
});
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ impl SyncDataSource for RewindDataSource {
|
||||
let query_text = exchange
|
||||
.input
|
||||
.iter()
|
||||
.find_map(AIAgentInput::user_query)
|
||||
.find_map(AIAgentInput::display_query)
|
||||
.unwrap_or_default();
|
||||
|
||||
// Find the end of this "block" - either the next user query or end of exchanges
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use ai::skills::{SkillProvider, SkillReference, SkillScope};
|
||||
use fuzzy_match::{match_indices_case_insensitive, FuzzyMatchResult};
|
||||
use ordered_float::OrderedFloat;
|
||||
use galaxy_core::ui::icons::Icon;
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
use galaxy_util::local_or_remote_path::LocalOrRemotePath;
|
||||
use galaxyui::elements::{
|
||||
ConstrainedBox, Container, CrossAxisAlignment, Flex, Highlight, ParentElement, Shrinkable, Text,
|
||||
};
|
||||
@@ -14,7 +14,6 @@ use galaxyui::text_layout::ClipConfig;
|
||||
use galaxyui::{
|
||||
AppContext, Element, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity as _,
|
||||
};
|
||||
use ordered_float::OrderedFloat;
|
||||
|
||||
use crate::ai::skills::SkillManager;
|
||||
use crate::appearance::Appearance;
|
||||
@@ -23,9 +22,9 @@ use crate::search::mixer::DataSourceRunErrorWrapper;
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
use crate::search::{SearchItem, SyncDataSource};
|
||||
use crate::terminal::cli_agent_sessions::{CLIAgentInputState, CLIAgentSessionsModel};
|
||||
use crate::terminal::input::inline_menu::styles as inline_styles;
|
||||
use crate::terminal::input::inline_menu::{
|
||||
default_navigation_message_items, InlineMenuAction, InlineMenuMessageArgs, InlineMenuType,
|
||||
default_navigation_message_items, styles as inline_styles, InlineMenuAction,
|
||||
InlineMenuMessageArgs, InlineMenuType,
|
||||
};
|
||||
use crate::terminal::input::message_bar::{Message, MessageItem};
|
||||
use crate::terminal::model::session::active_session::{ActiveSession, ActiveSessionEvent};
|
||||
@@ -77,7 +76,7 @@ impl SkillSelectorDataSource {
|
||||
terminal_view_id: EntityId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
ctx.subscribe_to_model(&active_session, |_, event, ctx| match event {
|
||||
ctx.subscribe_to_model(&active_session, |_, _, event, ctx| match event {
|
||||
// Emit event so the mixer can re-run its query with the new pwd
|
||||
ActiveSessionEvent::UpdatedPwd | ActiveSessionEvent::Bootstrapped => {
|
||||
ctx.emit(UpdatedAvailableSkills);
|
||||
@@ -104,12 +103,11 @@ impl SkillSelectorDataSource {
|
||||
self.include_bundled = include_bundled;
|
||||
}
|
||||
|
||||
/// Get the current working directory from the active session
|
||||
fn get_current_working_directory(&self, app: &AppContext) -> Option<PathBuf> {
|
||||
/// Get the current working directory location from the active session.
|
||||
fn get_current_working_directory(&self, app: &AppContext) -> Option<LocalOrRemotePath> {
|
||||
self.active_session
|
||||
.as_ref(app)
|
||||
.current_working_directory()
|
||||
.map(PathBuf::from)
|
||||
.current_working_directory_location(app)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,10 +121,12 @@ impl SyncDataSource for SkillSelectorDataSource {
|
||||
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
|
||||
let cwd = self.get_current_working_directory(app);
|
||||
let cli_agent_providers = self.active_cli_agent_providers(app);
|
||||
let skills =
|
||||
SkillManager::as_ref(app).get_skills_for_working_directory(cwd.as_deref(), app);
|
||||
let skills = SkillManager::as_ref(app).get_skills_for_working_directory(cwd.as_ref(), app);
|
||||
|
||||
// Filter out bundled skills when in open mode, since they cannot be opened.
|
||||
// Bundled skills are identified by scope rather than reference: local
|
||||
// catalog entries are `BundledSkillId`-referenced, but remote catalog
|
||||
// entries are path-referenced, and both must be excluded here.
|
||||
// When CLI agent input is open, filter to skills that exist in a supported
|
||||
// provider folder. We check all paths for the skill name (not just the
|
||||
// deduplicated provider) because deduplication may pick a higher-priority
|
||||
@@ -138,8 +138,7 @@ impl SyncDataSource for SkillSelectorDataSource {
|
||||
if let Some(providers) = &cli_agent_providers {
|
||||
skill_manager.skill_exists_for_any_provider(skill, providers)
|
||||
} else {
|
||||
self.include_bundled
|
||||
|| !matches!(skill.reference, SkillReference::BundledSkillId(_))
|
||||
self.include_bundled || skill.scope != SkillScope::Bundled
|
||||
}
|
||||
})
|
||||
.map(|mut skill| {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use ai::skills::SkillReference;
|
||||
use galaxyui::elements::ChildView;
|
||||
use galaxyui::{Element, Entity, ModelHandle, View, ViewContext, ViewHandle};
|
||||
use galaxyui::{Element, Entity, EntityId, ModelHandle, View, ViewContext, ViewHandle};
|
||||
|
||||
use crate::ai::blocklist::agent_view::AgentViewController;
|
||||
use crate::search::data_source::Query;
|
||||
@@ -14,7 +14,6 @@ use crate::terminal::input::suggestions_mode_model::{
|
||||
InputSuggestionsModeEvent, InputSuggestionsModeModel,
|
||||
};
|
||||
use crate::terminal::model::session::active_session::ActiveSession;
|
||||
use galaxyui::EntityId;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum InlineSkillSelectorEvent {
|
||||
|
||||
@@ -2,15 +2,17 @@ use ai::skills::SkillReference;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxyui::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity};
|
||||
use input_classifier::InputType;
|
||||
use settings::Setting as _;
|
||||
|
||||
use crate::ai::blocklist::{BlocklistAIInputEvent, BlocklistAIInputModel};
|
||||
use crate::ai::blocklist::{
|
||||
BlocklistAIInputEvent, BlocklistAIInputModel, InputTypeAutoDetectionSource,
|
||||
};
|
||||
use crate::ai::skills::SkillManager;
|
||||
use crate::search::slash_command_menu::StaticCommand;
|
||||
use crate::settings::InputSettings;
|
||||
use crate::terminal::input::buffer_model::{InputBufferModel, InputBufferUpdateEvent};
|
||||
use crate::terminal::input::slash_commands::SlashCommandDataSource;
|
||||
use crate::terminal::model::session::active_session::ActiveSession;
|
||||
use settings::Setting as _;
|
||||
|
||||
/// Event emitted by the slash command model when its entry state is updated.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -131,7 +133,7 @@ impl SlashCommandModel {
|
||||
data_source: ModelHandle<SlashCommandDataSource>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
ctx.subscribe_to_model(buffer_model, |me, event, ctx| {
|
||||
ctx.subscribe_to_model(buffer_model, |me, _, event, ctx| {
|
||||
me.handle_input_buffer_update(event, ctx);
|
||||
});
|
||||
|
||||
@@ -140,7 +142,7 @@ impl SlashCommandModel {
|
||||
//
|
||||
// In the new modality, slash commands _are_ accessible in the terminal view, which is
|
||||
// in locked shell mode if NLD is disabled.
|
||||
ctx.subscribe_to_model(ai_input_model, |me, event, ctx| match event {
|
||||
ctx.subscribe_to_model(ai_input_model, |me, _, event, ctx| match event {
|
||||
BlocklistAIInputEvent::InputTypeChanged { config }
|
||||
| BlocklistAIInputEvent::LockChanged { config } => {
|
||||
if config.is_locked {
|
||||
@@ -193,7 +195,11 @@ impl SlashCommandModel {
|
||||
&& !self.ai_input_model.as_ref(ctx).is_input_type_locked()
|
||||
{
|
||||
self.ai_input_model.update(ctx, |input_model, ctx| {
|
||||
input_model.set_input_type(InputType::Shell, ctx);
|
||||
input_model.set_input_type(
|
||||
InputType::Shell,
|
||||
Some(InputTypeAutoDetectionSource::SlashCommand),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -242,11 +248,11 @@ impl SlashCommandModel {
|
||||
|
||||
let skill_name = possible_command.strip_prefix('/')?;
|
||||
|
||||
let cwd = self.active_session.as_ref(ctx).current_working_directory();
|
||||
let cwd_path = cwd.as_ref().map(std::path::Path::new);
|
||||
let active_session = self.active_session.as_ref(ctx);
|
||||
let cwd_path = active_session.current_working_directory_location(ctx);
|
||||
let skills = SkillManager::handle(ctx)
|
||||
.as_ref(ctx)
|
||||
.get_skills_for_working_directory(cwd_path, ctx);
|
||||
.get_skills_for_working_directory(cwd_path.as_ref(), ctx);
|
||||
|
||||
let matched_skill = skills.into_iter().find(|skill| skill.name == skill_name)?;
|
||||
|
||||
@@ -332,7 +338,11 @@ impl SlashCommandModel {
|
||||
// mode, either locked or unlocked; if the input were locked to shell mode then the
|
||||
// state would be `DisabledUntilEmptyBuffer` and we would have shortcircuited above.
|
||||
self.ai_input_model.update(ctx, |input_model, ctx| {
|
||||
input_model.set_input_type(InputType::AI, ctx);
|
||||
input_model.set_input_type(
|
||||
InputType::AI,
|
||||
Some(InputTypeAutoDetectionSource::SlashCommand),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
self.state = SlashCommandEntryState::SlashCommand(detected_command);
|
||||
@@ -346,7 +356,11 @@ impl SlashCommandModel {
|
||||
|
||||
// Skill commands always require AI mode
|
||||
self.ai_input_model.update(ctx, |input_model, ctx| {
|
||||
input_model.set_input_type(InputType::AI, ctx);
|
||||
input_model.set_input_type(
|
||||
InputType::AI,
|
||||
Some(InputTypeAutoDetectionSource::SlashCommand),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
self.state = SlashCommandEntryState::SkillCommand(detected_skill);
|
||||
}
|
||||
@@ -373,7 +387,11 @@ impl SlashCommandModel {
|
||||
// handled appropriately. I am just making this change to preserve the existing
|
||||
// product behavior (agent icon in NLD toggle becomes yellow).
|
||||
self.ai_input_model.update(ctx, |input_model, ctx| {
|
||||
input_model.set_input_type(InputType::AI, ctx);
|
||||
input_model.set_input_type(
|
||||
InputType::AI,
|
||||
Some(InputTypeAutoDetectionSource::SlashCommand),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
use settings::Setting as _;
|
||||
use warpui::{App, SingletonEntity as _};
|
||||
|
||||
use super::SlashCommandEntryState;
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::blocklist::{QueuedQuery, QueuedQueryModel, QueuedQueryOrigin};
|
||||
use crate::report_if_error;
|
||||
use crate::search::slash_command_menu::static_commands::commands;
|
||||
use crate::settings::AISettings;
|
||||
use crate::terminal::input::tests::{add_window_with_bootstrapped_terminal, initialize_app};
|
||||
use galaxyui::{App, SingletonEntity as _};
|
||||
use settings::Setting as _;
|
||||
|
||||
#[test]
|
||||
fn test_parse_slash_command_handles_argument_rules() {
|
||||
@@ -459,7 +462,18 @@ fn test_submit_queued_prompt_routes_plain_text_to_conversation() {
|
||||
// It routes through detect_command (returning None) and falls through
|
||||
// to send_user_query_in_new_conversation.
|
||||
input.update(&mut app, |input, ctx| {
|
||||
input.submit_queued_prompt("fix the tests".to_string(), ctx);
|
||||
let conversation_id = AIConversationId::new();
|
||||
let query_id = QueuedQueryModel::handle(ctx).update(ctx, |model, ctx| {
|
||||
model.append(
|
||||
conversation_id,
|
||||
QueuedQuery::new(
|
||||
"fix the tests".to_owned(),
|
||||
QueuedQueryOrigin::QueueSlashCommand,
|
||||
),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
input.submit_queued_prompt("fix the tests".to_string(), conversation_id, query_id, ctx);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -491,7 +505,18 @@ fn test_submit_queued_prompt_detects_slash_command() {
|
||||
// submit_queued_prompt should detect the slash command and route through
|
||||
// execute_slash_command. This should not panic.
|
||||
input.update(&mut app, |input, ctx| {
|
||||
input.submit_queued_prompt(command_text, ctx);
|
||||
let conversation_id = AIConversationId::new();
|
||||
let query_id = QueuedQueryModel::handle(ctx).update(ctx, |model, ctx| {
|
||||
model.append(
|
||||
conversation_id,
|
||||
QueuedQuery::new(
|
||||
command_text.clone(),
|
||||
QueuedQueryOrigin::QueueSlashCommand,
|
||||
),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
input.submit_queued_prompt(command_text, conversation_id, query_id, ctx);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,55 +1,64 @@
|
||||
mod saved_prompts;
|
||||
mod zero_state;
|
||||
|
||||
use ai::skills::SkillProvider;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
pub(crate) use saved_prompts::*;
|
||||
pub use zero_state::*;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use ai::skills::SkillProvider;
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxyui::fonts::FamilyId;
|
||||
use galaxyui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
|
||||
use ordered_float::OrderedFloat;
|
||||
pub(crate) use saved_prompts::*;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use galaxy_cli::agent::Harness;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_core::ui::Icon as WarpIcon;
|
||||
pub use zero_state::*;
|
||||
|
||||
use crate::ai::blocklist::BlocklistAIHistoryModel;
|
||||
use super::AcceptSlashCommandOrSavedPrompt;
|
||||
use crate::ai::agent_conversations_model::{AgentConversationsModel, AgentConversationsModelEvent};
|
||||
use crate::ai::blocklist::agent_view::{AgentViewController, AgentViewControllerEvent};
|
||||
use crate::ai::blocklist::block::cli_controller::{CLISubagentController, CLISubagentEvent};
|
||||
use crate::ai::blocklist::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel};
|
||||
use crate::ai::skills::{SkillDescriptor, SkillManager};
|
||||
use crate::search::data_source::{Query, QueryResult};
|
||||
use crate::search::mixer::DataSourceRunErrorWrapper;
|
||||
use crate::search::slash_command_menu::fuzzy_match::SlashCommandFuzzyMatchResult;
|
||||
use crate::search::slash_command_menu::static_commands::commands::{self, COMMAND_REGISTRY};
|
||||
use crate::search::slash_command_menu::static_commands::Availability;
|
||||
use crate::search::slash_command_menu::{SlashCommandId, StaticCommand};
|
||||
use crate::search::SyncDataSource;
|
||||
use crate::settings::{
|
||||
AISettings, AISettingsChangedEvent, InputSettings, InputSettingsChangedEvent, PrivacySettings,
|
||||
PrivacySettingsChangedEvent,
|
||||
};
|
||||
use crate::terminal::cli_agent_sessions::{
|
||||
CLIAgentInputState, CLIAgentSessionsModel, CLIAgentSessionsModelEvent,
|
||||
};
|
||||
use crate::terminal::model::session::active_session::{ActiveSession, ActiveSessionEvent};
|
||||
use crate::terminal::model::session::SessionType;
|
||||
use galaxy_core::ui::Icon as GalaxyIcon;
|
||||
|
||||
use super::AcceptSlashCommandOrSavedPrompt;
|
||||
use crate::{
|
||||
ai::blocklist::{
|
||||
agent_view::{AgentViewController, AgentViewControllerEvent},
|
||||
block::cli_controller::{CLISubagentController, CLISubagentEvent},
|
||||
},
|
||||
search::{
|
||||
slash_command_menu::{
|
||||
static_commands::commands::{self, COMMAND_REGISTRY},
|
||||
SlashCommandId, StaticCommand,
|
||||
},
|
||||
SyncDataSource,
|
||||
},
|
||||
settings::{AISettings, AISettingsChangedEvent, InputSettings, InputSettingsChangedEvent},
|
||||
terminal::model::session::active_session::{ActiveSession, ActiveSessionEvent},
|
||||
workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent},
|
||||
};
|
||||
use crate::terminal::view::ambient_agent::AmbientAgentViewModel;
|
||||
use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent};
|
||||
|
||||
pub struct DataSourceArgs {
|
||||
pub active_session: ModelHandle<ActiveSession>,
|
||||
pub agent_view_controller: ModelHandle<AgentViewController>,
|
||||
pub cli_subagent_controller: ModelHandle<CLISubagentController>,
|
||||
pub terminal_view_id: EntityId,
|
||||
pub ambient_agent_view_model: Option<ModelHandle<AmbientAgentViewModel>>,
|
||||
}
|
||||
|
||||
/// Context needed to decide which slash commands are enabled.
|
||||
struct ActiveCommandsContext {
|
||||
session_context: Availability,
|
||||
is_orchestration_enabled: bool,
|
||||
is_cloud_handoff_enabled: bool,
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
active_conversation_is_cloud_oz: bool,
|
||||
has_default_host: bool,
|
||||
is_cli_agent_input: bool,
|
||||
}
|
||||
|
||||
pub struct SlashCommandDataSource {
|
||||
@@ -59,22 +68,33 @@ pub struct SlashCommandDataSource {
|
||||
terminal_view_id: EntityId,
|
||||
active_commands_by_id: HashMap<SlashCommandId, StaticCommand>,
|
||||
active_repo_root: Option<PathBuf>,
|
||||
ambient_agent_view_model: Option<ModelHandle<AmbientAgentViewModel>>,
|
||||
is_cloud_mode_v2: bool,
|
||||
}
|
||||
|
||||
impl SlashCommandDataSource {
|
||||
pub fn new(args: DataSourceArgs, ctx: &mut ModelContext<Self>) -> Self {
|
||||
Self::build(args, /* is_cloud_mode_v2 */ false, ctx)
|
||||
}
|
||||
|
||||
pub fn for_cloud_mode_v2(args: DataSourceArgs, ctx: &mut ModelContext<Self>) -> Self {
|
||||
Self::build(args, /* is_cloud_mode_v2 */ true, ctx)
|
||||
}
|
||||
|
||||
fn build(args: DataSourceArgs, is_cloud_mode_v2: bool, ctx: &mut ModelContext<Self>) -> Self {
|
||||
let DataSourceArgs {
|
||||
active_session,
|
||||
agent_view_controller,
|
||||
cli_subagent_controller,
|
||||
terminal_view_id,
|
||||
ambient_agent_view_model,
|
||||
} = args;
|
||||
ctx.subscribe_to_model(&active_session, |me, event, ctx| match event {
|
||||
ctx.subscribe_to_model(&active_session, |me, _, event, ctx| match event {
|
||||
ActiveSessionEvent::UpdatedPwd | ActiveSessionEvent::Bootstrapped => {
|
||||
me.recompute_active_commands(ctx);
|
||||
}
|
||||
});
|
||||
ctx.subscribe_to_model(&cli_subagent_controller, |me, event, ctx| {
|
||||
ctx.subscribe_to_model(&cli_subagent_controller, |me, _, event, ctx| {
|
||||
if let CLISubagentEvent::SpawnedSubagent { .. }
|
||||
| CLISubagentEvent::FinishedSubagent { .. }
|
||||
| CLISubagentEvent::UpdatedControl { .. } = event
|
||||
@@ -82,19 +102,31 @@ impl SlashCommandDataSource {
|
||||
me.recompute_active_commands(ctx);
|
||||
}
|
||||
});
|
||||
ctx.subscribe_to_model(&agent_view_controller, |me, event, ctx| match event {
|
||||
ctx.subscribe_to_model(&agent_view_controller, |me, _, event, ctx| match event {
|
||||
AgentViewControllerEvent::EnteredAgentView { .. }
|
||||
| AgentViewControllerEvent::ExitedAgentView { .. } => {
|
||||
me.recompute_active_commands(ctx);
|
||||
}
|
||||
_ => (),
|
||||
});
|
||||
ctx.subscribe_to_model(&AISettings::handle(ctx), |me, event, ctx| {
|
||||
if matches!(event, AISettingsChangedEvent::IsAnyAIEnabled { .. }) {
|
||||
ctx.subscribe_to_model(&AISettings::handle(ctx), |me, _, event, ctx| {
|
||||
if matches!(
|
||||
event,
|
||||
AISettingsChangedEvent::IsAnyAIEnabled { .. }
|
||||
| AISettingsChangedEvent::ShouldForceDisableCloudHandoff { .. }
|
||||
) {
|
||||
me.recompute_active_commands(ctx);
|
||||
}
|
||||
});
|
||||
ctx.subscribe_to_model(&InputSettings::handle(ctx), |me, event, ctx| {
|
||||
ctx.subscribe_to_model(&PrivacySettings::handle(ctx), |me, _, event, ctx| {
|
||||
if matches!(
|
||||
event,
|
||||
PrivacySettingsChangedEvent::UpdateIsCloudConversationStorageEnabled { .. }
|
||||
) {
|
||||
me.recompute_active_commands(ctx);
|
||||
}
|
||||
});
|
||||
ctx.subscribe_to_model(&InputSettings::handle(ctx), |me, _, event, ctx| {
|
||||
if matches!(
|
||||
event,
|
||||
InputSettingsChangedEvent::EnableSlashCommandsInTerminal { .. }
|
||||
@@ -102,14 +134,18 @@ impl SlashCommandDataSource {
|
||||
me.recompute_active_commands(ctx);
|
||||
}
|
||||
});
|
||||
ctx.subscribe_to_model(&UserWorkspaces::handle(ctx), |me, event, ctx| {
|
||||
if matches!(event, UserWorkspacesEvent::CodebaseContextEnablementChanged) {
|
||||
ctx.subscribe_to_model(&UserWorkspaces::handle(ctx), |me, _, event, ctx| {
|
||||
if matches!(
|
||||
event,
|
||||
UserWorkspacesEvent::CodebaseContextEnablementChanged
|
||||
| UserWorkspacesEvent::TeamsChanged
|
||||
) {
|
||||
me.recompute_active_commands(ctx);
|
||||
}
|
||||
});
|
||||
ctx.subscribe_to_model(
|
||||
&CLIAgentSessionsModel::handle(ctx),
|
||||
move |me, event, ctx| {
|
||||
move |me, _, event, ctx| {
|
||||
if let CLIAgentSessionsModelEvent::InputSessionChanged {
|
||||
terminal_view_id: event_terminal_view_id,
|
||||
..
|
||||
@@ -121,6 +157,34 @@ impl SlashCommandDataSource {
|
||||
}
|
||||
},
|
||||
);
|
||||
// Recompute when the active conversation switches so commands gated on the active
|
||||
// conversation's task (e.g. /continue-locally) update on navigation.
|
||||
ctx.subscribe_to_model(
|
||||
&BlocklistAIHistoryModel::handle(ctx),
|
||||
|me, _, event, ctx| {
|
||||
if matches!(
|
||||
event,
|
||||
BlocklistAIHistoryEvent::SetActiveConversation { .. }
|
||||
| BlocklistAIHistoryEvent::ClearedActiveConversation { .. }
|
||||
) {
|
||||
me.recompute_active_commands(ctx);
|
||||
}
|
||||
},
|
||||
);
|
||||
// Recompute when task data is updated so commands gated on a conversation's task
|
||||
// harness (e.g. /continue-locally) appear once the task fetch resolves.
|
||||
ctx.subscribe_to_model(
|
||||
&AgentConversationsModel::handle(ctx),
|
||||
|me, _, event, ctx| {
|
||||
if matches!(
|
||||
event,
|
||||
AgentConversationsModelEvent::TasksUpdated
|
||||
| AgentConversationsModelEvent::NewTasksReceived
|
||||
) {
|
||||
me.recompute_active_commands(ctx);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
let mut me = Self {
|
||||
active_session,
|
||||
@@ -129,6 +193,8 @@ impl SlashCommandDataSource {
|
||||
terminal_view_id,
|
||||
active_commands_by_id: Default::default(),
|
||||
active_repo_root: None,
|
||||
ambient_agent_view_model,
|
||||
is_cloud_mode_v2,
|
||||
};
|
||||
me.recompute_active_commands(ctx);
|
||||
me
|
||||
@@ -139,7 +205,38 @@ impl SlashCommandDataSource {
|
||||
/// for a running CLI agent (Claude Code, Codex, etc.).
|
||||
const CLI_AGENT_INPUT_ALLOWED_COMMANDS: &[&str] = &["/prompts", "/skills"];
|
||||
|
||||
fn is_cloud_mode(&self, ctx: &AppContext) -> bool {
|
||||
self.is_cloud_mode_v2
|
||||
|| (FeatureFlag::CloudMode.is_enabled()
|
||||
&& self
|
||||
.ambient_agent_view_model
|
||||
.as_ref()
|
||||
.is_some_and(|model| model.as_ref(ctx).is_ambient_agent()))
|
||||
}
|
||||
|
||||
fn recompute_active_commands(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
let active_commands_context = self.active_commands_context(ctx);
|
||||
|
||||
let old_active_command_count = self.active_commands_by_id.len();
|
||||
self.active_commands_by_id = HashMap::from_iter(
|
||||
COMMAND_REGISTRY
|
||||
.all_commands_by_id()
|
||||
.filter(|(_, command)| {
|
||||
self.command_is_active_in_context(command, &active_commands_context)
|
||||
})
|
||||
.map(|(id, command)| (id, command.clone())),
|
||||
);
|
||||
|
||||
// This is an imperfect heuristic, but better than re-firing unnecessarily.
|
||||
//
|
||||
// If it actually matters, we can update it.
|
||||
if self.active_commands_by_id.len() != old_active_command_count {
|
||||
ctx.emit(UpdatedActiveCommands);
|
||||
}
|
||||
}
|
||||
|
||||
/// Gather the context needed to check slash command availability.
|
||||
fn active_commands_context(&self, ctx: &AppContext) -> ActiveCommandsContext {
|
||||
let is_cli_agent_input = self.is_cli_agent_input_open(ctx);
|
||||
|
||||
let mut session_context = Availability::empty();
|
||||
@@ -197,37 +294,81 @@ impl SlashCommandDataSource {
|
||||
session_context |= Availability::AI_ENABLED;
|
||||
}
|
||||
|
||||
let is_orchestration_enabled = AISettings::as_ref(ctx).is_orchestration_enabled(ctx);
|
||||
|
||||
let old_active_command_count = self.active_commands_by_id.len();
|
||||
self.active_commands_by_id = HashMap::from_iter(
|
||||
COMMAND_REGISTRY
|
||||
.all_commands_by_id()
|
||||
.filter(|(_, command)| command.is_active(session_context))
|
||||
.filter(|(_, command)| {
|
||||
command.name != commands::ORCHESTRATE_NAME || is_orchestration_enabled
|
||||
})
|
||||
// The static `/feedback` command is an AI-off fallback for the richer bundled
|
||||
// `feedback` skill. Hide it whenever the bundled skill will actually take over,
|
||||
// matching the precedence used by `Workspace::send_feedback`.
|
||||
.filter(|(_, command)| {
|
||||
command.name != commands::FEEDBACK.name
|
||||
|| !crate::workspace::is_feedback_skill_available(ctx)
|
||||
})
|
||||
// When CLI agent input is open, restrict to the explicit allowlist.
|
||||
.filter(|(_, command)| {
|
||||
!is_cli_agent_input
|
||||
|| Self::CLI_AGENT_INPUT_ALLOWED_COMMANDS.contains(&command.name)
|
||||
})
|
||||
.map(|(id, command)| (id, command.clone())),
|
||||
);
|
||||
|
||||
// This is an imperfect heuristic, but better than re-firing unnecessarily.
|
||||
//
|
||||
// If it actually matters, we can update it.
|
||||
if self.active_commands_by_id.len() != old_active_command_count {
|
||||
ctx.emit(UpdatedActiveCommands);
|
||||
if self.is_cloud_mode_v2 && FeatureFlag::CloudModeInputV2.is_enabled() {
|
||||
session_context |= Availability::CLOUD_MODE_V2_COMPOSER;
|
||||
}
|
||||
|
||||
if self.is_cloud_mode(ctx) {
|
||||
session_context |= Availability::CLOUD_AGENT;
|
||||
} else {
|
||||
session_context |= Availability::NOT_CLOUD_AGENT;
|
||||
}
|
||||
|
||||
// Hide /host when no default host is configured (env var or workspace setting).
|
||||
let has_default_host = std::env::var("WARP_CLOUD_MODE_DEFAULT_HOST")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
.is_some()
|
||||
|| UserWorkspaces::as_ref(ctx).default_host_slug().is_some();
|
||||
|
||||
let ai_settings = AISettings::as_ref(ctx);
|
||||
ActiveCommandsContext {
|
||||
session_context,
|
||||
is_orchestration_enabled: ai_settings.is_orchestration_enabled(ctx),
|
||||
is_cloud_handoff_enabled: ai_settings.is_cloud_handoff_enabled(ctx),
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
active_conversation_is_cloud_oz: self.active_conversation_is_cloud_oz(ctx),
|
||||
has_default_host,
|
||||
is_cli_agent_input,
|
||||
}
|
||||
}
|
||||
|
||||
fn command_is_active_in_context(
|
||||
&self,
|
||||
command: &StaticCommand,
|
||||
context: &ActiveCommandsContext,
|
||||
) -> bool {
|
||||
if !command.is_active(context.session_context) {
|
||||
return false;
|
||||
}
|
||||
if command.name == commands::ORCHESTRATE_NAME && !context.is_orchestration_enabled {
|
||||
return false;
|
||||
}
|
||||
if command.name == commands::MOVE_TO_CLOUD.name && !context.is_cloud_handoff_enabled {
|
||||
return false;
|
||||
}
|
||||
if command.name == commands::FORK.name
|
||||
&& context
|
||||
.session_context
|
||||
.contains(Availability::CLOUD_MODE_V2_COMPOSER)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// /continue-locally only applies to cloud Oz conversations. Non-Oz cloud runs
|
||||
// (Claude, Gemini) are filtered out so the slash menu doesn't surface a no-op command.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
if command.name == commands::CONTINUE_LOCALLY.name
|
||||
&& !context.active_conversation_is_cloud_oz
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// /host is only useful when a default self-hosted host is configured.
|
||||
if command.name == commands::HOST.name && !context.has_default_host {
|
||||
return false;
|
||||
}
|
||||
// When CLI agent input is open, restrict to the explicit allowlist.
|
||||
if context.is_cli_agent_input
|
||||
&& !Self::CLI_AGENT_INPUT_ALLOWED_COMMANDS.contains(&command.name)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn command_is_active(&self, command: &StaticCommand, ctx: &AppContext) -> bool {
|
||||
let active_commands_context = self.active_commands_context(ctx);
|
||||
self.command_is_active_in_context(command, &active_commands_context)
|
||||
}
|
||||
|
||||
/// Update the active repository root for this terminal. Called by the parent when
|
||||
@@ -251,6 +392,10 @@ impl SlashCommandDataSource {
|
||||
self.agent_view_controller.as_ref(ctx).is_active()
|
||||
}
|
||||
|
||||
pub fn active_session_for_v2_zero_state(&self) -> &ModelHandle<ActiveSession> {
|
||||
&self.active_session
|
||||
}
|
||||
|
||||
/// Returns `true` if the CLI agent rich input is currently open for this terminal.
|
||||
pub fn is_cli_agent_input_open(&self, ctx: &AppContext) -> bool {
|
||||
CLIAgentSessionsModel::as_ref(ctx).is_input_open(self.terminal_view_id)
|
||||
@@ -267,6 +412,57 @@ impl SlashCommandDataSource {
|
||||
.filter(|s| matches!(s.input_state, CLIAgentInputState::Open { .. }))
|
||||
.map(|s| s.agent.supported_skill_providers())
|
||||
}
|
||||
|
||||
/// Returns true when the active conversation is associated with a cloud Oz
|
||||
/// `AmbientAgentTask`. Used to gate `/continue-locally` to runs that can
|
||||
/// actually be forked into a local Warp conversation.
|
||||
///
|
||||
/// Permissive when the harness is not yet known: we consider an absent task or
|
||||
/// missing `agent_config_snapshot.harness` to be Oz, matching the existing
|
||||
/// tombstone gate (`conversation_ended_tombstone_view::render_action_buttons`).
|
||||
/// Only an explicit non-Oz harness (Claude, Gemini, OpenCode, Unknown) hides the
|
||||
/// command. Conversations without a `task_id` are local and never qualify.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn active_conversation_is_cloud_oz(&self, ctx: &AppContext) -> bool {
|
||||
let conversation_id = match self
|
||||
.agent_view_controller
|
||||
.as_ref(ctx)
|
||||
.agent_view_state()
|
||||
.active_conversation_id()
|
||||
{
|
||||
Some(id) => id,
|
||||
None => match BlocklistAIHistoryModel::as_ref(ctx)
|
||||
.active_conversation(self.terminal_view_id)
|
||||
{
|
||||
Some(conv) => conv.id(),
|
||||
None => return false,
|
||||
},
|
||||
};
|
||||
|
||||
let history = BlocklistAIHistoryModel::as_ref(ctx);
|
||||
let Some(conversation) = history.conversation(&conversation_id) else {
|
||||
return false;
|
||||
};
|
||||
let Some(task_id) = conversation.task_id() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let Some(task) = AgentConversationsModel::as_ref(ctx).get_task_data(&task_id) else {
|
||||
// Task data not yet fetched. Permissive default: assume Oz so the command
|
||||
// is reachable while the fetch is in flight; once the fetch resolves,
|
||||
// `TasksUpdated` triggers a recompute and a non-Oz task hides the command.
|
||||
return true;
|
||||
};
|
||||
|
||||
match task
|
||||
.agent_config_snapshot
|
||||
.as_ref()
|
||||
.and_then(|s| s.harness.as_ref())
|
||||
{
|
||||
Some(config) => config.harness_type == Harness::Oz,
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SyncDataSource for SlashCommandDataSource {
|
||||
@@ -309,6 +505,7 @@ impl SyncDataSource for SlashCommandDataSource {
|
||||
InlineItem::from_slash_command(id, command, app)
|
||||
.with_name_match_result(fuzzy_result.name_match_result)
|
||||
.with_description_match_result(fuzzy_result.description_match_result)
|
||||
.with_compact_layout(self.is_cloud_mode_v2)
|
||||
.with_score(
|
||||
OrderedFloat(score) * SCORE_MULTIPLIER
|
||||
+ OrderedFloat(prefix_boost) * SCORE_MULTIPLIER
|
||||
@@ -324,11 +521,11 @@ impl SyncDataSource for SlashCommandDataSource {
|
||||
// Skills are invoked by the agent, so they're hidden entirely when AI is globally off.
|
||||
if FeatureFlag::ListSkills.is_enabled() && AISettings::as_ref(app).is_any_ai_enabled(app) {
|
||||
let cli_agent_providers = self.active_cli_agent_providers(app);
|
||||
let cwd = self.active_session.as_ref(app).current_working_directory();
|
||||
let cwd_path = cwd.as_ref().map(std::path::Path::new);
|
||||
let active_session = self.active_session.as_ref(app);
|
||||
let cwd_path = active_session.current_working_directory_location(app);
|
||||
let skills = SkillManager::handle(app)
|
||||
.as_ref(app)
|
||||
.get_skills_for_working_directory(cwd_path, app);
|
||||
.get_skills_for_working_directory(cwd_path.as_ref(), app);
|
||||
|
||||
let skill_manager = SkillManager::as_ref(app);
|
||||
for mut skill in skills {
|
||||
@@ -362,6 +559,7 @@ impl SyncDataSource for SlashCommandDataSource {
|
||||
InlineItem::from_skill(&skill, app)
|
||||
.with_name_match_result(fuzzy_result.name_match_result)
|
||||
.with_description_match_result(fuzzy_result.description_match_result)
|
||||
.with_compact_layout(self.is_cloud_mode_v2)
|
||||
.with_score(
|
||||
OrderedFloat(score) * SCORE_MULTIPLIER
|
||||
+ OrderedFloat(prefix_boost) * SCORE_MULTIPLIER
|
||||
@@ -412,6 +610,7 @@ pub struct InlineItem {
|
||||
pub name_match_result: Option<FuzzyMatchResult>,
|
||||
pub description_match_result: Option<FuzzyMatchResult>,
|
||||
pub score: OrderedFloat<f64>,
|
||||
pub compact_layout: bool,
|
||||
}
|
||||
|
||||
impl InlineItem {
|
||||
@@ -430,6 +629,27 @@ impl InlineItem {
|
||||
name_match_result: None,
|
||||
description_match_result: None,
|
||||
score: OrderedFloat(f64::MIN),
|
||||
compact_layout: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn from_saved_prompt(
|
||||
saved_prompt: &crate::workflows::CloudWorkflow,
|
||||
app: &AppContext,
|
||||
) -> Self {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
Self {
|
||||
action: AcceptSlashCommandOrSavedPrompt::SavedPrompt {
|
||||
id: saved_prompt.id,
|
||||
},
|
||||
icon_path: "bundled/svg/prompt.svg",
|
||||
name: saved_prompt.model().data.name().to_owned(),
|
||||
description: None,
|
||||
font_family: appearance.ui_font_family(),
|
||||
name_match_result: None,
|
||||
description_match_result: None,
|
||||
score: OrderedFloat(f64::MIN),
|
||||
compact_layout: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,6 +682,7 @@ impl InlineItem {
|
||||
name_match_result: None,
|
||||
description_match_result: None,
|
||||
score: OrderedFloat(f64::MIN),
|
||||
compact_layout: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -479,8 +700,13 @@ impl InlineItem {
|
||||
self.score = score;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_compact_layout(mut self, compact: bool) -> Self {
|
||||
self.compact_layout = compact;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_test.rs"]
|
||||
#[path = "mod_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
+1
-2
@@ -1,6 +1,5 @@
|
||||
use crate::search::slash_command_menu::fuzzy_match::SlashCommandFuzzyMatchResult;
|
||||
|
||||
use super::prefix_match_bonus;
|
||||
use crate::search::slash_command_menu::fuzzy_match::SlashCommandFuzzyMatchResult;
|
||||
|
||||
#[test]
|
||||
fn exact_match_returns_full_bonus() {
|
||||
@@ -6,6 +6,7 @@ use galaxyui::fonts::FamilyId;
|
||||
use galaxyui::{AppContext, SingletonEntity};
|
||||
use ordered_float::OrderedFloat;
|
||||
|
||||
use super::{AcceptSlashCommandOrSavedPrompt, InlineItem};
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::cloud_object::CloudObject;
|
||||
use crate::search::async_snapshot_data_source::AsyncSnapshotDataSource;
|
||||
@@ -16,8 +17,6 @@ use crate::server::ids::SyncId;
|
||||
use crate::settings::AISettings;
|
||||
use crate::workflows::CloudWorkflowModel;
|
||||
|
||||
use super::{AcceptSlashCommandOrSavedPrompt, InlineItem};
|
||||
|
||||
pub(super) struct SavedPromptCandidate {
|
||||
pub(super) id: SyncId,
|
||||
pub(super) model: Arc<CloudWorkflowModel>,
|
||||
@@ -114,6 +113,7 @@ pub(crate) fn fuzzy_match_saved_prompts(
|
||||
name_match_result,
|
||||
description_match_result: None,
|
||||
score: OrderedFloat(100.0),
|
||||
compact_layout: false,
|
||||
};
|
||||
results.push(QueryResult::from(item));
|
||||
}
|
||||
@@ -143,6 +143,7 @@ pub(crate) fn fuzzy_match_saved_prompts(
|
||||
name_match_result: match_result.name_match_result,
|
||||
description_match_result: match_result.content_match_result,
|
||||
score,
|
||||
compact_layout: false,
|
||||
};
|
||||
results.push(QueryResult::from(item));
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::server::ids::{ClientId, SyncId};
|
||||
use crate::workflows::workflow::Workflow;
|
||||
use crate::workflows::CloudWorkflowModel;
|
||||
use ordered_float::OrderedFloat;
|
||||
|
||||
use super::{fuzzy_match_saved_prompts, SavedPromptCandidate, SavedPromptsSnapshot};
|
||||
use crate::server::ids::{ClientId, SyncId};
|
||||
use crate::workflows::workflow::Workflow;
|
||||
use crate::workflows::CloudWorkflowModel;
|
||||
|
||||
const TEST_FONT_FAMILY: galaxyui::fonts::FamilyId = galaxyui::fonts::FamilyId(0);
|
||||
|
||||
|
||||
@@ -1,22 +1,32 @@
|
||||
use galaxyui::{Entity, ModelHandle};
|
||||
use itertools::Itertools;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxyui::{Entity, ModelHandle, SingletonEntity};
|
||||
|
||||
use crate::ai::skills::SkillManager;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::search::data_source::{Query, QueryResult};
|
||||
use crate::search::mixer::DataSourceRunErrorWrapper;
|
||||
use crate::search::slash_command_menu::static_commands::commands;
|
||||
use crate::search::SyncDataSource;
|
||||
use crate::settings::AISettings;
|
||||
use crate::terminal::input::slash_commands::{
|
||||
AcceptSlashCommandOrSavedPrompt, InlineItem, SlashCommandDataSource,
|
||||
};
|
||||
|
||||
pub struct ZeroStateDataSource {
|
||||
slash_command_data_source: ModelHandle<SlashCommandDataSource>,
|
||||
is_cloud_mode_v2: bool,
|
||||
}
|
||||
|
||||
impl ZeroStateDataSource {
|
||||
pub fn new(slash_command_data_source: &ModelHandle<SlashCommandDataSource>) -> Self {
|
||||
pub fn new(
|
||||
slash_command_data_source: &ModelHandle<SlashCommandDataSource>,
|
||||
is_cloud_mode_v2: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
slash_command_data_source: slash_command_data_source.clone(),
|
||||
is_cloud_mode_v2,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,7 +80,9 @@ impl SyncDataSource for ZeroStateDataSource {
|
||||
active_prioritized_commands.push((active_command_id, active_command));
|
||||
} else {
|
||||
results.push(
|
||||
InlineItem::from_slash_command(active_command_id, active_command, app).into(),
|
||||
InlineItem::from_slash_command(active_command_id, active_command, app)
|
||||
.with_compact_layout(self.is_cloud_mode_v2)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -80,7 +92,64 @@ impl SyncDataSource for ZeroStateDataSource {
|
||||
.iter()
|
||||
.find(|(_, active_command)| active_command.name == prioritized_command.name)
|
||||
{
|
||||
results.push(InlineItem::from_slash_command(id, command, app).into());
|
||||
results.push(
|
||||
InlineItem::from_slash_command(id, command, app)
|
||||
.with_compact_layout(self.is_cloud_mode_v2)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if self.is_cloud_mode_v2
|
||||
&& FeatureFlag::ListSkills.is_enabled()
|
||||
&& AISettings::as_ref(app).is_any_ai_enabled(app)
|
||||
{
|
||||
let slash_command_data_source = self.slash_command_data_source.as_ref(app);
|
||||
let cli_agent_providers = slash_command_data_source.active_cli_agent_providers(app);
|
||||
let active_session = slash_command_data_source
|
||||
.active_session_for_v2_zero_state()
|
||||
.as_ref(app);
|
||||
let cwd = active_session.current_working_directory_location(app);
|
||||
let skill_manager_handle = SkillManager::handle(app);
|
||||
let skill_manager = skill_manager_handle.as_ref(app);
|
||||
let skills = skill_manager.get_skills_for_working_directory(cwd.as_ref(), app);
|
||||
|
||||
for mut skill in skills
|
||||
.into_iter()
|
||||
.sorted_by(|a, b| b.name.to_lowercase().cmp(&a.name.to_lowercase()))
|
||||
{
|
||||
if let Some(providers) = &cli_agent_providers {
|
||||
if !skill_manager.skill_exists_for_any_provider(&skill, providers) {
|
||||
continue;
|
||||
}
|
||||
skill.provider = skill_manager.best_supported_provider(&skill, providers);
|
||||
}
|
||||
results.push(
|
||||
InlineItem::from_skill(&skill, app)
|
||||
.with_compact_layout(self.is_cloud_mode_v2)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if self.is_cloud_mode_v2 && AISettings::as_ref(app).is_any_ai_enabled(app) {
|
||||
let saved_prompts: Vec<_> = CloudModel::as_ref(app)
|
||||
.get_all_active_workflows()
|
||||
.filter(|cw| cw.model().data.is_agent_mode_workflow())
|
||||
.sorted_by(|a, b| {
|
||||
b.model()
|
||||
.data
|
||||
.name()
|
||||
.to_lowercase()
|
||||
.cmp(&a.model().data.name().to_lowercase())
|
||||
})
|
||||
.collect();
|
||||
for saved_prompt in saved_prompts {
|
||||
results.push(
|
||||
InlineItem::from_saved_prompt(saved_prompt, app)
|
||||
.with_compact_layout(self.is_cloud_mode_v2)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,43 @@
|
||||
mod cloud_mode_v2_view;
|
||||
mod data_source;
|
||||
mod search_item;
|
||||
mod view;
|
||||
pub(super) mod view;
|
||||
|
||||
pub use data_source::*;
|
||||
pub use view::*;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use std::path::PathBuf;
|
||||
|
||||
use ai::skills::SkillReference;
|
||||
pub use cloud_mode_v2_view::{CloudModeV2SlashCommandView, Section as CloudModeV2Section};
|
||||
pub use data_source::*;
|
||||
pub use view::{CloseReason, InlineSlashCommandView, SlashCommandsEvent};
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use galaxy_cli::agent::Harness;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_core::send_telemetry_from_ctx;
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_core::ui::theme::AnsiColorIdentifier;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use galaxy_util::path::{CleanPathResult, LineAndColumnArg};
|
||||
use galaxyui::clipboard::ClipboardContent;
|
||||
use galaxyui::{SingletonEntity, ViewContext};
|
||||
use galaxyui::{AppContext, SingletonEntity, ViewContext};
|
||||
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::ai::agent_conversations_model::AgentConversationsModel;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::ai::agent_management::telemetry::AgentManagementTelemetryEvent;
|
||||
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
|
||||
use crate::ai::ambient_agents::telemetry::HandoffEntryPoint;
|
||||
use crate::ai::blocklist::agent_view::{
|
||||
AgentViewEntryOrigin, DismissalStrategy, EphemeralMessage, ENTER_OR_EXIT_CONFIRMATION_WINDOW,
|
||||
};
|
||||
use crate::ai::blocklist::{BlocklistAIHistoryModel, SlashCommandRequest};
|
||||
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
|
||||
use crate::ai::blocklist::handoff::PendingCloudLaunch;
|
||||
use crate::ai::blocklist::{
|
||||
BlocklistAIHistoryModel, InputTypeAutoDetectionSource, PendingAttachment, QueuedQuery,
|
||||
QueuedQueryId, QueuedQueryModel, QueuedQueryOrigin, SlashCommandRequest,
|
||||
};
|
||||
use crate::ai::conversation_rename::rename_conversation;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::code_review::telemetry_event::CodeReviewPaneEntrypoint;
|
||||
use crate::search::slash_command_menu::static_commands::commands::{self, COMMAND_REGISTRY};
|
||||
@@ -24,16 +46,21 @@ use crate::search::slash_command_menu::{SlashCommandId, StaticCommand};
|
||||
use crate::server::ids::SyncId;
|
||||
use crate::server::telemetry::SlashCommandAcceptedDetails;
|
||||
use crate::settings::AISettings;
|
||||
use crate::tab::SelectedTabColor;
|
||||
use crate::terminal::input::decorations::InputBackgroundJobOptions;
|
||||
use crate::terminal::input::inline_menu::{InlineMenuAction, InlineMenuType};
|
||||
use crate::terminal::input::message_bar::Message;
|
||||
use crate::terminal::input::models::InlineModelSelectorTab;
|
||||
use crate::terminal::input::slash_command_model::{
|
||||
SlashCommandEntryState, UpdatedSlashCommandModel,
|
||||
};
|
||||
use crate::terminal::input::{
|
||||
CompletionsTrigger, Event, Input, InputSuggestionsMode, UserQueryMenuAction,
|
||||
CompletionsTrigger, Event, Input, InputAction, InputSuggestionsMode, UserQueryMenuAction,
|
||||
};
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::terminal::model::session::Session;
|
||||
use crate::terminal::view::TerminalAction;
|
||||
use crate::ui_components::color_dot;
|
||||
use crate::view_components::DismissibleToast;
|
||||
use crate::workflows::{WorkflowSelectionSource, WorkflowSource, WorkflowType};
|
||||
use crate::workspace::{ForkedConversationDestination, ToastStack, WorkspaceAction};
|
||||
@@ -94,16 +121,61 @@ impl SlashCommandTrigger {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn open_file_command_path(
|
||||
session: &Session,
|
||||
current_dir: &str,
|
||||
raw_arg: &str,
|
||||
) -> (PathBuf, Option<LineAndColumnArg>) {
|
||||
let parsed_path = CleanPathResult::with_line_and_column_number(raw_arg.trim());
|
||||
// The argument may contain shell-escaped characters (e.g. `\ ` for spaces) from auto-suggest.
|
||||
// Unescape them so the path matches the actual filesystem entry.
|
||||
let unescaped_path = session.shell_family().unescape(&parsed_path.path);
|
||||
// Expand `~` to the user's home directory.
|
||||
let expanded_path = shellexpand::tilde(&unescaped_path);
|
||||
|
||||
let shell_path = session
|
||||
.convert_directory_to_typed_path_buf(current_dir.to_owned())
|
||||
.join(session.convert_directory_to_typed_path_buf(expanded_path.into_owned()))
|
||||
.normalize();
|
||||
let file_path = session
|
||||
.maybe_convert_to_native_path(&shell_path.to_path())
|
||||
.unwrap_or_else(|err| {
|
||||
log::warn!("unable to convert /open-file path to native path: {err:?}");
|
||||
PathBuf::from(shell_path.to_string_lossy().into_owned())
|
||||
});
|
||||
|
||||
(file_path, parsed_path.line_and_column_num)
|
||||
}
|
||||
|
||||
impl Input {
|
||||
fn is_slash_command_available(&self, command: &StaticCommand, ctx: &AppContext) -> bool {
|
||||
let slash_command_data_source = if self.is_cloud_mode_input_v2_composing(ctx) {
|
||||
let Some(data_source) = self.cloud_mode_composer_slash_command_data_source.as_ref()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
data_source
|
||||
} else {
|
||||
&self.slash_command_data_source
|
||||
};
|
||||
slash_command_data_source
|
||||
.as_ref(ctx)
|
||||
.command_is_active(command, ctx)
|
||||
}
|
||||
|
||||
pub(super) fn select_slash_command(
|
||||
&mut self,
|
||||
command: &StaticCommand,
|
||||
trigger: SlashCommandTrigger,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if !self.is_slash_command_available(command, ctx) {
|
||||
return;
|
||||
}
|
||||
if command.argument.as_ref().is_none() {
|
||||
self.execute_slash_command(
|
||||
command, None, trigger, /*is_queued_prompt*/ false, ctx,
|
||||
command, None, trigger, /*is_queued_prompt*/ false, None, None, ctx,
|
||||
);
|
||||
} else if command
|
||||
.argument
|
||||
@@ -123,6 +195,8 @@ impl Input {
|
||||
argument.as_ref(),
|
||||
trigger,
|
||||
/*is_queued_prompt*/ false,
|
||||
None,
|
||||
None,
|
||||
ctx,
|
||||
);
|
||||
} else {
|
||||
@@ -186,7 +260,7 @@ impl Input {
|
||||
if detected_command.command.auto_enter_ai_mode
|
||||
|| !FeatureFlag::AgentView.is_enabled()
|
||||
{
|
||||
self.enter_ai_mode(ctx);
|
||||
self.enter_ai_mode(Some(InputTypeAutoDetectionSource::SlashCommand), ctx);
|
||||
}
|
||||
|
||||
if detected_command.command.name == commands::EDIT.name
|
||||
@@ -196,7 +270,7 @@ impl Input {
|
||||
.is_some_and(|argument| argument.is_empty())
|
||||
&& self.suggestions_mode_model.as_ref(ctx).is_closed()
|
||||
{
|
||||
self.open_completion_suggestions(CompletionsTrigger::Keybinding, ctx);
|
||||
self.open_completion_suggestions(CompletionsTrigger::SlashCommandAutoOpen, ctx);
|
||||
}
|
||||
}
|
||||
SlashCommandEntryState::SkillCommand(detected_skill) => {
|
||||
@@ -213,7 +287,7 @@ impl Input {
|
||||
}
|
||||
|
||||
// Skill commands always require AI mode
|
||||
self.enter_ai_mode(ctx);
|
||||
self.enter_ai_mode(Some(InputTypeAutoDetectionSource::SlashCommand), ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -291,12 +365,15 @@ impl Input {
|
||||
/// the agent was busy.
|
||||
///
|
||||
/// Returns `true` if execution was 'handled' (whether or not it resulted in success or failure).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn execute_slash_command(
|
||||
&mut self,
|
||||
command: &StaticCommand,
|
||||
argument: Option<&String>,
|
||||
trigger: SlashCommandTrigger,
|
||||
is_queued_prompt: bool,
|
||||
queued_conversation_id: Option<AIConversationId>,
|
||||
queued_query_id: Option<QueuedQueryId>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> bool {
|
||||
fn show_error_toast(message: String, ctx: &mut ViewContext<Input>) {
|
||||
@@ -399,8 +476,20 @@ impl Input {
|
||||
_create_docker_sandbox if command.name == commands::CREATE_DOCKER_SANDBOX.name => {
|
||||
ctx.emit(Event::CreateDockerSandbox);
|
||||
}
|
||||
_conversations if command.name == commands::CONVERSATIONS.name => {
|
||||
if FeatureFlag::AgentView.is_enabled() {
|
||||
conversations if command.name == commands::CONVERSATIONS.name => {
|
||||
if self.is_cloud_mode_input_v2_composing(ctx) {
|
||||
self.suggestions_mode_model.update(ctx, |model, ctx| {
|
||||
model.set_mode(InputSuggestionsMode::Closed, ctx);
|
||||
});
|
||||
self.clear_buffer_and_reset_undo_stack(ctx);
|
||||
if let Some(view) = self.cloud_mode_v2_history_menu_view.clone() {
|
||||
view.update(ctx, |v, ctx| {
|
||||
v.arm_initial_buffer_sync(ctx);
|
||||
});
|
||||
}
|
||||
ctx.dispatch_typed_action_deferred(InputAction::OpenInlineHistoryMenu);
|
||||
return true;
|
||||
} else if FeatureFlag::AgentView.is_enabled() {
|
||||
self.open_conversation_menu(ctx);
|
||||
} else {
|
||||
ctx.dispatch_typed_action(&TerminalAction::OpenConversationsPalette);
|
||||
@@ -420,7 +509,69 @@ impl Input {
|
||||
|
||||
ctx.dispatch_typed_action(&WorkspaceAction::SetActiveTabName(name.to_owned()));
|
||||
}
|
||||
_create_env if command.name == commands::CREATE_ENVIRONMENT.name => {
|
||||
_ if command.name == commands::RENAME_CONVERSATION.name => {
|
||||
let Some(conversation_id) = self
|
||||
.ai_context_model
|
||||
.as_ref(ctx)
|
||||
.selected_conversation_id(ctx)
|
||||
else {
|
||||
show_error_toast(
|
||||
"/rename-conversation requires an active conversation".to_owned(),
|
||||
ctx,
|
||||
);
|
||||
return true;
|
||||
};
|
||||
rename_conversation(conversation_id, argument.cloned().unwrap_or_default(), ctx);
|
||||
}
|
||||
set_tab_color if command.name == commands::SET_TAB_COLOR.name => {
|
||||
let supported_options = || {
|
||||
color_dot::TAB_COLOR_OPTIONS
|
||||
.iter()
|
||||
.map(|c| c.to_string().to_ascii_lowercase())
|
||||
.chain(std::iter::once("none".to_owned()))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
};
|
||||
|
||||
let Some(arg) = argument
|
||||
.map(|name| name.trim())
|
||||
.filter(|name| !name.is_empty())
|
||||
else {
|
||||
show_error_toast(
|
||||
format!(
|
||||
"Please provide a color after /set-tab-color ({})",
|
||||
supported_options()
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
return true;
|
||||
};
|
||||
|
||||
let color = if arg.eq_ignore_ascii_case("none") {
|
||||
SelectedTabColor::Cleared
|
||||
} else {
|
||||
let parsed = arg
|
||||
.parse::<AnsiColorIdentifier>()
|
||||
.ok()
|
||||
.filter(|c| color_dot::TAB_COLOR_OPTIONS.contains(c));
|
||||
match parsed {
|
||||
Some(c) => SelectedTabColor::Color(c),
|
||||
None => {
|
||||
show_error_toast(
|
||||
format!(
|
||||
"Unknown tab color '{arg}'. Use one of: {}.",
|
||||
supported_options()
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ctx.dispatch_typed_action(&WorkspaceAction::SetActiveTabColor(color));
|
||||
}
|
||||
create_env if command.name == commands::CREATE_ENVIRONMENT.name => {
|
||||
// If the user included args after the slash command, treat them as repo paths/URLs.
|
||||
let repos = argument
|
||||
.map(|arg| {
|
||||
@@ -450,9 +601,6 @@ impl Input {
|
||||
#[cfg(feature = "local_fs")]
|
||||
match argument {
|
||||
Some(args) if !args.is_empty() => {
|
||||
use galaxy_util::path::CleanPathResult;
|
||||
use shellexpand::tilde;
|
||||
|
||||
let Some(session_id) = self.active_block_session_id() else {
|
||||
return false;
|
||||
};
|
||||
@@ -480,20 +628,14 @@ impl Input {
|
||||
.active_block_metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.current_working_directory())
|
||||
.map(std::path::PathBuf::from);
|
||||
.map(str::to_owned);
|
||||
|
||||
let Some(current_dir) = current_dir else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let parsed_path = CleanPathResult::with_line_and_column_number(args.trim());
|
||||
// The argument may contain shell-escaped characters (e.g. `\ ` for
|
||||
// spaces) from auto-suggest. Unescape them so the path matches the
|
||||
// actual filesystem entry.
|
||||
let unescaped_path = session.shell_family().unescape(&parsed_path.path);
|
||||
// Expand `~` to the user's home directory.
|
||||
let expanded_path = tilde(&unescaped_path);
|
||||
let file_path = current_dir.join(&*expanded_path);
|
||||
let (file_path, line_col) =
|
||||
open_file_command_path(&session, ¤t_dir, args);
|
||||
|
||||
match std::fs::metadata(&file_path) {
|
||||
Ok(metadata) if metadata.is_file() => {
|
||||
@@ -502,7 +644,7 @@ impl Input {
|
||||
ctx.dispatch_typed_action(&TerminalAction::OpenCodeInWarp {
|
||||
path: file_path,
|
||||
layout: external_editor::settings::EditorLayout::SplitPane,
|
||||
line_col: parsed_path.line_and_column_num,
|
||||
line_col,
|
||||
});
|
||||
}
|
||||
Ok(_) => {
|
||||
@@ -627,11 +769,82 @@ impl Input {
|
||||
if !FeatureFlag::ListSkills.is_enabled() {
|
||||
return false;
|
||||
}
|
||||
if self.is_cloud_mode_input_v2_composing(ctx) {
|
||||
self.apply_v2_slash_section_filter(CloudModeV2Section::Skills, ctx);
|
||||
return true;
|
||||
}
|
||||
// Open the skill selector menu for invocation - skill command will be inserted into buffer
|
||||
self.open_invoke_skill_selector(ctx);
|
||||
}
|
||||
_models if command.name == commands::MODEL.name => {
|
||||
self.open_model_selector(ctx);
|
||||
host if command.name == commands::HOST.name => {
|
||||
if !self.is_cloud_mode_input_v2_composing(ctx) {
|
||||
return false;
|
||||
}
|
||||
// Only open the host selector when a default host is configured.
|
||||
if self
|
||||
.host_selector()
|
||||
.is_none_or(|h| !h.as_ref(ctx).has_default_host())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.suggestions_mode_model.update(ctx, |model, ctx| {
|
||||
model.set_mode(InputSuggestionsMode::Closed, ctx);
|
||||
});
|
||||
self.clear_buffer_and_reset_undo_stack(ctx);
|
||||
self.open_v2_host_selector(ctx);
|
||||
return true;
|
||||
}
|
||||
harness if command.name == commands::HARNESS.name => {
|
||||
if !self.is_cloud_mode_input_v2_composing(ctx) {
|
||||
// Defensive: the command is registered only when the V2 flag is on and its
|
||||
// availability requires CLOUD_MODE_V2_COMPOSER, so this branch should be unreachable.
|
||||
return false;
|
||||
}
|
||||
self.suggestions_mode_model.update(ctx, |model, ctx| {
|
||||
model.set_mode(InputSuggestionsMode::Closed, ctx);
|
||||
});
|
||||
self.clear_buffer_and_reset_undo_stack(ctx);
|
||||
self.open_v2_harness_selector(ctx);
|
||||
return true;
|
||||
}
|
||||
environment if command.name == commands::ENVIRONMENT.name => {
|
||||
if !self.is_cloud_mode_input_v2_composing(ctx) {
|
||||
return false;
|
||||
}
|
||||
self.suggestions_mode_model.update(ctx, |model, ctx| {
|
||||
model.set_mode(InputSuggestionsMode::Closed, ctx);
|
||||
});
|
||||
self.clear_buffer_and_reset_undo_stack(ctx);
|
||||
self.open_v2_environment_selector(ctx);
|
||||
return true;
|
||||
}
|
||||
models if command.name == commands::MODEL.name => {
|
||||
if self.is_cloud_mode_input_v2_composing(ctx) {
|
||||
self.suggestions_mode_model.update(ctx, |model, ctx| {
|
||||
model.set_mode(InputSuggestionsMode::Closed, ctx);
|
||||
});
|
||||
self.clear_buffer_and_reset_undo_stack(ctx);
|
||||
self.agent_input_footer.update(ctx, |footer, ctx| {
|
||||
footer.open_v2_model_selector(ctx);
|
||||
});
|
||||
return true;
|
||||
} else if trigger.is_keybinding() {
|
||||
// A keybinding may carry a pre-existing prompt in the buffer; open
|
||||
// like the model chip so the prompt is parked for search and
|
||||
// restored when a model is selected (or the selector is dismissed).
|
||||
self.open_model_selector_and_snapshot_prompt(
|
||||
InlineModelSelectorTab::BaseAgent,
|
||||
ctx,
|
||||
);
|
||||
} else {
|
||||
// Typed `/model`: the buffer holds the consumable command text.
|
||||
// Just switch into the model selector; `set_mode` snapshots the
|
||||
// buffer so it's restored on dismiss but cleared on selection.
|
||||
self.suggestions_mode_model.update(ctx, |model, ctx| {
|
||||
model.set_mode(InputSuggestionsMode::ModelSelector, ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
_profiles if command.name == commands::PROFILE.name => {
|
||||
if !FeatureFlag::InlineProfileSelector.is_enabled() {
|
||||
@@ -640,7 +853,11 @@ impl Input {
|
||||
|
||||
self.open_profile_selector(ctx);
|
||||
}
|
||||
_prompts if command.name == commands::PROMPTS.name => {
|
||||
prompts if command.name == commands::PROMPTS.name => {
|
||||
if self.is_cloud_mode_input_v2_composing(ctx) {
|
||||
self.apply_v2_slash_section_filter(CloudModeV2Section::Prompts, ctx);
|
||||
return true;
|
||||
}
|
||||
if FeatureFlag::AgentView.is_enabled() {
|
||||
self.open_prompts_menu(ctx);
|
||||
} else {
|
||||
@@ -698,24 +915,53 @@ impl Input {
|
||||
ctx.dispatch_typed_action(&TerminalAction::ToggleUsageFooter);
|
||||
}
|
||||
}
|
||||
_context if command.name == commands::CONTEXT.name => {
|
||||
let history = BlocklistAIHistoryModel::handle(ctx);
|
||||
let conversation = history
|
||||
.as_ref(ctx)
|
||||
.active_conversation(self.terminal_view_id);
|
||||
if conversation.is_none() {
|
||||
show_error_toast(
|
||||
"Cannot show context: no active conversation".to_owned(),
|
||||
ctx,
|
||||
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
|
||||
move_to_cloud if command.name == commands::MOVE_TO_CLOUD.name => {
|
||||
if !AISettings::as_ref(ctx).is_cloud_handoff_enabled(ctx) {
|
||||
return false;
|
||||
}
|
||||
let prompt = argument
|
||||
.map(|argument| argument.trim())
|
||||
.filter(|argument| !argument.is_empty())
|
||||
.map(str::to_owned);
|
||||
if let Some(prompt) = prompt {
|
||||
// `/handoff query` auto-submits, same as `& query`.
|
||||
let attachments = self.collect_cloud_launch_attachments(ctx);
|
||||
let launch = PendingCloudLaunch {
|
||||
prompt,
|
||||
attachments,
|
||||
};
|
||||
ctx.dispatch_typed_action_deferred(
|
||||
WorkspaceAction::OpenLocalToCloudHandoffPane {
|
||||
launch: Some(launch),
|
||||
environment_id: None,
|
||||
entry_point: HandoffEntryPoint::SlashCommand,
|
||||
},
|
||||
);
|
||||
} else if self.source_conversation_has_content(ctx) {
|
||||
// Empty `/handoff` with a non-empty source conversation:
|
||||
// dispatch the immediate empty-prompt handoff (continue /
|
||||
// snapshot rehydration); the workspace synthesizes the
|
||||
// launch and collects attachments.
|
||||
ctx.dispatch_typed_action_deferred(
|
||||
WorkspaceAction::OpenLocalToCloudHandoffPane {
|
||||
launch: None,
|
||||
environment_id: None,
|
||||
entry_point: HandoffEntryPoint::SlashCommand,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
ctx.dispatch_typed_action(&TerminalAction::ToggleContextView);
|
||||
// Empty `/handoff` with no source content — surface a toast
|
||||
// so the user knows why nothing happened. The chip falls
|
||||
// back to `&` compose mode here; the slash-command flow
|
||||
// does not because it has no compose-draft state to seed.
|
||||
show_error_toast(
|
||||
"Nothing to hand off — start a conversation first.".to_owned(),
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
}
|
||||
_settings if command.name == commands::SETTINGS.name => {
|
||||
ctx.dispatch_typed_action(&TerminalAction::ToggleSettingsView);
|
||||
}
|
||||
_fork if command.name == commands::FORK.name => {
|
||||
fork if command.name == commands::FORK.name => {
|
||||
let Some(conversation_id) = self
|
||||
.ai_context_model
|
||||
.as_ref(ctx)
|
||||
@@ -725,11 +971,15 @@ impl Input {
|
||||
return true;
|
||||
};
|
||||
|
||||
let destination = if trigger.is_cmd_or_ctrl_enter() {
|
||||
ForkedConversationDestination::NewTab
|
||||
} else {
|
||||
ForkedConversationDestination::SplitPane
|
||||
};
|
||||
let destination =
|
||||
ForkedConversationDestination::for_fork_trigger(trigger.is_cmd_or_ctrl_enter());
|
||||
|
||||
// Move any pending attachments out of the source input so they travel with the
|
||||
// initial prompt into the forked pane and no longer linger on the original input.
|
||||
// Only drain them when a non-empty prompt will actually be sent; the fork drops
|
||||
// attachments when there is no initial prompt, which would silently discard them.
|
||||
let initial_attachments =
|
||||
self.maybe_take_attachments_for_initial_prompt(argument, ctx);
|
||||
|
||||
ctx.dispatch_typed_action(&WorkspaceAction::ForkAIConversation {
|
||||
conversation_id,
|
||||
@@ -737,6 +987,7 @@ impl Input {
|
||||
summarize_after_fork: false,
|
||||
summarization_prompt: None,
|
||||
initial_prompt: argument.cloned(),
|
||||
initial_attachments,
|
||||
destination,
|
||||
});
|
||||
}
|
||||
@@ -744,7 +995,55 @@ impl Input {
|
||||
self.open_user_query_menu(UserQueryMenuAction::ForkFrom, ctx);
|
||||
return true;
|
||||
}
|
||||
_fork_and_compact if command.name == commands::FORK_AND_COMPACT.name => {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
continue_locally if command.name == commands::CONTINUE_LOCALLY.name => {
|
||||
let Some(conversation_id) = self
|
||||
.ai_context_model
|
||||
.as_ref(ctx)
|
||||
.selected_conversation_id(ctx)
|
||||
else {
|
||||
show_error_toast(
|
||||
"/continue-locally requires an active conversation".to_owned(),
|
||||
ctx,
|
||||
);
|
||||
return true;
|
||||
};
|
||||
|
||||
if !conversation_is_cloud_oz_for_slash_command(conversation_id, ctx) {
|
||||
show_error_toast(
|
||||
"/continue-locally is only available for cloud Oz conversations".to_owned(),
|
||||
ctx,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
let destination =
|
||||
ForkedConversationDestination::for_fork_trigger(trigger.is_cmd_or_ctrl_enter());
|
||||
|
||||
send_telemetry_from_ctx!(
|
||||
AgentManagementTelemetryEvent::SlashCommandContinueLocally,
|
||||
ctx
|
||||
);
|
||||
|
||||
// Move any pending attachments out of the source input so they travel with the
|
||||
// initial prompt into the continued local pane and no longer linger on the
|
||||
// original input. Only drain them when a non-empty prompt will actually be sent;
|
||||
// the fork drops attachments when there is no initial prompt, which would
|
||||
// silently discard them.
|
||||
let initial_attachments =
|
||||
self.maybe_take_attachments_for_initial_prompt(argument, ctx);
|
||||
|
||||
ctx.dispatch_typed_action(&WorkspaceAction::ForkAIConversation {
|
||||
conversation_id,
|
||||
fork_from_exchange: None,
|
||||
summarize_after_fork: false,
|
||||
summarization_prompt: None,
|
||||
initial_prompt: argument.cloned(),
|
||||
initial_attachments,
|
||||
destination,
|
||||
});
|
||||
}
|
||||
fork_and_compact if command.name == commands::FORK_AND_COMPACT.name => {
|
||||
let Some(conversation_id) = self
|
||||
.ai_context_model
|
||||
.as_ref(ctx)
|
||||
@@ -757,11 +1056,8 @@ impl Input {
|
||||
return true;
|
||||
};
|
||||
|
||||
let destination = if trigger.is_cmd_or_ctrl_enter() {
|
||||
ForkedConversationDestination::SplitPane
|
||||
} else {
|
||||
ForkedConversationDestination::CurrentPane
|
||||
};
|
||||
let destination =
|
||||
ForkedConversationDestination::for_fork_trigger(trigger.is_cmd_or_ctrl_enter());
|
||||
|
||||
ctx.dispatch_typed_action(&WorkspaceAction::ForkAIConversation {
|
||||
conversation_id,
|
||||
@@ -769,27 +1065,50 @@ impl Input {
|
||||
summarize_after_fork: true,
|
||||
summarization_prompt: None,
|
||||
initial_prompt: argument.cloned(),
|
||||
initial_attachments: vec![],
|
||||
destination,
|
||||
});
|
||||
}
|
||||
_compact_and if command.name == commands::COMPACT_AND.name => {
|
||||
if self
|
||||
.ai_context_model
|
||||
.as_ref(ctx)
|
||||
.selected_conversation_id(ctx)
|
||||
.is_none()
|
||||
{
|
||||
show_error_toast(
|
||||
"/compact-and requires an active conversation".to_owned(),
|
||||
ctx,
|
||||
);
|
||||
return true;
|
||||
compact_and if command.name == commands::COMPACT_AND.name => {
|
||||
let conversation_id = if is_queued_prompt {
|
||||
let Some(conversation_id) = queued_conversation_id else {
|
||||
log::error!("Queued /compact-and missing conversation id");
|
||||
return true;
|
||||
};
|
||||
conversation_id
|
||||
} else {
|
||||
let Some(conversation_id) = self
|
||||
.ai_context_model
|
||||
.as_ref(ctx)
|
||||
.selected_conversation_id(ctx)
|
||||
else {
|
||||
show_error_toast(
|
||||
"/compact-and requires an active conversation".to_owned(),
|
||||
ctx,
|
||||
);
|
||||
return true;
|
||||
};
|
||||
conversation_id
|
||||
};
|
||||
|
||||
ctx.dispatch_typed_action(&WorkspaceAction::SummarizeAIConversation {
|
||||
prompt: None,
|
||||
initial_prompt: argument.cloned(),
|
||||
});
|
||||
if is_queued_prompt {
|
||||
let Some(queued_query_id) = queued_query_id else {
|
||||
log::error!("Queued /compact-and missing queued query id");
|
||||
return true;
|
||||
};
|
||||
self.execute_queued_compact_and(
|
||||
conversation_id,
|
||||
queued_query_id,
|
||||
argument.cloned(),
|
||||
ctx,
|
||||
);
|
||||
} else {
|
||||
let summarize = WorkspaceAction::SummarizeAIConversation {
|
||||
prompt: None,
|
||||
initial_prompt: argument.cloned(),
|
||||
};
|
||||
ctx.dispatch_typed_action(&summarize);
|
||||
}
|
||||
}
|
||||
_queue if command.name == commands::QUEUE.name => {
|
||||
let Some(conversation_id) = self
|
||||
@@ -807,17 +1126,34 @@ impl Input {
|
||||
};
|
||||
|
||||
let history = BlocklistAIHistoryModel::handle(ctx);
|
||||
let is_in_progress = history
|
||||
// An empty conversation defaults to `InProgress` even though nothing is
|
||||
// running, so exclude it here to auto-send rather than queue.
|
||||
let should_queue = history
|
||||
.as_ref(ctx)
|
||||
.conversation(&conversation_id)
|
||||
.is_some_and(|c| c.status().is_in_progress() || c.status().is_blocked());
|
||||
.is_some_and(|c| {
|
||||
!c.is_empty() && (c.status().is_in_progress() || c.status().is_blocked())
|
||||
});
|
||||
|
||||
if is_in_progress {
|
||||
ctx.dispatch_typed_action(&WorkspaceAction::QueuePromptForConversation {
|
||||
prompt,
|
||||
if should_queue {
|
||||
let attachments = self.ai_context_model.update(ctx, |context_model, ctx| {
|
||||
context_model.take_pending_attachments(ctx)
|
||||
});
|
||||
QueuedQueryModel::handle(ctx).update(ctx, |model, ctx| {
|
||||
model.append(
|
||||
conversation_id,
|
||||
QueuedQuery::new_with_attachments(
|
||||
prompt,
|
||||
QueuedQueryOrigin::QueueSlashCommand,
|
||||
attachments,
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
} else {
|
||||
self.submit_queued_prompt(prompt, ctx);
|
||||
// Not in progress: submit immediately as a regular (non-queued) user query so
|
||||
// the live staging is sent and reset, rather than treated as a queued-row fire.
|
||||
self.submit_user_query_now(prompt, ctx);
|
||||
}
|
||||
}
|
||||
_open_repo if command.name == commands::OPEN_REPO.name => {
|
||||
@@ -826,10 +1162,8 @@ impl Input {
|
||||
}
|
||||
self.open_repos_menu(ctx);
|
||||
}
|
||||
_command_that_just_sends_ai_request_with_prefix
|
||||
if command.name == commands::COMPACT.name
|
||||
|| command.name == commands::PLAN.name
|
||||
|| command.name == commands::ORCHESTRATE.name =>
|
||||
command_that_just_sends_ai_request_with_prefix
|
||||
if slash_command_is_submitted_as_prompt(command) =>
|
||||
{
|
||||
// These slash commands just send AI requests with the slash command text as a
|
||||
// prefix, and special handling is done downstream as an implementation detail
|
||||
@@ -897,9 +1231,17 @@ impl Input {
|
||||
self.suggestions_mode_model.as_ref(ctx).mode(),
|
||||
InputSuggestionsMode::SlashCommands
|
||||
) {
|
||||
self.inline_slash_commands_view.update(ctx, |view, ctx| {
|
||||
view.accept_selected_item(true, ctx);
|
||||
});
|
||||
if self.is_cloud_mode_input_v2_composing(ctx) {
|
||||
if let Some(view) = self.cloud_mode_v2_slash_commands_view.clone() {
|
||||
view.update(ctx, |view, ctx| {
|
||||
view.accept_selected_item(true, ctx);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
self.inline_slash_commands_view.update(ctx, |view, ctx| {
|
||||
view.accept_selected_item(true, ctx);
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -908,20 +1250,28 @@ impl Input {
|
||||
SlashCommandEntryState::SlashCommand(detected_command) => {
|
||||
let command = detected_command.command.clone();
|
||||
let argument = detected_command.argument.clone();
|
||||
if !self.is_slash_command_available(&command, ctx) {
|
||||
return false;
|
||||
}
|
||||
self.execute_slash_command(
|
||||
&command,
|
||||
argument.as_ref(),
|
||||
SlashCommandTrigger::cmd_or_ctrl_enter(),
|
||||
/*is_queued_prompt*/ false,
|
||||
None,
|
||||
None,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
SlashCommandEntryState::SkillCommand(_)
|
||||
if self.is_cloud_mode_input_v2_composing(ctx) =>
|
||||
{
|
||||
false
|
||||
}
|
||||
SlashCommandEntryState::SkillCommand(detected_skill) => {
|
||||
let reference = detected_skill.reference.clone();
|
||||
let user_query = detected_skill.argument.clone();
|
||||
self.execute_skill_command(
|
||||
reference, user_query, /*is_queued_prompt*/ false, ctx,
|
||||
)
|
||||
self.execute_skill_command(reference, user_query, None, None, ctx)
|
||||
}
|
||||
SlashCommandEntryState::None
|
||||
| SlashCommandEntryState::Composing { .. }
|
||||
@@ -929,6 +1279,41 @@ impl Input {
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_v2_slash_section_filter(
|
||||
&mut self,
|
||||
section: CloudModeV2Section,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
self.editor.update(ctx, |editor, ctx| {
|
||||
editor.set_buffer_text("/", ctx);
|
||||
});
|
||||
if let Some(view) = self.cloud_mode_v2_slash_commands_view.clone() {
|
||||
view.update(ctx, |v, ctx| {
|
||||
v.set_section_filter(Some(section), ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn maybe_clear_v2_slash_section_filter(
|
||||
&mut self,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> bool {
|
||||
if !self.is_cloud_mode_input_v2_composing(ctx) {
|
||||
return false;
|
||||
}
|
||||
let Some(view) = self.cloud_mode_v2_slash_commands_view.clone() else {
|
||||
return false;
|
||||
};
|
||||
let has_filter = view.as_ref(ctx).has_section_filter();
|
||||
if !has_filter {
|
||||
return false;
|
||||
}
|
||||
view.update(ctx, |v, ctx| {
|
||||
v.set_section_filter(None, ctx);
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
/// Executes a slash command on `enter` keypress.
|
||||
///
|
||||
/// If the slash command menu is open, then "accepts" the slash command:
|
||||
@@ -948,9 +1333,17 @@ impl Input {
|
||||
self.suggestions_mode_model.as_ref(ctx).mode(),
|
||||
InputSuggestionsMode::SlashCommands
|
||||
) {
|
||||
self.inline_slash_commands_view.update(ctx, |view, ctx| {
|
||||
view.accept_selected_item(false, ctx);
|
||||
});
|
||||
if self.is_cloud_mode_input_v2_composing(ctx) {
|
||||
if let Some(view) = self.cloud_mode_v2_slash_commands_view.clone() {
|
||||
view.update(ctx, |view, ctx| {
|
||||
view.accept_selected_item(false, ctx);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
self.inline_slash_commands_view.update(ctx, |view, ctx| {
|
||||
view.accept_selected_item(false, ctx);
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -958,24 +1351,169 @@ impl Input {
|
||||
SlashCommandEntryState::SlashCommand(detected_command) => {
|
||||
let command = detected_command.command.clone();
|
||||
let argument = detected_command.argument.clone();
|
||||
if !self.is_slash_command_available(&command, ctx) {
|
||||
return false;
|
||||
}
|
||||
self.execute_slash_command(
|
||||
&command,
|
||||
argument.as_ref(),
|
||||
SlashCommandTrigger::input(),
|
||||
/*is_queued_prompt*/ false,
|
||||
None,
|
||||
None,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
SlashCommandEntryState::SkillCommand(_)
|
||||
if self.is_cloud_mode_input_v2_composing(ctx) =>
|
||||
{
|
||||
false
|
||||
}
|
||||
SlashCommandEntryState::SkillCommand(detected_skill) => {
|
||||
let reference = detected_skill.reference.clone();
|
||||
let user_query = detected_skill.argument.clone();
|
||||
self.execute_skill_command(
|
||||
reference, user_query, /*is_queued_prompt*/ false, ctx,
|
||||
)
|
||||
self.execute_skill_command(reference, user_query, None, None, ctx)
|
||||
}
|
||||
SlashCommandEntryState::None
|
||||
| SlashCommandEntryState::Composing { .. }
|
||||
| SlashCommandEntryState::DisabledUntilEmptyBuffer => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Drains pending attachments from the input's context model, but only when `argument`
|
||||
/// contains a non-empty prompt. Forked conversations drop attachments when there is no
|
||||
/// initial prompt to send, so draining them unconditionally would silently discard them;
|
||||
/// leaving them staged in the source input instead loses nothing.
|
||||
fn maybe_take_attachments_for_initial_prompt(
|
||||
&mut self,
|
||||
argument: Option<&String>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Vec<PendingAttachment> {
|
||||
if argument.is_none_or(|argument| argument.trim().is_empty()) {
|
||||
return Vec::new();
|
||||
}
|
||||
self.ai_context_model.update(ctx, |context_model, ctx| {
|
||||
context_model.take_pending_attachments(ctx)
|
||||
})
|
||||
}
|
||||
|
||||
/// Sends a queued `/compact-and` summary and stores its follow-up on the original conversation.
|
||||
pub(super) fn execute_queued_compact_and(
|
||||
&mut self,
|
||||
conversation_id: AIConversationId,
|
||||
queued_query_id: QueuedQueryId,
|
||||
initial_prompt: Option<String>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
let followup_attachments = QueuedQueryModel::as_ref(ctx)
|
||||
.attachments_for(conversation_id, queued_query_id)
|
||||
.to_vec();
|
||||
self.ai_controller.update(ctx, move |controller, ctx| {
|
||||
controller.send_queued_slash_command_request(
|
||||
SlashCommandRequest::Summarize { prompt: None },
|
||||
queued_query_id,
|
||||
Some(conversation_id),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
let Some(initial_prompt) = initial_prompt.filter(|prompt| !prompt.trim().is_empty()) else {
|
||||
return;
|
||||
};
|
||||
QueuedQueryModel::handle(ctx).update(ctx, |model, ctx| {
|
||||
model.append(
|
||||
conversation_id,
|
||||
QueuedQuery::new_with_attachments(
|
||||
initial_prompt,
|
||||
QueuedQueryOrigin::CompactAndSlashCommand,
|
||||
followup_attachments,
|
||||
),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether executing the static slash `command` submits its text to the conversation as an AI
|
||||
/// prompt (handled downstream like a normal user query) rather than performing an immediate
|
||||
/// local action.
|
||||
///
|
||||
/// This is the single source of truth for the "reiterated as a prompt vs handled immediately"
|
||||
/// distinction: only `/compact`, `/plan`, and `/orchestrate` are sent as prompts (mirroring the
|
||||
/// `command_that_just_sends_ai_request_with_prefix` arm in [`Input::execute_slash_command`]).
|
||||
/// Every other slash command emits an immediate action (forking, switching model, opening a
|
||||
/// menu, etc.), so callers gating prompt queuing or shared-session forwarding should treat those
|
||||
/// as "run now".
|
||||
pub(crate) fn slash_command_is_submitted_as_prompt(command: &StaticCommand) -> bool {
|
||||
command.name == commands::COMPACT.name
|
||||
|| command.name == commands::PLAN.name
|
||||
|| command.name == commands::ORCHESTRATE.name
|
||||
}
|
||||
|
||||
/// Returns true when the conversation with `conversation_id` is associated with an Oz
|
||||
/// `AmbientAgentTask`. Callers deciding between `/fork` and `/continue-locally` should also
|
||||
/// check the same `CLOUD_AGENT` context that gates `/continue-locally`.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub(crate) fn conversation_is_cloud_oz_for_slash_command(
|
||||
conversation_id: AIConversationId,
|
||||
ctx: &AppContext,
|
||||
) -> bool {
|
||||
let history = BlocklistAIHistoryModel::as_ref(ctx);
|
||||
let Some(conversation) = history.conversation(&conversation_id) else {
|
||||
return false;
|
||||
};
|
||||
let Some(task_id) = conversation.task_id() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let Some(task) = AgentConversationsModel::as_ref(ctx).get_task_data(&task_id) else {
|
||||
// Permissive: not yet fetched. Matches the data-source default so the command isn't
|
||||
// wrongly blocked while the task fetch is in flight.
|
||||
return true;
|
||||
};
|
||||
|
||||
match task
|
||||
.agent_config_snapshot
|
||||
.as_ref()
|
||||
.and_then(|s| s.harness.as_ref())
|
||||
{
|
||||
Some(config) => config.harness_type == Harness::Oz,
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Tooltip and slash command name for the fork button, returned as a unit so
|
||||
/// callers rendering the button and callers inserting the command always agree.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub(crate) struct ForkButtonAction {
|
||||
pub tooltip: &'static str,
|
||||
pub command_name: &'static str,
|
||||
}
|
||||
|
||||
/// Returns the tooltip and slash command for the fork button given an optional
|
||||
/// conversation ID. Uses `/continue-locally` for Oz conversations when `/fork`
|
||||
/// is unavailable in the current cloud-agent context, and `/fork` otherwise.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub(crate) fn fork_button_action(
|
||||
conversation_id: Option<AIConversationId>,
|
||||
is_cloud_agent_context: bool,
|
||||
ctx: &AppContext,
|
||||
) -> ForkButtonAction {
|
||||
if is_cloud_agent_context
|
||||
&& conversation_id.is_some_and(|id| conversation_is_cloud_oz_for_slash_command(id, ctx))
|
||||
{
|
||||
ForkButtonAction {
|
||||
tooltip: "Continue locally",
|
||||
command_name: commands::CONTINUE_LOCALLY.name,
|
||||
}
|
||||
} else {
|
||||
ForkButtonAction {
|
||||
tooltip: "Fork conversation",
|
||||
command_name: commands::FORK.name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
use super::slash_command_is_submitted_as_prompt;
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::search::slash_command_menu::static_commands::{commands, Availability};
|
||||
const BASELINE_AVAILABILITY: Availability = Availability::AGENT_VIEW
|
||||
.union(Availability::AI_ENABLED)
|
||||
.union(Availability::NO_LRC_CONTROL);
|
||||
|
||||
/// The centralized classifier must mark only the prompt-submitting commands (/compact, /plan,
|
||||
/// /orchestrate) as "submitted as a prompt". Every other slash command emits an immediate action
|
||||
/// and must be treated as "run now" by the prompt-queue gate and the shared-session viewer path.
|
||||
#[test]
|
||||
fn slash_command_is_submitted_as_prompt_only_for_prompt_commands() {
|
||||
// Prompt-submitting commands reiterate their text into the conversation.
|
||||
assert!(slash_command_is_submitted_as_prompt(&commands::COMPACT));
|
||||
assert!(slash_command_is_submitted_as_prompt(&commands::PLAN));
|
||||
assert!(slash_command_is_submitted_as_prompt(&commands::ORCHESTRATE));
|
||||
|
||||
// Action-emitting commands run immediately and are never queued / forwarded as prompts.
|
||||
assert!(!slash_command_is_submitted_as_prompt(&commands::FORK));
|
||||
assert!(!slash_command_is_submitted_as_prompt(
|
||||
&commands::FORK_AND_COMPACT
|
||||
));
|
||||
assert!(!slash_command_is_submitted_as_prompt(&commands::FORK_FROM));
|
||||
assert!(!slash_command_is_submitted_as_prompt(
|
||||
&commands::CONTINUE_LOCALLY
|
||||
));
|
||||
assert!(!slash_command_is_submitted_as_prompt(
|
||||
&commands::COMPACT_AND
|
||||
));
|
||||
assert!(!slash_command_is_submitted_as_prompt(&commands::MODEL));
|
||||
assert!(!slash_command_is_submitted_as_prompt(&commands::REWIND));
|
||||
assert!(!slash_command_is_submitted_as_prompt(
|
||||
&commands::CONVERSATIONS
|
||||
));
|
||||
assert!(!slash_command_is_submitted_as_prompt(&commands::QUEUE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_cloud_agent_commands_are_only_active_outside_cloud_mode() {
|
||||
let local_context = BASELINE_AVAILABILITY | Availability::NOT_CLOUD_AGENT;
|
||||
assert!(commands::AGENT.is_active(local_context));
|
||||
assert!(commands::NEW.is_active(local_context));
|
||||
|
||||
let cloud_context = BASELINE_AVAILABILITY;
|
||||
assert!(!commands::AGENT.is_active(cloud_context));
|
||||
assert!(!commands::NEW.is_active(cloud_context));
|
||||
|
||||
let _cloud_mode_input_v2 = FeatureFlag::CloudModeInputV2.override_enabled(true);
|
||||
let cloud_mode_v2_context = BASELINE_AVAILABILITY | Availability::CLOUD_MODE_V2_COMPOSER;
|
||||
assert!(!commands::AGENT.is_active(cloud_mode_v2_context));
|
||||
assert!(!commands::NEW.is_active(cloud_mode_v2_context));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cloud_mode_v2_commands_are_active_only_in_cloud_mode_v2_context() {
|
||||
let cloud_context = BASELINE_AVAILABILITY;
|
||||
assert!(!commands::HARNESS.is_active(cloud_context));
|
||||
|
||||
let _cloud_mode_input_v2 = FeatureFlag::CloudModeInputV2.override_enabled(true);
|
||||
let cloud_mode_v2_context = BASELINE_AVAILABILITY | Availability::CLOUD_MODE_V2_COMPOSER;
|
||||
assert!(commands::PLAN.is_active(cloud_mode_v2_context));
|
||||
assert!(commands::MODEL.is_active(cloud_mode_v2_context));
|
||||
assert!(commands::HARNESS.is_active(cloud_mode_v2_context));
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "local_fs", windows))]
|
||||
mod windows {
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::super::*;
|
||||
use crate::terminal::model::session::command_executor::testing::TestCommandExecutor;
|
||||
use crate::terminal::model::session::SessionInfo;
|
||||
use crate::terminal::shell::ShellType;
|
||||
use crate::terminal::ShellLaunchData;
|
||||
|
||||
fn wsl_session() -> Session {
|
||||
Session::new(
|
||||
SessionInfo::new_for_test().with_shell_type(ShellType::Bash),
|
||||
Arc::new(TestCommandExecutor::default()),
|
||||
)
|
||||
.with_shell_launch_data(ShellLaunchData::WSL {
|
||||
distro: "Ubuntu".to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_file_command_converts_wsl_paths_to_host_paths() {
|
||||
let session = wsl_session();
|
||||
let cases = [
|
||||
(
|
||||
"/home/ubuntu",
|
||||
"subdir/test.txt",
|
||||
r"\\WSL$\Ubuntu\home\ubuntu\subdir\test.txt",
|
||||
None,
|
||||
),
|
||||
(
|
||||
"/home/ubuntu/project",
|
||||
"../test.txt",
|
||||
r"\\WSL$\Ubuntu\home\ubuntu\test.txt",
|
||||
None,
|
||||
),
|
||||
(
|
||||
"/home/ubuntu",
|
||||
"subdir/file\\ name.txt",
|
||||
r"\\WSL$\Ubuntu\home\ubuntu\subdir\file name.txt",
|
||||
None,
|
||||
),
|
||||
(
|
||||
"/home/ubuntu",
|
||||
"subdir/test.txt:4:2",
|
||||
r"\\WSL$\Ubuntu\home\ubuntu\subdir\test.txt",
|
||||
Some(LineAndColumnArg {
|
||||
line_num: 4,
|
||||
column_num: Some(2),
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
||||
for (current_dir, raw_arg, expected_path, expected_line_col) in cases {
|
||||
let (path, line_col) = open_file_command_path(&session, current_dir, raw_arg);
|
||||
|
||||
assert_eq!(path, PathBuf::from(expected_path));
|
||||
assert_eq!(line_col, expected_line_col);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,14 @@ use galaxyui::prelude::{ConstrainedBox, Container, CrossAxisAlignment, Empty, Fl
|
||||
use galaxyui::{AppContext, Element, SingletonEntity};
|
||||
use ordered_float::OrderedFloat;
|
||||
|
||||
use super::{AcceptSlashCommandOrSavedPrompt, InlineItem};
|
||||
use crate::ai::blocklist::agent_view::shortcuts::render_keystroke_with_color_overrides;
|
||||
use crate::search::item::SearchItemDetail;
|
||||
use crate::search::slash_command_menu::static_commands::commands::COMMAND_REGISTRY;
|
||||
use crate::search::{ItemHighlightState, SearchItem};
|
||||
use crate::terminal::input::inline_menu::styles as inline_styles;
|
||||
use crate::util::bindings::keybinding_name_to_keystroke;
|
||||
|
||||
use super::{AcceptSlashCommandOrSavedPrompt, InlineItem};
|
||||
|
||||
fn inline_width_for_name_column(app: &AppContext) -> f32 {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
@@ -90,7 +90,7 @@ impl SearchItem for InlineItem {
|
||||
};
|
||||
|
||||
let name_element = if let Some(keystroke) = keystroke {
|
||||
Flex::row()
|
||||
let mut row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(name_text.finish())
|
||||
.with_child(
|
||||
@@ -111,17 +111,23 @@ impl SearchItem for InlineItem {
|
||||
))
|
||||
.with_margin_left(4.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(Shrinkable::new(1., Empty::new().finish()).finish())
|
||||
.finish()
|
||||
);
|
||||
if !self.compact_layout {
|
||||
row = row.with_child(Shrinkable::new(1., Empty::new().finish()).finish());
|
||||
}
|
||||
row.finish()
|
||||
} else {
|
||||
name_text.finish()
|
||||
};
|
||||
|
||||
row.add_child(if self.description.is_some() {
|
||||
ConstrainedBox::new(name_element)
|
||||
.with_width(inline_width_for_name_column(app))
|
||||
.finish()
|
||||
if self.compact_layout {
|
||||
Container::new(name_element).with_margin_right(8.).finish()
|
||||
} else {
|
||||
ConstrainedBox::new(name_element)
|
||||
.with_width(inline_width_for_name_column(app))
|
||||
.finish()
|
||||
}
|
||||
} else {
|
||||
name_element
|
||||
});
|
||||
@@ -171,4 +177,12 @@ impl SearchItem for InlineItem {
|
||||
fn accessibility_label(&self) -> String {
|
||||
format!("{:?}", self.action)
|
||||
}
|
||||
|
||||
fn detail_data(&self) -> Option<SearchItemDetail> {
|
||||
Some(SearchItemDetail {
|
||||
title: self.name.clone(),
|
||||
description: self.description.clone(),
|
||||
title_font_family: self.font_family,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ use galaxyui::elements::ChildView;
|
||||
use galaxyui::{AppContext, Element, ViewContext};
|
||||
use galaxyui::{Entity, ModelHandle, View, ViewHandle};
|
||||
use lazy_static::lazy_static;
|
||||
use galaxyui::{AppContext, Element, Entity, ModelHandle, View, ViewContext, ViewHandle};
|
||||
|
||||
use crate::ai::blocklist::agent_view::AgentViewController;
|
||||
use crate::search::data_source::{Query, QueryFilter};
|
||||
@@ -13,11 +14,10 @@ use crate::search::slash_command_menu::SlashCommandId;
|
||||
use crate::server::ids::SyncId;
|
||||
use crate::terminal::input::buffer_model::InputBufferModel;
|
||||
use crate::terminal::input::inline_menu::{InlineMenuEvent, InlineMenuPositioner, InlineMenuView};
|
||||
use crate::terminal::input::slash_command_model::SlashCommandEntryState;
|
||||
use crate::terminal::input::slash_command_model::SlashCommandModel;
|
||||
use crate::terminal::input::slash_commands::UpdatedActiveCommands;
|
||||
use crate::terminal::input::slash_command_model::{SlashCommandEntryState, SlashCommandModel};
|
||||
use crate::terminal::input::slash_commands::{
|
||||
AcceptSlashCommandOrSavedPrompt, SlashCommandDataSource, ZeroStateDataSource,
|
||||
AcceptSlashCommandOrSavedPrompt, SlashCommandDataSource, UpdatedActiveCommands,
|
||||
ZeroStateDataSource,
|
||||
};
|
||||
use crate::terminal::input::suggestions_mode_model::{
|
||||
InputSuggestionsModeEvent, InputSuggestionsModeModel,
|
||||
@@ -100,7 +100,8 @@ impl InlineSlashCommandView {
|
||||
});
|
||||
},
|
||||
);
|
||||
let zero_state_source = ctx.add_model(|_| ZeroStateDataSource::new(&slash_commands_source));
|
||||
let zero_state_source =
|
||||
ctx.add_model(|_| ZeroStateDataSource::new(&slash_commands_source, false));
|
||||
let saved_prompts_source = super::saved_prompts_data_source();
|
||||
|
||||
let mixer = ctx.add_model(|ctx| {
|
||||
@@ -273,9 +274,7 @@ impl View for InlineSlashCommandView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a Query that includes the StaticSlashCommands filter so both sync and
|
||||
/// async sources run.
|
||||
fn slash_command_query(text: &str) -> Query {
|
||||
pub(super) fn slash_command_query(text: &str) -> Query {
|
||||
Query {
|
||||
text: text.to_owned(),
|
||||
filters: SLASH_COMMAND_FILTERS.clone(),
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
//! - StaticWorkflowEnumSuggestions
|
||||
//! - DynamicWorkflowEnumSuggestions
|
||||
|
||||
use warpui::elements::{
|
||||
Align, Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, DragBarSide,
|
||||
DropShadow, Element, Empty, Flex, ParentElement, Radius, Resizable, Shrinkable,
|
||||
SizeConstraintCondition, SizeConstraintSwitch, Text,
|
||||
};
|
||||
use warpui::presenter::ChildView;
|
||||
use warpui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
|
||||
|
||||
use super::{
|
||||
DynamicEnumSuggestionStatus, Input, InputAction, MenuPositioning, DYNAMIC_ENUM_FAILURE_MESSAGE,
|
||||
DYNAMIC_ENUM_GENERATE_MESSAGE, DYNAMIC_ENUM_HORIZONTAL_TEXT_PADDING,
|
||||
@@ -18,14 +26,7 @@ use crate::input_suggestions::{
|
||||
DETAILS_PANEL_MARGIN, DETAILS_PANEL_PADDING, HISTORY_DETAILS_PANEL_WIDTH,
|
||||
LABEL_PADDING as InputSuggestionsLabelPadding,
|
||||
};
|
||||
use crate::themes::theme::GalaxyTheme;
|
||||
use galaxyui::elements::{
|
||||
Align, Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, DragBarSide,
|
||||
DropShadow, Element, Empty, Flex, ParentElement, Radius, Resizable, Shrinkable,
|
||||
SizeConstraintCondition, SizeConstraintSwitch, Text,
|
||||
};
|
||||
use galaxyui::presenter::ChildView;
|
||||
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
|
||||
use crate::themes::theme::WarpTheme;
|
||||
|
||||
enum SuggestionsResizeConfig {
|
||||
WidthAndHeight,
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
use galaxyui::{Entity, ModelContext, ModelHandle};
|
||||
|
||||
use super::{BufferState, DynamicEnumSuggestionStatus, InputConfig, InputSuggestionsMode};
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::terminal::input::buffer_model::InputBufferModel;
|
||||
use crate::terminal::input::inline_menu::InlineMenuType;
|
||||
|
||||
use super::{BufferState, DynamicEnumSuggestionStatus, InputConfig, InputSuggestionsMode};
|
||||
|
||||
/// Model responsible for managing the input suggestions mode state.
|
||||
pub struct InputSuggestionsModeModel {
|
||||
mode: InputSuggestionsMode,
|
||||
|
||||
@@ -1,31 +1,25 @@
|
||||
use super::{
|
||||
common::{
|
||||
add_command_xray_overlay, add_input_suggestions_overlays, add_voltron_overlay,
|
||||
add_workflow_info_overlay, should_show_terminal_input_message_bar,
|
||||
wrap_input_with_terminal_padding_and_focus_handler,
|
||||
},
|
||||
Input, InputAction, InputDropTargetData,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
context_chips::spacing,
|
||||
features::FeatureFlag,
|
||||
settings::{AppEditorSettings, InputModeSettings},
|
||||
terminal::{
|
||||
block_list_settings::BlockListSettings, block_list_viewport::InputMode,
|
||||
settings::TerminalSettings, view::TerminalAction,
|
||||
},
|
||||
};
|
||||
use galaxy_core::settings::Setting;
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
Border, Clipped, Container, DropTarget, Element, Flex, Hoverable, ParentElement,
|
||||
SavePosition, Stack,
|
||||
},
|
||||
presenter::ChildView,
|
||||
AppContext, SingletonEntity,
|
||||
use galaxyui::elements::{
|
||||
Border, Clipped, Container, DropTarget, Element, Flex, Hoverable, ParentElement, SavePosition,
|
||||
Stack,
|
||||
};
|
||||
use warpui::presenter::ChildView;
|
||||
use warpui::{AppContext, SingletonEntity};
|
||||
|
||||
use super::common::{
|
||||
add_command_xray_overlay, add_input_suggestions_overlays, add_voltron_overlay,
|
||||
add_workflow_info_overlay, should_show_terminal_input_message_bar,
|
||||
wrap_input_with_terminal_padding_and_focus_handler,
|
||||
};
|
||||
use super::{Input, InputAction, InputDropTargetData};
|
||||
use crate::appearance::Appearance;
|
||||
use crate::context_chips::spacing;
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::settings::{AppEditorSettings, InputModeSettings};
|
||||
use crate::terminal::block_list_settings::BlockListSettings;
|
||||
use crate::terminal::block_list_viewport::InputMode;
|
||||
use crate::terminal::settings::TerminalSettings;
|
||||
use crate::terminal::view::TerminalAction;
|
||||
|
||||
impl Input {
|
||||
/// Renders the terminal mode input when `FeatureFlag::AgentView` is enabled and there is no
|
||||
|
||||
@@ -8,10 +8,8 @@ use parking_lot::FairMutex;
|
||||
use pathfinder_color::ColorU;
|
||||
|
||||
use super::buffer_model::InputBufferModel;
|
||||
use super::message_bar::{
|
||||
common::render_terminal_message, truncated_command_for_block, Message, MessageItem,
|
||||
MessageProvider,
|
||||
};
|
||||
use super::message_bar::common::render_terminal_message;
|
||||
use super::message_bar::{truncated_command_for_block, Message, MessageItem, MessageProvider};
|
||||
use crate::ai::blocklist::{
|
||||
BlocklistAIContextEvent, BlocklistAIContextModel, BlocklistAIInputModel,
|
||||
};
|
||||
@@ -263,10 +261,10 @@ impl MessageProvider<TerminalMessageArgs<'_>> for ContinueConversationMessagePro
|
||||
}
|
||||
|
||||
mod internal {
|
||||
use crate::terminal::{
|
||||
model::blocks::{BlockHeight, BlockHeightItem, BlockHeightSummary, RichContentItem},
|
||||
TerminalModel,
|
||||
use crate::terminal::model::blocks::{
|
||||
BlockHeight, BlockHeightItem, BlockHeightSummary, RichContentItem,
|
||||
};
|
||||
use crate::terminal::TerminalModel;
|
||||
|
||||
impl TerminalModel {
|
||||
pub(super) fn is_last_visible_item_agent_view_block(&self) -> bool {
|
||||
|
||||
@@ -1,34 +1,26 @@
|
||||
use crate::{
|
||||
ai::blocklist::InputType,
|
||||
appearance::Appearance,
|
||||
context_chips::spacing,
|
||||
features::FeatureFlag,
|
||||
settings::{AppEditorSettings, InputModeSettings},
|
||||
terminal::{
|
||||
block_list_viewport::InputMode,
|
||||
input::{InputAction, InputDropTargetData},
|
||||
settings::TerminalSettings,
|
||||
view::TerminalAction,
|
||||
},
|
||||
themes::theme::color::internal_colors,
|
||||
};
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
Border, ChildView, Container, CornerRadius, DropTarget, Element, Flex, Hoverable,
|
||||
ParentElement, Radius, SavePosition, Stack,
|
||||
},
|
||||
AppContext, SingletonEntity,
|
||||
};
|
||||
use settings::Setting;
|
||||
|
||||
use super::{
|
||||
common::{
|
||||
add_command_xray_overlay, add_input_suggestions_overlays, add_vim_status_to_stack,
|
||||
add_voltron_overlay, add_workflow_info_overlay, maybe_add_buy_credits_banner,
|
||||
wrap_input_with_terminal_padding_and_focus_handler,
|
||||
},
|
||||
Input,
|
||||
use galaxyui::elements::{
|
||||
Border, ChildView, Container, CornerRadius, DropTarget, Element, Flex, Hoverable,
|
||||
ParentElement, Radius, SavePosition, Stack,
|
||||
};
|
||||
use galaxyui::{AppContext, SingletonEntity};
|
||||
|
||||
use super::common::{
|
||||
add_command_xray_overlay, add_input_suggestions_overlays, add_vim_status_to_stack,
|
||||
add_voltron_overlay, add_workflow_info_overlay, maybe_add_buy_credits_banner,
|
||||
wrap_input_with_terminal_padding_and_focus_handler,
|
||||
};
|
||||
use super::Input;
|
||||
use crate::ai::blocklist::InputType;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::context_chips::spacing;
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::settings::{AppEditorSettings, InputModeSettings};
|
||||
use crate::terminal::block_list_viewport::InputMode;
|
||||
use crate::terminal::input::{InputAction, InputDropTargetData};
|
||||
use crate::terminal::settings::TerminalSettings;
|
||||
use crate::terminal::view::TerminalAction;
|
||||
use crate::themes::theme::color::internal_colors;
|
||||
|
||||
impl Input {
|
||||
/// Renders the universal input. This is used when `FeatureFlag::AgentView` is disabled and the
|
||||
|
||||
@@ -35,7 +35,7 @@ impl InlineMenuAction for SelectUserQuery {
|
||||
key: "enter".to_owned(),
|
||||
..Default::default()
|
||||
}),
|
||||
MessageItem::text(" current pane"),
|
||||
MessageItem::text(" new pane"),
|
||||
],
|
||||
move |ctx| {
|
||||
ctx.dispatch_typed_action(InlineMenuRowAction::Accept {
|
||||
@@ -64,7 +64,7 @@ impl InlineMenuAction for SelectUserQuery {
|
||||
items.push(MessageItem::clickable(
|
||||
vec![
|
||||
MessageItem::keystroke(modifier_keystroke),
|
||||
MessageItem::text(" new pane"),
|
||||
MessageItem::text(" new tab"),
|
||||
],
|
||||
move |ctx| {
|
||||
ctx.dispatch_typed_action(InlineMenuRowAction::Accept {
|
||||
|
||||
Reference in New Issue
Block a user