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

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+189 -70
View File
@@ -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
+1 -1
View File
@@ -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(..)
+22 -31
View File
@@ -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,
+56 -20
View File
@@ -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();
+24 -32
View File
@@ -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)),
))
+9 -13
View File
@@ -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)
}
}
+3 -5
View File
@@ -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 => (),
+10 -14
View File
@@ -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.
+11 -17
View File
@@ -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;
+104
View File
@@ -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() {
+1 -1
View File
@@ -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,
+25 -1
View File
@@ -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::{
+4 -4
View File
@@ -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() {
+60 -19
View File
@@ -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 {
+55 -11
View File
@@ -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);
+178 -41
View File
@@ -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,
+26 -6
View File
@@ -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
+1 -2
View File
@@ -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
})
}
}
+1 -2
View File
@@ -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::{
-1
View File
@@ -55,7 +55,6 @@ impl InlineReposMenuView {
},
ctx,
);
mixer.run_query(repos_query(""), ctx);
mixer
});
+1 -1
View File
@@ -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
+13 -14
View File
@@ -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 -2
View File
@@ -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 {
+29 -11
View File
@@ -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,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(),
);
}
}
+630 -92
View File
@@ -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, &current_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,
+20 -26
View File
@@ -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 {
+21 -29
View File
@@ -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
+2 -2
View File
@@ -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 {